新卒インフラエンジニア向け「Kubernetes 実践教科書① CKAD アプリケーション開発編」の第9回です。第8回で fanclub-frontend を追加し、Frontend → Backend の通信を確立しました。ただし /api/members は DB がまだ無いため 503 のままでした。今回は PVC と StatefulSet で PostgreSQL 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) です。VOLUMEBINDINGMODE が WaitForFirstConsumer のため、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 を残してデータ保護)。

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(=同じデータ)に戻る」性質が得られます。

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: ServiceのclusterIP: Noneは headless 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ボリュームで ConfigMapfanclub-db-initを/docker-entrypoint-initdb.dにマウントし、初回起動時にschema.sqlを実行させます。この StatefulSet は ConfigMapfanclub-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-0 が Running になり、PVC data-fanclub-db-0 が Bound(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-db に、DB 接続 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-dbがfanclub-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/2 で Running になります(READY の分母 2 は本体 + ネイティブ Sidecar の数です)。
NAME READY STATUS RESTARTS AGE
fanclub-backend 2/2 Running 0 25s
3 層の動作確認と永続化の検証
DB につながると、第8回で 503 だった /health/ready が UP になります。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)に出るか確認します。plan は free / 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 は登録された会員(採番された id と created_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 になったら(-w は Ctrl-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 のライフサイクルから独立しているため、データが守られます。
やってみよう
kubectl get storageclassで既定 SC(standard・WaitForFirstConsumer)を確認する。schema.sqlを作成し、ConfigMapfanclub-db-initを作る。db-statefulset.yaml(headless Service + StatefulSet +volumeClaimTemplates)を適用し、fanclub-db-0がRunning・PVCdata-fanclub-db-0がBoundになることを確認する。- Backend を 0.3.0 でビルド・push し、DB 接続 env 付きの Pod を作り直して
2/2 Runningにする。 /health/readyがUPになり、POST → GET で会員が登録・取得できることを確認し、port-forwardでブラウザ表示する。kubectl delete pod fanclub-db-0で DB Pod を削除 → 再作成後も会員データが残ること(永続化)を確認する。
理解度チェック
次の各文が正しいか(○)誤りか(×)を判断してください。解答は下にまとめています。
- PVC はストレージの「要求」、PV はその「実体」である。
- kind の既定 StorageClass では、PVC に対して管理者が手動で PV を作らないと
Boundにならない。 - StatefulSet の Pod 名は
fanclub-db-0のように序数付きで安定している。 volumeClaimTemplatesにより、Pod ごとに専用の PVC が自動生成される。- アクセスモード
ReadWriteOnceは、複数ノードから同時に read-write できることを意味する。 - StatefulSet の Pod を削除すると、対応する PVC も一緒に削除されてデータが消える。
- 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 接続情報を ConfigMap と Secret に外部化し、ServiceAccount の基礎を学びます。設定値とコードを分離し、パスワードを安全に扱う「12 ファクター」的な構成へと整えます。
シリーズ一覧
第1部:コンテナと Docker
- 第1回 コンテナ技術概念 + Docker 環境準備
- 第2回 Docker 基本操作
- 第3回 Dockerfile + マルチステージ + JDK 25 / Payara Micro イメージビルド
- 第4回 コンテナレジストリ + イメージタグ戦略 + Trivy スキャン
第2部:Kubernetes 基礎
第3部:アプリリソース
- 第7回 Pod + Multi-container パターン
- 第8回 Service とネットワーキング
- 第9回 ストレージ(PVC + StatefulSet)+ PostgreSQL DB 追加 ← 今ここ
- 第10回 ConfigMap + Secret + ServiceAccount 基礎
- 第11回 Job + CronJob + DaemonSet
第4部:ワークロード戦略
- 第12回 Deployment + 3 Probe + Rolling Update + Probe デバッグ実践
- 第13回 Deployment 戦略補完(Blue/Green + Canary + Recreate)
- 第14回 ResourceQuota + LimitRange + Multi-tenant Namespace
