PR

Kubernetes応用編 第03回

Kubernetes応用編 第03回
ステートフルなアプリの扱い方 — StatefulSet

3.1 はじめに

前回(第2回)では、RBACを使ってTaskBoardの各Namespaceにアクセス制御を適用しました。developerとoperatorのServiceAccountにそれぞれ異なる権限を設定し、「誰が何をできるか」を制御できるようになっています。

現時点のTaskBoardの構成を確認しておきましょう。

第2回終了時の構成:

[app Namespace]
  ├── Nginx (Deployment, 2レプリカ) + Service (ClusterIP)
  └── TaskBoard API (Deployment, 2レプリカ, インメモリ版) + Service (NodePort:30080)

[db Namespace]
  └── (空 — 本回でMySQL追加)

[monitoring Namespace]
  └── (空 — 第4回でログ収集DaemonSet追加予定)

基盤:
  ├── kindクラスタ (CP 1 + Worker 3)
  ├── Metrics Server 稼働中
  ├── 各NamespaceにResourceQuota / LimitRange適用済み
  └── 各NamespaceにRBAC適用済み(developer / operator ServiceAccount)

ここで一つ問題があります。現在のTaskBoard APIはインメモリ版で、タスクのデータをArrayList(メモリ上のリスト)に保持しています。これは、Podが再起動するとデータがすべて消えることを意味します。実際に試してみましょう。

[Execution User: developer]

# タスクを1件作成する
kubectl exec -n app deploy/taskboard-api -- curl -s -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"テスト用タスク","status":"open"}'
{"id":1,"title":"テスト用タスク","status":"open"}

タスクが作成されました。しかし、このデータはPodのメモリ上にしか存在しません。本番環境でこのままでは使い物になりません。TaskBoardに本格的なデータベースを追加し、データを永続化する必要があります。

本回のゴール

BeforeAfter
PVCでデータは永続化できる(入門第8回)が、DBの正しい動かし方がわからないDeploymentとStatefulSetの設計判断基準を持ち、StatefulSetでMySQLを運用できる。TaskBoard APIがMySQL接続版に更新され、データが永続化されている

今回は「構築 → 破壊 → 復活」のストーリーで進めます。まずDeploymentでMySQLを動かしてデータを失い、その後StatefulSetで正しく構築し直してデータが残ることを確認します。さらに、TaskBoard APIをMySQL接続版に更新し、「アプリ更新 → コンテナイメージ再ビルド → デプロイ」のワークフローも体験します。

3.2 DeploymentでDBを動かしてみる — そして壊す

まず、あえて「間違った方法」でMySQLをデプロイします。DeploymentでMySQLを動かすとどうなるかを体験してから、正しいアプローチに進みましょう。

3.2.1 MySQLをDeploymentでデプロイする

MySQLのrootパスワードを格納するSecretを作成します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/mysql-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: mysql-secret
  namespace: db
  labels:
    app: taskboard
    component: db
type: Opaque
stringData:
  MYSQL_ROOT_PASSWORD: "taskboard-root-pass"
  MYSQL_DATABASE: "taskboard"
  MYSQL_USER: "taskboard"
  MYSQL_PASSWORD: "taskboard-pass"
EOF

Secretには4つの値を定義しています。MYSQL_ROOT_PASSWORDはMySQLのrootパスワード、MYSQL_DATABASEは自動作成するデータベース名、MYSQL_USERMYSQL_PASSWORDはアプリケーション用のユーザーです。MySQLの公式コンテナイメージは、これらの環境変数を初回起動時に自動的に読み取って設定してくれます。

[Execution User: developer]

kubectl apply -f ~/k8s-applied/mysql-secret.yaml
secret/mysql-secret created

次に、Deploymentでreplicas: 1のMySQLをデプロイします。PVCは使わず、コンテナ内部のストレージのみでデータを保持する構成です。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/mysql-deployment-bad.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mysql-bad
  namespace: db
  labels:
    app: taskboard
    component: db
spec:
  replicas: 1
  selector:
    matchLabels:
      app: taskboard
      component: db
  template:
    metadata:
      labels:
        app: taskboard
        component: db
    spec:
      containers:
        - name: mysql
          image: mysql:8.0
          envFrom:
            - secretRef:
                name: mysql-secret
          ports:
            - containerPort: 3306
          resources:
            requests:
              cpu: "200m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
EOF

このマニフェストにはPersistentVolumeClaimがありません。MySQLのデータディレクトリ(/var/lib/mysql)はコンテナ内部のファイルシステムに書き込まれます。つまり、コンテナが消えるとデータも一緒に消えます。

[Execution User: developer]

kubectl apply -f ~/k8s-applied/mysql-deployment-bad.yaml
deployment.apps/mysql-bad created

MySQLの起動には少し時間がかかります。PodがRunningになるまで待ちましょう。

[Execution User: developer]

kubectl get pods -n db -w
NAME                         READY   STATUS    RESTARTS   AGE
mysql-bad-6d8f9b7c5-x4k2m   1/1     Running   0          35s

RunningになったらCtrl+Cで監視を停止してください。

3.2.2 データを書き込んでからPodを削除する — データ消失を体験する

MySQLにテーブルを作成し、データを書き込みます。

[Execution User: developer]

kubectl exec -n db deploy/mysql-bad -- mysql -u root -ptaskboard-root-pass taskboard -e "
CREATE TABLE test_data (
  id INT AUTO_INCREMENT PRIMARY KEY,
  message VARCHAR(255)
);
INSERT INTO test_data (message) VALUES ('このデータは消えるでしょうか?');
SELECT * FROM test_data;
"
mysql: [Warning] Using a password on the command line interface can be insecure.
id	message
1	このデータは消えるでしょうか?

データが入っていることを確認しました。では、Podを削除します。DeploymentはPodを自動で再作成してくれます。

[Execution User: developer]

kubectl delete pod -n db -l app=taskboard,component=db
pod "mysql-bad-6d8f9b7c5-x4k2m" deleted

新しいPodが起動するのを待ちます。

[Execution User: developer]

kubectl get pods -n db -w
NAME                         READY   STATUS    RESTARTS   AGE
mysql-bad-6d8f9b7c5-r7n9p   1/1     Running   0          25s

新しいPodが起動しました。先ほどのデータを確認してみましょう。

[Execution User: developer]

kubectl exec -n db deploy/mysql-bad -- mysql -u root -ptaskboard-root-pass taskboard -e "
SHOW TABLES;
"
mysql: [Warning] Using a password on the command line interface can be insecure.

テーブル一覧が空です。test_dataテーブルは跡形もなく消えています。

3.2.3 なぜデータが消えたのか — DeploymentとPodの関係を振り返る

Deploymentは「指定されたレプリカ数のPodが常に動いている状態」を維持するリソースです。Podが削除されると、Deploymentは新しいPodを作成します。しかし、この新しいPodは前のPodとは別のコンテナです。コンテナ内部に書き込まれたデータは、コンテナと一緒に消えます。

入門編第8回で学んだPVC(PersistentVolumeClaim)を使えば、データをコンテナの外に永続化できます。しかしデータベースには、単にデータを永続化するだけでは不十分な要件があります。

  • Pod名が固定であること — DBのレプリケーションでは「どのPodがプライマリで、どのPodがレプリカか」を識別する必要がある
  • PVCとPodが1対1で紐づくこと — 各Podが自分専用のデータ領域を持ち、他のPodのデータを上書きしないこと
  • 起動・停止の順序が保証されること — プライマリが先に起動し、レプリカがその後に続く、といった順序制御

Deploymentはこれらの要件を満たしません。そこで登場するのがStatefulSetです。まず、Deployment版のMySQLを片付けてからStatefulSetに進みましょう。

[Execution User: developer]

kubectl delete -f ~/k8s-applied/mysql-deployment-bad.yaml
deployment.apps "mysql-bad" deleted

3.3 StatefulSetとは何か

3.3.1 VMの世界との対比 — vSAN上のクラスタ化DBと共有ストレージ

VMwareの世界では、データベースサーバーの高可用性構成は次のように実現していました。

要件VMware / 従来インフラKubernetes
データの永続化vSANデータストアまたは共有LUN(FC / iSCSI)にVMDKを配置PersistentVolume + PersistentVolumeClaim
各ノードに専用ストレージLUN設計で各DBサーバーVMに専用のディスクを割り当てvolumeClaimTemplates(StatefulSet)
ノードの識別固定のホスト名・IPアドレスでプライマリ/レプリカを識別Pod名の固定(mysql-0, mysql-1…)+ Headless Service
起動順序の制御vSphere HAの起動優先度、またはOracle RACのノード起動順序StatefulSetのordinal index順に起動

VMの世界では、共有ストレージのLUN設計やネットワーク設定で「どのDBサーバーがどのディスクを使い、どのホスト名で通信するか」を固定していました。KubernetesのStatefulSetは、同じ概念をPodとPVCの組み合わせで実現します。

3.3.2 DeploymentとStatefulSetの違い — 3つのポイント

特性DeploymentStatefulSet
Pod名ランダムなサフィックス(例: nginx-7d8b4f6c9-k2m5x)連番のインデックス(例: mysql-0, mysql-1)
起動・停止の順序順序の保証なし(並列に起動・停止)0から順番に起動、逆順に停止
PVCとの関係全Podが同じPVCを共有(または各自でPVCを定義)volumeClaimTemplatesにより各Podに専用PVCが自動作成

それぞれのポイントを具体的に見ていきます。

Pod名の固定。DeploymentのPod名はnginx-7d8b4f6c9-k2m5xのようにランダムな文字列が付きます。Podが再作成されるたびにこの名前は変わります。一方、StatefulSetのPod名はmysql-0mysql-1のように連番のインデックスが付き、再作成されても同じ名前になります。データベースのレプリケーション設定でプライマリのホスト名を固定したい場合に不可欠です。

起動・停止の順序保証。StatefulSetでは、mysql-0が完全に起動してReadyになるまで、mysql-1は起動しません。停止時は逆順で、mysql-1が先に停止してからmysql-0が停止します。データベースクラスタでプライマリを先に起動させたいケースで必要な特性です。

PVCとPodの1対1の紐づけ。StatefulSetのvolumeClaimTemplatesを使うと、mysql-0にはmysql-data-mysql-0というPVCが、mysql-1にはmysql-data-mysql-1というPVCが自動的に作成されます。Podが削除されて再作成されても、同じ名前のPodは同じPVCに再接続されます。これがデータの永続性を保証する仕組みです。

3.3.3 Headless Serviceとは何か — 個別PodへのDNSアクセス

StatefulSetを使うには、Headless Serviceが必要です。通常のService(ClusterIP)は、リクエストをランダムなPodに振り分けます。しかしデータベースでは「プライマリに書き込み、レプリカから読み取り」のように、特定のPodにアクセスしたい場合があります。

Headless ServiceはclusterIP: Noneと設定されたServiceです。ロードバランサーとしての機能を持たず、代わりに各PodにDNS名を割り当てます。

通常のService(ClusterIP):
  クライアント → nginx.app.svc.cluster.local → ランダムなPodに振り分け

Headless Service(clusterIP: None):
  クライアント → mysql-0.mysql-headless.db.svc.cluster.local → mysql-0 に直接アクセス
  クライアント → mysql-1.mysql-headless.db.svc.cluster.local → mysql-1 に直接アクセス

DNS名の形式は<Pod名>.<Headless Service名>.<Namespace>.svc.cluster.localです。今回は1台構成ですが、この仕組みを理解しておくことで、将来レプリカを追加する際にもスムーズに対応できます。

3.4 StorageClassを理解する — 入門からの知識補足

3.4.1 入門編のhostPathとの違い

入門編第8回では、PVCを使ってデータを永続化しました。そのときはhostPath(ノードのローカルディスク上のディレクトリ)をストレージとして使いました。hostPathは学習用としてはシンプルですが、「どのノードのどのディレクトリにデータが置かれるか」をマニフェストに直接記述する必要がありました。

StorageClassは、ストレージの「種類」と「プロビジョニング方法」を抽象化するリソースです。PVCがStorageClassを参照すると、KubernetesがPersistentVolume(PV)を自動的に作成してくれます(動的プロビジョニング)。

入門編(手動プロビジョニング):
  管理者がPVを手動で作成 → PVCがPVに紐づく

応用編以降(動的プロビジョニング):
  PVCがStorageClassを指定 → Kubernetesが自動でPVを作成

本番環境では、AWS EBSやAzure Diskなどのクラウドストレージに対応したStorageClassを定義して使います。kindでは、ローカルディスクを使うStorageClassがデフォルトで用意されています。

3.4.2 kindのデフォルトStorageClass

kindクラスタにどのStorageClassが設定されているか確認してみましょう。

[Execution User: developer]

kubectl get storageclass
NAME                 PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
standard (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  3d

standardという名前のStorageClassが1つあり、(default)マークが付いています。これはPVCでStorageClassを明示的に指定しなかった場合に自動的に使われるStorageClassです。プロビジョナーはrancher.io/local-pathで、Nodeのローカルディスクにデータを保存します。

3.4.3 ReclaimPolicy — Podを消したらデータはどうなるか

RECLAIMPOLICY列にDeleteと表示されています。これはPVCを削除した場合の動作を定義するポリシーです。

ReclaimPolicyPVC削除時の動作用途
DeletePVとデータが自動で削除される一時的なデータ、テスト環境
RetainPVとデータが残る(手動で掃除が必要)本番のDB、消えては困るデータ

kindのデフォルトはDeleteです。本番環境でデータベースに使う場合はRetainのStorageClassを用意するのが一般的です。今回はkindの学習環境なのでDeleteのまま進めます。

重要なのは、StatefulSetのPodを削除しても、PVCは自動では削除されないという点です。StatefulSetが管理するPVCは、StatefulSet自体を削除しても残ります。ReclaimPolicyがDeleteであっても、PVCが存在する限りデータは消えません。PVCの削除は明示的な操作が必要です。これはデータ保護のための設計です。

3.5 MySQLをStatefulSetでデプロイする

3.5.1 Headless Serviceを作成する

StatefulSetには、紐づくHeadless Serviceが必要です。先にServiceを作成します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/mysql-headless-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: mysql-headless
  namespace: db
  labels:
    app: taskboard
    component: db
spec:
  clusterIP: None           # ← Headless Serviceのキー設定
  selector:
    app: taskboard
    component: db
  ports:
    - port: 3306
      targetPort: 3306
      name: mysql
EOF

clusterIP: NoneがHeadless Serviceの特徴です。通常のServiceとは異なり、仮想IPアドレスは割り当てられず、DNS名で各Podに直接アクセスする仕組みになります。

[Execution User: developer]

kubectl apply -f ~/k8s-applied/mysql-headless-service.yaml
service/mysql-headless created

3.5.2 StatefulSetマニフェストを作成する

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/mysql-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
  namespace: db
  labels:
    app: taskboard
    component: db
spec:
  serviceName: mysql-headless   # ← Headless Service名と一致させる
  replicas: 1
  selector:
    matchLabels:
      app: taskboard
      component: db
  template:
    metadata:
      labels:
        app: taskboard
        component: db
    spec:
      containers:
        - name: mysql
          image: mysql:8.0
          envFrom:
            - secretRef:
                name: mysql-secret
          ports:
            - containerPort: 3306
              name: mysql
          resources:
            requests:
              cpu: "200m"        # DB操作の安定性確保
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"    # InnoDBバッファプール + 接続スレッド
          volumeMounts:
            - name: mysql-data
              mountPath: /var/lib/mysql   # MySQLのデータディレクトリ
  volumeClaimTemplates:            # ← StatefulSet固有の設定
    - metadata:
        name: mysql-data
      spec:
        accessModes:
          - ReadWriteOnce          # 1つのPodからのみ読み書き可能
        resources:
          requests:
            storage: 1Gi           # 学習環境のため1GiBで十分
EOF

Deploymentとの主な違いは次の3点です。

  • kind: StatefulSet — DeploymentではなくStatefulSetを使う
  • serviceName: mysql-headless — 紐づくHeadless Serviceの名前を指定する。これによりmysql-0.mysql-headless.db.svc.cluster.localというDNS名がPodに割り当てられる
  • volumeClaimTemplates — PVCの「テンプレート」を定義する。StatefulSetはこのテンプレートを元に、各Podに専用のPVCを自動作成する。mysql-0にはmysql-data-mysql-0というPVCが作られる

resourcesの値は、設計書の目安(requests: 200m/256Mi、limits: 500m/512Mi)に基づいています。MySQLのInnoDBバッファプールと接続スレッドの動作に必要なメモリを確保しつつ、kindの学習環境で他のPodと共存できる範囲に収めています。

3.5.3 デプロイして動作を確認する — Pod名と起動順序の観察

[Execution User: developer]

kubectl apply -f ~/k8s-applied/mysql-statefulset.yaml
statefulset.apps/mysql created

Podの起動を観察します。

[Execution User: developer]

kubectl get pods -n db -w
NAME      READY   STATUS    RESTARTS   AGE
mysql-0   0/1     Pending   0          2s
mysql-0   0/1     ContainerCreating   0          3s
mysql-0   1/1     Running             0          18s

Pod名がmysql-0です。Deploymentのmysql-bad-6d8f9b7c5-x4k2mのようなランダムなサフィックスではなく、StatefulSet名+連番インデックスの形式になっています。Ctrl+Cで監視を停止してください。

PVCも確認しましょう。

[Execution User: developer]

kubectl get pvc -n db
NAME                STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
mysql-data-mysql-0  Bound    pvc-a1b2c3d4-e5f6-7890-abcd-ef1234567890   1Gi        RWO            standard       45s

mysql-data-mysql-0というPVCが自動作成されました。名前は<volumeClaimTemplatesのname>-<StatefulSet名>-<インデックス>の形式です。STATUSがBoundになっており、PersistentVolumeに紐づいています。

3.6 データの永続性を検証する — 構築・破壊・復活

3.6.1 MySQLにデータを書き込む

StatefulSet版のMySQLにデータを書き込みます。

[Execution User: developer]

kubectl exec -n db mysql-0 -- mysql -u root -ptaskboard-root-pass taskboard -e "
CREATE TABLE test_data (
  id INT AUTO_INCREMENT PRIMARY KEY,
  message VARCHAR(255)
);
INSERT INTO test_data (message) VALUES ('StatefulSetならデータは残る');
SELECT * FROM test_data;
"
mysql: [Warning] Using a password on the command line interface can be insecure.
id	message
1	StatefulSetならデータは残る

今回はStatefulSetのPod名が固定されているため、kubectl exec -n db mysql-0のようにPod名を直接指定できます。Deploymentのときはdeploy/mysql-badのようにリソース経由で指定する必要がありましたが、StatefulSetではPod名が予測可能なため直接指定が使えます。

3.6.2 Podを削除する — StatefulSetが同じ名前で再作成することを確認する

[Execution User: developer]

kubectl delete pod -n db mysql-0
pod "mysql-0" deleted

StatefulSetが新しいPodを作成する様子を観察します。

[Execution User: developer]

kubectl get pods -n db -w
NAME      READY   STATUS              RESTARTS   AGE
mysql-0   0/1     ContainerCreating   0          3s
mysql-0   1/1     Running             0          15s

新しいPodの名前は再びmysql-0です。Deploymentのように新しいランダムな名前にはなりません。StatefulSetは削除されたPodと同じ名前・同じインデックスでPodを再作成します。そして、同じ名前のPVCに再接続します。Ctrl+Cで監視を停止してください。

3.6.3 データが残っていることを確認する

[Execution User: developer]

kubectl exec -n db mysql-0 -- mysql -u root -ptaskboard-root-pass taskboard -e "
SELECT * FROM test_data;
"
mysql: [Warning] Using a password on the command line interface can be insecure.
id	message
1	StatefulSetならデータは残る

データが残っています。Podは一度削除されて新しく作り直されましたが、PVCに保存されたデータはそのまま維持されています。これがStatefulSetの「復活」です。

Deployment版との結果を比較しましょう。

Deployment版(PVCなし)StatefulSet版
Pod名mysql-bad-6d8f9b7c5-x4k2m(ランダム)mysql-0(固定)
PVCなし(コンテナ内ストレージ)mysql-data-mysql-0(自動作成、再接続)
Pod削除後のデータ消失残存

Headless ServiceによるDNSアクセスも確認しておきましょう。クラスタ内の一時Podからnslookupを実行します。

[Execution User: developer]

kubectl run dns-test --image=busybox:1.36 --rm -it --restart=Never -n db -- \
  nslookup mysql-0.mysql-headless.db.svc.cluster.local
Server:    10.96.0.10
Address 1: 10.96.0.10 kube-dns.kube-system.svc.cluster.local

Name:      mysql-0.mysql-headless.db.svc.cluster.local
Address 1: 10.244.2.8 mysql-0.mysql-headless.db.svc.cluster.local
pod "dns-test" deleted

mysql-0.mysql-headless.db.svc.cluster.localというFQDNでmysql-0 PodのIPアドレスが解決できています。TaskBoard APIからMySQLに接続する際は、このDNS名を使います。

検証用のテストテーブルを削除しておきます。後のステップでJPAによるDDL自動生成を使うため、クリーンな状態にします。

[Execution User: developer]

kubectl exec -n db mysql-0 -- mysql -u root -ptaskboard-root-pass taskboard -e "
DROP TABLE IF EXISTS test_data;
"

3.7 TaskBoard APIをMySQL接続版に更新する

MySQLがStatefulSetで安定稼働していることを確認できました。次は、TaskBoard APIをインメモリ版からMySQL接続版に更新します。ここでは「ソースコードを変更 → Dockerイメージを再ビルド → kindに投入 → デプロイ」というワークフローを体験します。このワークフローは、本番環境でもアプリケーションを更新する際の基本的な流れです。

3.7.1 ソースコードの変更点を理解する

第1回で作成したTaskBoard APIのソースコードを、MySQL接続版に変更します。変更するファイルは4つです。

ファイル変更内容
pom.xmlMySQL JDBCドライバーの依存を追加
Task.javaJPAアノテーション(@Entity, @Id等)を追加
TaskService.javaArrayListからJPA(EntityManager)経由のDB操作に全面書き換え
persistence.xml(新規)JPAの接続設定を定義

TaskResource.javaTaskBoardApplication.javaは変更しません。REST APIのエンドポイント定義はデータストアの変更に影響を受けないためです。これはレイヤー分離の恩恵です。

技術的な背景を簡単に説明します。Payara Micro 7.2026.1はJakarta EE 11をサポートしており、Jakarta Persistence(JPA)3.2が利用できます。JPAはJavaアプリケーションとデータベースの間を仲介するORMフレームワークで、SQLを直接書かなくてもJavaオブジェクトとデータベースのテーブルを自動的にマッピングしてくれます。

3.7.2 変更後のソースコードを配置する

persistence.xmlの配置先ディレクトリを作成します。

[Execution User: developer]

mkdir -p ~/k8s-applied/taskboard-api/src/main/resources/META-INF

まず、pom.xmlを更新します。MySQL JDBCドライバー(Connector/J)の依存を追加します。第1回からの変更箇所にはコメントを付けています。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/taskboard-api/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.taskboard</groupId>
    <artifactId>taskboard-api</artifactId>
    <version>2.0.0</version>
    <packaging>war</packaging>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <failOnMissingWebXml>false</failOnMissingWebXml>
    </properties>

    <dependencies>
        <!-- Jakarta EE 11 Web Profile API -->
        <dependency>
            <groupId>jakarta.platform</groupId>
            <artifactId>jakarta.jakartaee-web-api</artifactId>
            <version>11.0.0</version>
            <scope>provided</scope>
        </dependency>
        <!-- MicroProfile 6.1 API(ヘルスチェック等) -->
        <dependency>
            <groupId>org.eclipse.microprofile</groupId>
            <artifactId>microprofile</artifactId>
            <version>6.1</version>
            <type>pom</type>
            <scope>provided</scope>
        </dependency>
        <!-- ▼ 第3回で追加: MySQL JDBCドライバー -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>9.1.0</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>taskboard-api</finalName>
    </build>
</project>
EOF

第1回からの変更点は2箇所です。version2.0.0に上げたことと、MySQL Connector/J 9.1.0の依存を追加したことです。Connector/Jのscoperuntimeで、コンパイル時には不要ですがWARファイルに含めて実行時に使います(Payara Microが提供するものではないため、providedではなくruntimeです)。

次に、Task.javaにJPAアノテーションを追加します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/taskboard-api/src/main/java/com/taskboard/Task.java
package com.taskboard;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

/**
 * タスクエンティティ。
 * 第3回でJPAエンティティに拡張。MySQLのtasksテーブルにマッピングする。
 *
 * 第1回からの変更点:
 *   - @Entity, @Table, @Id, @GeneratedValue アノテーションを追加
 *   - インポート文にjakarta.persistenceパッケージを追加
 */
@Entity
@Table(name = "tasks")
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String status;

    public Task() {
    }

    public Task(Long id, String title, String status) {
        this.id = id;
        this.title = title;
        this.status = status;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }

    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
}
EOF

第1回からの変更点は、JPAアノテーションの追加のみです。@EntityでJPA管理対象のエンティティであることを宣言し、@Table(name = "tasks")でマッピング先のテーブル名を指定しています。@Idは主キー、@GeneratedValue(strategy = GenerationType.IDENTITY)はMySQLのAUTO_INCREMENTによる自動採番を使う設定です。フィールドやメソッドの構造は変わっていません。

次に、TaskService.javaをMySQL版に書き換えます。これは全面的な変更です。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/taskboard-api/src/main/java/com/taskboard/TaskService.java
package com.taskboard;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.transaction.Transactional;
import java.util.List;
import java.util.Optional;

/**
 * MySQL版タスクサービス。
 * EntityManager経由でMySQLにCRUD操作を行う。
 *
 * 第1回からの変更点:
 *   - ArrayListによるインメモリ保持 → EntityManager経由のDB操作に全面書き換え
 *   - AtomicLongによるID採番 → MySQLのAUTO_INCREMENTに委譲
 *   - @Transactionalアノテーションによるトランザクション管理を追加
 */
@ApplicationScoped
public class TaskService {

    @PersistenceContext(unitName = "taskboard-pu")
    private EntityManager em;

    public List<Task> findAll() {
        return em.createQuery("SELECT t FROM Task t", Task.class)
                 .getResultList();
    }

    public Optional<Task> findById(Long id) {
        Task task = em.find(Task.class, id);
        return Optional.ofNullable(task);
    }

    @Transactional
    public Task create(Task task) {
        if (task.getStatus() == null || task.getStatus().isEmpty()) {
            task.setStatus("open");
        }
        em.persist(task);
        return task;
    }
}
EOF

第1回のインメモリ版と比較すると、構造が大きく変わっています。ArrayListAtomicLongがなくなり、代わりにEntityManager@PersistenceContextで注入しています。findAll()はJPQL(Java Persistence Query Language)を使い、create()em.persist()でデータベースに永続化します。@Transactionalアノテーションにより、書き込み操作はトランザクション内で実行されます。

最後に、JPA設定ファイルpersistence.xmlを新規作成します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/taskboard-api/src/main/resources/META-INF/persistence.xml
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="3.2"
             xmlns="https://jakarta.ee/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence
                                 https://jakarta.ee/xml/ns/persistence/persistence_3_2.xsd">

    <persistence-unit name="taskboard-pu" transaction-type="JTA">
        <!-- Payara MicroのデフォルトJDBCリソースを使用 -->
        <jta-data-source>java:app/jdbc/taskboard</jta-data-source>

        <class>com.taskboard.Task</class>

        <properties>
            <!--
              DDL自動生成: テーブルが存在しなければ作成する。
              第4回のDB初期化Job(初期データ投入)とは役割が異なる:
                - persistence.xml の create → テーブル構造(スキーマ)の自動生成
                - 第4回のJob → 初期データ(マスタデータ等)の投入
            -->
            <property name="jakarta.persistence.schema-generation.database.action"
                      value="create"/>
            <!-- Eclipselink(Payara内蔵ORM)のログレベル -->
            <property name="eclipselink.logging.level" value="INFO"/>
        </properties>
    </persistence-unit>
</persistence>
EOF

persistence.xmlはJPAの設定ファイルで、src/main/resources/META-INF/に配置する規約です。主要な設定を説明します。

  • persistence-unit name="taskboard-pu" — TaskService.javaの@PersistenceContext(unitName = "taskboard-pu")と対応する名前
  • jta-data-source — 接続先データソースのJNDI名。後述のDataSourceDefinitionで定義する名前と一致させる
  • schema-generation.database.action = create — アプリケーション起動時に、エンティティクラスの定義に基づいてテーブルを自動作成する。テーブルがすでに存在する場合は何もしない(データは消えない)

DDL自動生成(create)は「テーブル構造の自動作成」を担当します。第4回で作成するDB初期化Jobは「初期データ(マスタデータ等)の投入」を担当します。スキーマ定義はアプリケーション(JPA)に、データ投入はJobに、と責任を分離する設計です。

次に、データソース定義を行います。Payara Microでは、@DataSourceDefinitionアノテーションでJDBCデータソースを定義できます。このアノテーションを配置するクラスを作成します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/taskboard-api/src/main/java/com/taskboard/DataSourceConfig.java
package com.taskboard;

import jakarta.annotation.sql.DataSourceDefinition;
import jakarta.ejb.Singleton;
import jakarta.ejb.Startup;

/**
 * MySQLデータソース定義。
 * Payara Microの @DataSourceDefinition でJDBCリソースを宣言する。
 *
 * 環境変数(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD)で
 * 接続先を外部から設定可能にしている。
 * 環境変数が未設定の場合はデフォルト値が使われる。
 */
@DataSourceDefinition(
    name = "java:app/jdbc/taskboard",
    className = "com.mysql.cj.jdbc.MysqlDataSource",
    serverName = "${ENV=DB_HOST:mysql-0.mysql-headless.db.svc.cluster.local}",
    portNumber = 3306,
    databaseName = "${ENV=DB_NAME:taskboard}",
    user = "${ENV=DB_USER:taskboard}",
    password = "${ENV=DB_PASSWORD:taskboard-pass}",
    properties = {
        "useSSL=false",
        "allowPublicKeyRetrieval=true"
    }
)
@Singleton
@Startup
public class DataSourceConfig {
}
EOF

@DataSourceDefinitionはPayara Micro固有の環境変数展開構文${ENV=変数名:デフォルト値}をサポートしています。これにより、DB接続先をDeploymentの環境変数で外部から制御できます。デフォルト値にはStatefulSetのHeadless Service経由のFQDNを指定しています。

ファイル構成を確認しておきましょう。

[Execution User: developer]

find ~/k8s-applied/taskboard-api -type f | sort
/home/developer/k8s-applied/taskboard-api/Dockerfile
/home/developer/k8s-applied/taskboard-api/pom.xml
/home/developer/k8s-applied/taskboard-api/src/main/java/com/taskboard/DataSourceConfig.java
/home/developer/k8s-applied/taskboard-api/src/main/java/com/taskboard/Task.java
/home/developer/k8s-applied/taskboard-api/src/main/java/com/taskboard/TaskBoardApplication.java
/home/developer/k8s-applied/taskboard-api/src/main/java/com/taskboard/TaskResource.java
/home/developer/k8s-applied/taskboard-api/src/main/java/com/taskboard/TaskService.java
/home/developer/k8s-applied/taskboard-api/src/main/resources/META-INF/persistence.xml

第1回からDataSourceConfig.javapersistence.xmlの2ファイルが追加されています。

3.7.3 コンテナイメージを再ビルドしてデプロイする

ソースコードが準備できたら、コンテナイメージを再ビルドします。Dockerfileは第1回で作成したものをそのまま使えます。multi-stage buildにより、Maven + JDK環境でのビルドからPayara Microイメージの作成まで、すべてDockerの中で完結します。

イメージタグは2.0.0に更新します。MySQL接続版への変更はアプリケーションの大きな機能追加であるため、メジャーバージョンを上げます。

[Execution User: developer]

cd ~/k8s-applied/taskboard-api
docker build -t taskboard-api:2.0.0 .

初回のビルド時にはMySQL Connector/Jのダウンロードが追加で入るため、第1回よりも少し時間がかかります。ビルドの最後に以下のようなメッセージが表示されれば成功です。

 => exporting to image
 => => naming to docker.io/library/taskboard-api:2.0.0

ビルドしたイメージをkindクラスタに投入します。

[Execution User: developer]

kind load docker-image taskboard-api:2.0.0 --name k8s-applied
Image: "taskboard-api:2.0.0" with ID "sha256:b2c3d4e5..." not yet present on node "k8s-applied-worker", loading...
Image: "taskboard-api:2.0.0" with ID "sha256:b2c3d4e5..." not yet present on node "k8s-applied-worker2", loading...
Image: "taskboard-api:2.0.0" with ID "sha256:b2c3d4e5..." not yet present on node "k8s-applied-worker3", loading...
Image: "taskboard-api:2.0.0" with ID "sha256:b2c3d4e5..." not yet present on node "k8s-applied-control-plane", loading...

次に、TaskBoard APIのDeploymentマニフェストを更新します。イメージタグの変更と、DB接続用の環境変数を追加します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/taskboard-api-deployment-v2.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: taskboard-api
  namespace: app
  labels:
    app: taskboard
    component: api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: taskboard
      component: api
  template:
    metadata:
      labels:
        app: taskboard
        component: api
    spec:
      containers:
        - name: taskboard-api
          image: taskboard-api:2.0.0       # ← 1.0.0 → 2.0.0 に更新
          imagePullPolicy: Never            # kindに直接投入済みのため
          ports:
            - containerPort: 8080
          env:
            # ▼ 第3回で追加: MySQL接続設定
            - name: DB_HOST
              value: "mysql-0.mysql-headless.db.svc.cluster.local"
            - name: DB_NAME
              value: "taskboard"
            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: mysql-secret
                  key: MYSQL_USER
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysql-secret
                  key: MYSQL_PASSWORD
          resources:
            requests:
              cpu: "200m"
              memory: "384Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
EOF

第1回のDeploymentからの変更点は次のとおりです。

  • imagetaskboard-api:2.0.0に更新
  • envにDB接続用の環境変数を追加。DB_HOSTはHeadless Service経由のFQDNを直接指定し、DB_USERDB_PASSWORDはdb Namespaceのmysql-secretから参照

ここで一つ注意点があります。mysql-secretはdb Namespaceにあり、TaskBoard APIのDeploymentはapp Namespaceにあります。Secretは同じNamespace内のPodからしか参照できないため、secretKeyRefで別NamespaceのSecretを直接参照することはできません。app Namespaceにも同じ認証情報を持つSecretを作成する必要があります。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/mysql-secret-app.yaml
apiVersion: v1
kind: Secret
metadata:
  name: mysql-secret
  namespace: app
  labels:
    app: taskboard
    component: api
type: Opaque
stringData:
  MYSQL_USER: "taskboard"
  MYSQL_PASSWORD: "taskboard-pass"
EOF

[Execution User: developer]

kubectl apply -f ~/k8s-applied/mysql-secret-app.yaml
secret/mysql-secret created

では、新しいDeploymentを適用します。既存のDeployment(taskboard-api)のイメージとenvが更新されるため、ローリングアップデートが自動で実行されます。

[Execution User: developer]

kubectl apply -f ~/k8s-applied/taskboard-api-deployment-v2.yaml
deployment.apps/taskboard-api configured

Podが更新されるのを確認します。Payara Microの起動には15〜20秒かかります。

[Execution User: developer]

kubectl rollout status deployment/taskboard-api -n app
Waiting for deployment "taskboard-api" rollout to finish: 1 old replicas are pending termination...
deployment "taskboard-api" successfully rolled out

更新後のPodが正しいイメージで起動しているか確認します。

[Execution User: developer]

kubectl get pods -n app -o wide
NAME                             READY   STATUS    RESTARTS   AGE   IP            NODE
nginx-7d8b4f6c9-k2m5x           1/1     Running   0          3d    10.244.1.3    k8s-applied-worker
nginx-7d8b4f6c9-p8n3q           1/1     Running   0          3d    10.244.2.4    k8s-applied-worker2
taskboard-api-7c9f8d6b5-m3k7p   1/1     Running   0          45s   10.244.3.12   k8s-applied-worker3
taskboard-api-7c9f8d6b5-n8r2q   1/1     Running   0          30s   10.244.1.9    k8s-applied-worker

TaskBoard APIのPodが新しくなっています(AGEが短い)。Podのイメージを確認します。

[Execution User: developer]

kubectl describe pod -n app -l app=taskboard,component=api | grep "Image:"
    Image:          taskboard-api:2.0.0
    Image:          taskboard-api:2.0.0

両方のPodがtaskboard-api:2.0.0で動いています。

3.7.4 API経由でタスクを作成し、MySQLに永続化されることを確認する

TaskBoard APIの動作確認を行います。NodePort(30080)経由でアクセスします。

[Execution User: developer]

# kindのNodeのIPアドレスを取得
NODE_IP=$(kubectl get nodes k8s-applied-worker -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}')

# タスクを作成
curl -s -X POST http://${NODE_IP}:30080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"MySQLに永続化されるタスク","status":"open"}'
{"id":1,"title":"MySQLに永続化されるタスク","status":"open"}

もう1件追加してみましょう。

[Execution User: developer]

curl -s -X POST http://${NODE_IP}:30080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Pod再起動後も残るか検証","status":"open"}'
{"id":2,"title":"Pod再起動後も残るか検証","status":"open"}

タスク一覧を取得します。

[Execution User: developer]

curl -s http://${NODE_IP}:30080/api/tasks | python3 -m json.tool
[
    {
        "id": 1,
        "title": "MySQLに永続化されるタスク",
        "status": "open"
    },
    {
        "id": 2,
        "title": "Pod再起動後も残るか検証",
        "status": "open"
    }
]

MySQLにもデータが入っていることを直接確認します。

[Execution User: developer]

kubectl exec -n db mysql-0 -- mysql -u taskboard -ptaskboard-pass taskboard -e "
SELECT * FROM tasks;
"
mysql: [Warning] Using a password on the command line interface can be insecure.
id	title	status
1	MySQLに永続化されるタスク	open
2	Pod再起動後も残るか検証	open

API経由で作成したタスクが、MySQLのtasksテーブルに保存されています。tasksテーブルはpersistence.xmlのDDL自動生成(create)により、アプリケーション初回起動時に自動作成されました。

最後の検証です。API PodとMySQL Podの両方を削除し、再起動後もデータが残ることを確認します。

[Execution User: developer]

# API PodとMySQL Podを同時に削除
kubectl delete pod -n app -l app=taskboard,component=api
kubectl delete pod -n db mysql-0
pod "taskboard-api-7c9f8d6b5-m3k7p" deleted
pod "taskboard-api-7c9f8d6b5-n8r2q" deleted
pod "mysql-0" deleted

すべてのPodが再起動するのを待ちます。MySQL(StatefulSet)の再起動後にAPI(Deployment)が接続するため、MySQLが先にReadyになる必要があります。30秒ほど待ってから確認します。

[Execution User: developer]

# MySQLの起動を待つ
kubectl wait --for=condition=Ready pod/mysql-0 -n db --timeout=60s

# API Podの起動を待つ
kubectl rollout status deployment/taskboard-api -n app
pod/mysql-0 condition met
deployment "taskboard-api" successfully rolled out

データを確認します。

[Execution User: developer]

curl -s http://${NODE_IP}:30080/api/tasks | python3 -m json.tool
[
    {
        "id": 1,
        "title": "MySQLに永続化されるタスク",
        "status": "open"
    },
    {
        "id": 2,
        "title": "Pod再起動後も残るか検証",
        "status": "open"
    }
]

API PodもMySQL Podも削除・再作成されましたが、データはそのまま残っています。StatefulSetのvolumeClaimTemplatesによりPVCが維持され、MySQLのデータディレクトリが保護されているためです。

リソース消費も確認しておきましょう。

[Execution User: developer]

kubectl top pods -n db
NAME      CPU(cores)   MEMORY(bytes)
mysql-0   15m          180Mi

MySQLはアイドル時にCPU 15m、メモリ180MiB程度を消費しています。requests(200m/256Mi)の範囲内で安定して動作しており、limits(500m/512Mi)にも余裕があります。

🔧 トラブルシュートTips

TaskBoard APIのPodがCrashLoopBackOffになる場合は、まずMySQLが起動しているか確認してください。APIはMySQLに接続できないと起動に失敗します。kubectl logs -n app -l app=taskboard,component=apiでログを確認し、Connection refusedCommunications link failureが出ていればMySQL側の問題です。kubectl get pods -n dbでmysql-0がRunningであることを確認し、まだ起動中であれば少し待ってからAPIのPodを再起動(kubectl rollout restart deployment/taskboard-api -n app)してください。

また、app Namespaceにmysql-secretが存在しない場合もPodが起動しません。kubectl get secret mysql-secret -n appで確認してください。

3.8 この回のまとめ

3.8.1 TaskBoardの現在地

今回の作業で、TaskBoardは以下の状態になりました。

第3回終了時の構成:

[app Namespace]
  ├── Nginx (Deployment, 2レプリカ) + Service (ClusterIP)
  └── TaskBoard API (Deployment, 2レプリカ, MySQL接続版 v2.0.0) + Service (NodePort:30080)
      └── → mysql-0.mysql-headless.db.svc.cluster.local に接続

[db Namespace]
  ├── MySQL (StatefulSet, 1レプリカ) + Headless Service + PVC(1Gi)
  └── mysql-secret (Secret)

[monitoring Namespace]
  └── (空 — 第4回でログ収集DaemonSet追加予定)

基盤:
  ├── kindクラスタ (CP 1 + Worker 3)
  ├── Metrics Server 稼働中
  ├── 各NamespaceにResourceQuota / LimitRange適用済み
  └── 各NamespaceにRBAC適用済み(developer / operator ServiceAccount)

TaskBoard APIがMySQL接続版に更新され、API経由で作成されたタスクがMySQLに永続化されるようになりました。「アプリ更新 → コンテナイメージ再ビルド → kind load → ローリングアップデート」というワークフローも体験しました。

3.8.2 StatefulSet設計の判断基準 — いつ使う / いつ使わない

StatefulSetが適切なケースStatefulSetが過剰なケース
データベース(MySQL, PostgreSQL, MongoDB等)ステートレスなWebサーバー(Nginx, Apache)
メッセージキュー(Kafka, RabbitMQ)ステートレスなAPIサーバー(今回のPayara Micro自体はステートレス)
分散ストレージ(Elasticsearch, Cassandra)バッチ処理(Job / CronJobで十分)
Pod名やDNS名を固定する必要があるワークロードPVCは使うがPod名の固定が不要なワークロード(DeploymentでPVCを共有すれば済む場合)

判断の軸は「Pod名の固定」「PVCとPodの1対1の紐づけ」「起動順序の保証」の3つです。このうち1つでも必要であればStatefulSetを選択します。データを永続化したいだけで、Pod名の固定や起動順序が不要であれば、DeploymentとPVCの組み合わせでも対応できます。

3.8.3 実践編への橋渡し

今回学んだStatefulSetの知識は、実践編 第2回「基本設計」と第3回「詳細設計」で再び登場します。応用編では「StatefulSetをこう使う」を体験しましたが、実践編では「なぜStatefulSetを選んだのか」「なぜreplicas: 1なのか」「ReclaimPolicyをRetainにすべきか」といった設計判断の根拠を設計書として言語化します。

また、第5回「アプリケーション構築」では、設計書に基づいてStatefulSetを含むTaskBoard全体を依存関係を考慮した適用順序でデプロイする手順を実践します。

3.8.4 次回予告

次回は第4回「Deployment以外のワークロード — DaemonSet / Job / CronJob」です。今回のMySQLに初期データを投入するJob、定期バックアップを実行するCronJob、全Workerノードでログを収集するDaemonSetを追加します。Deploymentと今回のStatefulSetに加えて、残りのワークロードリソースを揃え、5種のワークロードを使い分ける判断力を身につけます。

AIコラム

💡 AIの活用ヒント

「StatefulSetとDeploymentのどちらを使うべきか」のような設計判断は、AIに要件を伝えて壁打ちすると効率的です。
例:「MySQLをK8sで運用したい。データは永続化必須。レプリケーションは当面なし。DeploymentとStatefulSetのどちらが適切か、理由とともに教えて」

AIは判断の軸(Pod名の固定が必要か、起動順序が必要か等)を整理してくれます。ただし、最終的な判断はあなたの環境の制約(クラスタのStorageClass、運用チームの体制、将来のスケールアウト計画等)を踏まえて行ってください。

kubernetes