本記事には広告(アフィリエイトリンク)が含まれます。

PVCとStatefulSetでDB永続化|CKAD第9回

広告

新卒インフラエンジニア向け「Kubernetes 実践教科書① CKAD アプリケーション開発編」の第9回です。第8回で fanclub-frontend を追加し、Frontend → Backend の通信を確立しました。ただし /api/membersDB がまだ無いため 503 のままでした。今回は PVCStatefulSetPostgreSQL 18 を永続化して追加し、模擬アプリを 3 層(Frontend + Backend + DB)に完成させます。永続ストレージ(PV / PVC / StorageClass)と StatefulSet の仕組みを学び、最後はブラウザ(port-forward)から本物の会員 CRUD が動くところまで確認します。

動作確認バージョン:kind v0.31.0(K8s v1.35.0)/ kubectl v1.35.6 / postgres:18(公式)/ fanclub-backend:0.3.0 / fanclub-frontend:0.1.0(Nginx 1.27-alpine)(2026-06-27 実機検証時点)

広告

今ここマップ(全 19 回中の現在地)

現在地は第 3 部「アプリリソース」の第 9 回です。DB を追加し、模擬アプリを 3 層に完成させます。

  • 第 1 部 コンテナと Docker(第 1〜4 回)
  • 第 2 部 Kubernetes 基礎(第 5〜6 回)
  • 第 3 部 アプリリソース(第 7〜11 回)← 今ここ
  • 第 4 部 ワークロード戦略(第 12〜14 回)
  • 第 5 部 セキュリティ基礎(第 15〜16 回)
  • 第 6 部 パッケージ管理と HTTPS 公開(第 17〜19 回)

この回のゴール

  • PV / PVC / StorageClass の関係と動的プロビジョニングを説明できる。
  • StatefulSet と volumeClaimTemplates で PostgreSQL を永続化し、Pod を作り直してもデータが残ることを確認できる。
  • Backend を DB 接続対応(0.3.0)に更新して 3 層を完成させ、ブラウザから会員 CRUD が動くことを確認できる。

なぜ永続ストレージが必要か

コンテナのファイルシステムは使い捨てです。Pod を削除・再作成すると、コンテナ内に書いたファイルは消えます。第7回で使った emptyDir も「Pod が生きている間だけ」のボリュームで、Pod が消えれば一緒に消えます。会員データを保持する DB を Pod の中だけに置くと、Pod の再起動でデータが消えてしまいます。

そこで Kubernetes は、Pod のライフサイクルから独立して存在するストレージとして PersistentVolume(PV)を用意します。Pod は PersistentVolumeClaim(PVC)でストレージを要求し、PV をマウントします。Pod が作り直されても PVC と PV は残るため、データが保持されます。

PV / PVC / StorageClass

PersistentVolume(実体)と PersistentVolumeClaim(要求)

  • PV:実際のストレージ領域の「実体」。クラスタ管理者が用意するか、後述の動的プロビジョニングで自動生成されます。
  • PVC:アプリ側からの「要求」。「1Gi・ReadWriteOnce が欲しい」と書くと、条件に合う PV に結び付けられます(この状態を Bound と呼びます)。

アプリ開発者(CKAD の主役)が書くのは基本的に PVC です。「どんなストレージが欲しいか」を宣言すれば、実体の用意は Kubernetes に任せられます。

StorageClass と動的プロビジョニング

StorageClass は「どの方式でストレージを払い出すか」のテンプレートです。PVC が StorageClass を指定(または既定を使用)すると、対応するプロビジョナが PV を自動生成して PVC に結び付けます。これが動的プロビジョニングで、管理者が事前に PV を手作りする必要がありません。

kind には既定の StorageClass standard(プロビジョナ rancher.io/local-path)が最初から用意されています。確認します。

実行コマンド:

$ kubectl get storageclass

実行結果(例):standard(default) です。VOLUMEBINDINGMODEWaitForFirstConsumer のため、PVC を作っただけでは Pending のままで、それを使う Pod がスケジュールされて初めて PV が払い出されます。

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

アクセスモードと Reclaim Policy

  • アクセスモードReadWriteOnce(RWO・単一ノードから read-write)/ ReadOnlyMany(ROX・複数ノードから read-only)/ ReadWriteMany(RWX・複数ノードから read-write)。kind の local-path は実質 RWO です。DB は単一書き込みなので RWO で十分です。
  • Reclaim Policy:PVC を削除したときの PV の扱い。Delete(PV も消す・kind 既定)/ Retain(PV を残してデータ保護)。
PVC(ストレージ要求)が StorageClass(standard・rancher.io/local-path)を介してプロビジョナに渡り、PV(実体)が自動生成されて PVC に Bound される動的プロビジョニングの流れを示す図。Pod は PVC をマウントする。
図1:動的プロビジョニング(PVC → StorageClass → PV 自動生成 → Bound)

StatefulSet の概念

StatefulSet は、状態を持つアプリ(DB など)のためのワークロードです。Deployment と違い、次の 3 つの「安定性」を提供します。

  • 安定したネットワーク識別子:Pod 名が fanclub-db-0 のように序数付きで固定され、再作成されても名前が変わりません(Deployment はランダムなサフィックスが付きます)。
  • 安定した永続ストレージvolumeClaimTemplates により Pod ごとに専用の PVCが自動生成され、Pod が作り直されても同じ PVC が再アタッチされます。
  • 順序付き起動・終了:序数の小さい Pod から順に起動し、逆順で終了します。

なぜ DB は StatefulSet なのか

Deployment の Pod は使い捨て・入れ替え自由が前提で、全 Pod が同じ PVC を共有しようとします。DB は「このデータはこのインスタンスのもの」という同一性が必要なため、Pod ごとに専用ストレージを持てる StatefulSet が適します。本回は単一レプリカですが、StatefulSet を使うことで「Pod を作り直しても同じ PVC(=同じデータ)に戻る」性質が得られます。

StatefulSet fanclub-db が headless Service(ClusterIP None)で安定 DNS を提供し、Pod fanclub-db-0 が volumeClaimTemplates から自動生成された専用 PVC data-fanclub-db-0 をマウントする構成図。Pod を再作成しても同じ PVC が再アタッチされデータが残ることを示す。
図2:StatefulSet の安定 ID(fanclub-db-0)と専用 PVC(data-fanclub-db-0)の再アタッチ

PostgreSQL 18 を StatefulSet + PVC で起動する

members テーブルを初期化する ConfigMap を作る

PostgreSQL 公式イメージは、初回起動時(データディレクトリが空のとき)に /docker-entrypoint-initdb.d/ 配下の .sql を自動実行します。ここに会員テーブルの DDL を置いて、DB 起動と同時に members テーブルを作ります。まず DDL ファイルを developer のホームに schema.sql として作成します(一般ユーザー所有・root 不要)。本シリーズの教材一式では app-src/db/schema.sql として同梱しています。

CREATE TABLE IF NOT EXISTS members (
    id         SERIAL PRIMARY KEY,
    name       VARCHAR(100) NOT NULL,
    email      VARCHAR(255) NOT NULL UNIQUE,
    plan       VARCHAR(50)  NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_members_plan ON members(plan);

この schema.sql から ConfigMap を作ります。--from-file で「キー名 schema.sql=ファイル内容」の ConfigMap になります。--from-file はカレントディレクトリの schema.sql を読むため、schema.sql を置いたディレクトリ(developer のホーム)で実行します。

実行コマンド:

$ cd ~
$ kubectl create configmap fanclub-db-init --from-file=schema.sql=schema.sql
$ kubectl get configmap fanclub-db-init

実行結果(例):

NAME              DATA   AGE
fanclub-db-init   1      3s

StatefulSet と headless Service のマニフェスト

次のマニフェストを developer のホームに db-statefulset.yaml として作成します(一般ユーザー所有のファイルで、kubectl apply はクラスタへ送るだけなので root 権限は不要です)。--- で headless Service と StatefulSet を 1 ファイルにまとめ、YAML は省略せず全量を示します。

apiVersion: v1
kind: Service
metadata:
  name: fanclub-db
spec:
  clusterIP: None
  selector:
    app: fanclub-db
  ports:
  - name: postgres
    port: 5432
    targetPort: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: fanclub-db
spec:
  serviceName: fanclub-db
  replicas: 1
  selector:
    matchLabels:
      app: fanclub-db
  template:
    metadata:
      labels:
        app: fanclub-db
    spec:
      containers:
      - name: postgres
        image: postgres:18
        ports:
        - containerPort: 5432
        env:
        - name: POSTGRES_DB
          value: fanclubdb
        - name: POSTGRES_USER
          value: appuser
        - name: POSTGRES_PASSWORD
          value: apppassword
        - name: PGDATA
          value: /var/lib/postgresql/data/pgdata
        volumeMounts:
        - name: data
          mountPath: /var/lib/postgresql/data
        - name: initdb
          mountPath: /docker-entrypoint-initdb.d
        resources:
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "512Mi"
            cpu: "500m"
      volumes:
      - name: initdb
        configMap:
          name: fanclub-db-init
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 1Gi
  • kind: ServiceclusterIP: Noneheadless Serviceです。StatefulSet は安定した DNS を得るためにこれを使い、serviceName: fanclub-db で結び付けます。Backend からは fanclub-db の名前で接続します。
  • volumeClaimTemplates:Pod ごとに data-fanclub-db-0 という PVC を自動生成します(<テンプレート名>-<StatefulSet名>-<序数>)。StorageClass は指定していないので既定の standard が使われます。
  • PGDATA をマウント直下ではなくサブディレクトリ .../pgdata にしています。マウント直下には lost+found 等が入りうるため、PostgreSQL の初期化が「ディレクトリが空でない」で失敗しないようにする定石です。
  • initdb ボリュームで ConfigMap fanclub-db-init/docker-entrypoint-initdb.d にマウントし、初回起動時に schema.sql を実行させます。この StatefulSet は ConfigMap fanclub-db-init を参照するため、先ほどの ConfigMap を先に作成してから適用してください(未作成だと Pod が ContainerCreating のまま止まります)。

実行コマンド:

$ kubectl apply -f db-statefulset.yaml
$ kubectl get pod fanclub-db-0 -o wide
$ kubectl get pvc

実行結果(例):Pod fanclub-db-0Running になり、PVC data-fanclub-db-0Bound(1Gi・RWO・standard)になります。PVC は Pod がスケジュールされて初めて Bound になります(WaitForFirstConsumer のため)。

NAME           READY   STATUS    RESTARTS   AGE   IP             NODE
fanclub-db-0   1/1     Running   0          30s   10.244.82.21   kind-control-plane

NAME                STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
data-fanclub-db-0   Bound    pvc-70bcc68f-2687-46b4-8d1b-461378fac77f   1Gi        RWO            standard       <unset>                 30s

members テーブルが作られたか、DB に psql でつないで確認します。一時的な psql Pod を使い、--rm で確認後に自動削除します。

実行コマンド:

$ kubectl run psql --rm -i --restart=Never --image=postgres:18 \
    --env=PGPASSWORD=apppassword -- \
    psql -h fanclub-db -U appuser -d fanclubdb -c '\dt'

実行結果(例):members テーブルが存在します。

         List of relations
 Schema |  Name   | Type  | Owner
--------+---------+-------+---------
 public | members | table | appuser

Backend を DB 接続対応(0.3.0)に更新する

第8回までの fanclub-backend:0.2.0 は、DB がまだ無い段階だったため DB に接続する設定(データソース)を持っていません。本回で PostgreSQL につなぐため、Backend に PostgreSQL データソースを追加した 0.3.0 を用意します。アプリは接続情報を環境変数DB_HOST / DB_PORT / DB_NAME / DB_USER / DB_PASSWORD)から受け取り、起動時にデータソース jdbc/fanclubDS を作成して接続します。ソース一式(変更点を含む)は本シリーズの教材一式に同梱しています。

補足(アプリ内部の話):今回の DB 接続は「アプリ層」の対応で、CKAD の出題範囲そのものではありません。要点は 接続情報をイメージに焼き込まず環境変数で受け取る点です(次回 ConfigMap / Secret に外部化します)。Payara Micro では、起動スクリプトが環境変数から JDBC データソースを作ってからアプリを deploy する順序にしています(データソースをアプリより先に用意するための実装上の工夫)。読者はこの仕組みを暗記する必要はなく、「Backend は DB_HOST 等の env で DB を見つける」とだけ押さえれば十分です。

教材一式の fanclub-api/(developer のホーム・一般ユーザー所有)で 0.3.0 をビルドし、第4回で構築した k8s-registry に push します。手順は第3〜4回と同じです。

実行コマンド:

$ cd ~/fanclub-api
$ docker build -t k8s-registry:5000/fanclub-backend:0.3.0 .
$ docker push k8s-registry:5000/fanclub-backend:0.3.0

Backend Pod に DB 接続情報を渡して作り直す

第7回で作った fanclub-backend Pod を、イメージを 0.3.0 に依存待ちを実在する fanclub-dbDB 接続 env を追加して作り直します。次のマニフェストを developer のホームに fanclub-backend-pod.yaml として作成します(一般ユーザー所有・root 不要)。接続情報は今回は Pod に直接(inline)書き、次回 ConfigMap / Secret に外部化します。

apiVersion: v1
kind: Pod
metadata:
  name: fanclub-backend
  labels:
    app: fanclub-backend
spec:
  initContainers:
  - name: wait-for-db
    image: busybox:1.37
    command:
    - sh
    - -c
    - 'until nslookup fanclub-db.default.svc.cluster.local >/dev/null 2>&1; do echo "waiting for fanclub-db..."; sleep 2; done; echo "fanclub-db resolved"'
  - name: log-shipper
    image: busybox:1.37
    restartPolicy: Always
    command:
    - sh
    - -c
    - 'echo "sidecar started"; tail -F /var/log/app/app.log 2>/dev/null || sleep infinity'
    volumeMounts:
    - name: applog
      mountPath: /var/log/app
  containers:
  - name: backend
    image: k8s-registry:5000/fanclub-backend:0.3.0
    ports:
    - containerPort: 8080
    env:
    - name: DB_HOST
      value: fanclub-db
    - name: DB_PORT
      value: "5432"
    - name: DB_NAME
      value: fanclubdb
    - name: DB_USER
      value: appuser
    - name: DB_PASSWORD
      value: apppassword
    resources:
      requests:
        memory: "512Mi"
        cpu: "250m"
      limits:
        memory: "768Mi"
        cpu: "1000m"
    volumeMounts:
    - name: applog
      mountPath: /var/log/app
  volumes:
  - name: applog
    emptyDir: {}
  • wait-for-dbfanclub-db の名前解決を待ってから本体を起動します(第7回の Init Container を実在の DB 依存に更新)。
  • env の 5 つが Backend のデータソース設定に使われます。DB_PORT は数値ですが env の値は文字列なので "5432" とクオートします。
  • パスワードを Pod に平文で書くのは本来避けるべきで、次回 Secret に移します。

第7回の Pod が残っている場合は作り直します。実行コマンド:

$ kubectl delete pod fanclub-backend --ignore-not-found
$ kubectl apply -f fanclub-backend-pod.yaml
$ kubectl get pod fanclub-backend

実行結果(例):Init / Sidecar / 本体の準備が整い 2/2Running になります(READY の分母 2 は本体 + ネイティブ Sidecar の数です)。

NAME              READY   STATUS    RESTARTS   AGE
fanclub-backend   2/2     Running   0          25s

3 層の動作確認と永続化の検証

DB につながると、第8回で 503 だった /health/readyUP になります。Payara Micro の起動には数秒かかるため、起動直後は数秒待ってから確認します。

実行コマンド:

$ kubectl run c1 --rm -i --restart=Never --image=curlimages/curl:latest -- \
    curl -s http://fanclub-backend:8080/health/ready

実行結果(例):

{"status":"UP","checks":[{"name":"fanclub-api-ready","status":"UP","data":{}}]}

会員を登録(POST)して、一覧(GET)に出るか確認します。planfree / standard / premium のいずれかです。

実行コマンド:

$ kubectl run c2 --rm -i --restart=Never --image=curlimages/curl:latest -- \
    curl -s -X POST http://fanclub-backend:8080/api/members \
    -H 'Content-Type: application/json' \
    -d '{"name":"山田太郎","email":"taro@example.com","plan":"premium"}'
$ kubectl run c3 --rm -i --restart=Never --image=curlimages/curl:latest -- \
    curl -s http://fanclub-backend:8080/api/members

実行結果(例):POST は登録された会員(採番された idcreated_at 付き)を返し、GET の一覧に 1 件出ます。

{"createdAt":"2026-06-27T00:04:07.560548","email":"taro@example.com","id":1,"name":"山田太郎","plan":"premium"}
[{"createdAt":"2026-06-27T00:04:07.560548","email":"taro@example.com","id":1,"name":"山田太郎","plan":"premium"}]

ブラウザで UI を見るには、第8回と同様に Frontend Service を port-forward します(第8回で作成した fanclub-frontend Pod / Service が動いている前提です)。確認後は Ctrl-C で停止します。

実行コマンド:

$ kubectl port-forward svc/fanclub-frontend 8888:80

ブラウザで http://localhost:8888/ を開くと、登録した会員が一覧に表示され、画面から登録・編集・削除が動きます。これで Frontend + Backend + DB の 3 層が完成しました(HTTPS での公開は第18回の Gateway API で行います)。

永続化を検証する(DB Pod を作り直してもデータが残る)

StatefulSet + PVC の効果を確認します。DB の Pod をわざと削除すると、StatefulSet が同じ名前 fanclub-db-0 で Pod を再作成し、同じ PVC data-fanclub-db-0 が再アタッチされます。

実行コマンド:

$ kubectl delete pod fanclub-db-0
$ kubectl get pod fanclub-db-0 -w

fanclub-db-0 が再び Running になったら(-wCtrl-C で抜けます)、もう一度一覧を取得します。なお DB Pod が入れ替わると Backend の接続プールが一時的に古い接続を保持するため、再作成直後は接続エラー(503)になることがあります。これは Backend の接続プールが古い接続を破棄して張り直すまで続きます。プールに接続検証を設定していない本構成では回復まで数十秒〜数分かかることがあるため、/health/ready が再び UP に戻るのを確認してから再実行すると確実です(急ぐ場合は Backend Pod を作り直すと即座にプールが張り直されます)。

実行コマンド:

$ kubectl run c4 --rm -i --restart=Never --image=curlimages/curl:latest -- \
    curl -s http://fanclub-backend:8080/api/members

実行結果(例):DB Pod を作り直したのに、先ほど登録した会員が残っています。これが PVC による永続化です。

[{"createdAt":"2026-06-27T00:04:07.560548","email":"taro@example.com","id":1,"name":"山田太郎","plan":"premium"}]

もし Pod 内のディスク(emptyDir や PVC なしのコンテナ FS)に DB を置いていたら、この時点でデータは消えていました。PVC は Pod のライフサイクルから独立しているため、データが守られます。

やってみよう

  1. kubectl get storageclass で既定 SC(standardWaitForFirstConsumer)を確認する。
  2. schema.sql を作成し、ConfigMap fanclub-db-init を作る。
  3. db-statefulset.yaml(headless Service + StatefulSet + volumeClaimTemplates)を適用し、fanclub-db-0Running・PVC data-fanclub-db-0Bound になることを確認する。
  4. Backend を 0.3.0 でビルド・push し、DB 接続 env 付きの Pod を作り直して 2/2 Running にする。
  5. /health/readyUP になり、POST → GET で会員が登録・取得できることを確認し、port-forward でブラウザ表示する。
  6. kubectl delete pod fanclub-db-0 で DB Pod を削除 → 再作成後も会員データが残ること(永続化)を確認する。

理解度チェック

次の各文が正しいか(○)誤りか(×)を判断してください。解答は下にまとめています。

  1. PVC はストレージの「要求」、PV はその「実体」である。
  2. kind の既定 StorageClass では、PVC に対して管理者が手動で PV を作らないと Bound にならない。
  3. StatefulSet の Pod 名は fanclub-db-0 のように序数付きで安定している。
  4. volumeClaimTemplates により、Pod ごとに専用の PVC が自動生成される。
  5. アクセスモード ReadWriteOnce は、複数ノードから同時に read-write できることを意味する。
  6. StatefulSet の Pod を削除すると、対応する PVC も一緒に削除されてデータが消える。
  7. PostgreSQL 公式イメージは、初回起動時に /docker-entrypoint-initdb.d/ 配下の .sql を実行する。

解答

  • 1. ○:PVC=要求、PV=実体。アプリ開発者は主に PVC を書く。
  • 2. ×:動的プロビジョニングで PV が自動生成される(WaitForFirstConsumer のため Pod 配置後に Bound)。
  • 3. ○:序数付きで安定。Deployment はランダムサフィックス。
  • 4. ○:data-fanclub-db-0 のように Pod ごとに自動生成される。
  • 5. ×:RWO は単一ノードからの read-write。複数ノード read-write は RWX。
  • 6. ×:Pod を消しても PVC は残り、データは保持される(再作成時に再アタッチ)。
  • 7. ○:データディレクトリが空の初回起動時に実行される。

まとめ

本記事では、コンテナの使い捨て性を起点に PV / PVC / StorageClass と動的プロビジョニングを学び、StatefulSet と volumeClaimTemplates で PostgreSQL 18 を永続化しました。schema.sql を ConfigMap 経由で initdb に渡して members テーブルを作り、Backend を DB 接続対応(0.3.0)に更新して 3 層を完成させました。POST / GET で会員 CRUD が動き、DB Pod を作り直してもデータが残ることで PVC の永続化を確認しました。これで模擬アプリは Frontend + Backend + DB の 3 層になり、ブラウザから実際に動かせる状態になりました。

次回予告

次回(第10回)は、今回 Pod に直接書いた DB 接続情報を ConfigMapSecret に外部化し、ServiceAccount の基礎を学びます。設定値とコードを分離し、パスワードを安全に扱う「12 ファクター」的な構成へと整えます。

シリーズ一覧

第1部:コンテナと Docker

第2部:Kubernetes 基礎

第3部:アプリリソース

第4部:ワークロード戦略

第5部:セキュリティ基礎

第6部:パッケージ管理 + HTTPS 公開

広告
kubernetes
スポンサーリンク