【4-6】Kubernetes実践編:データ永続化の基本!PV・PVCとStatefulSetまで
Kubernetes実践編:データ永続化の基本!PV・PVCとStatefulSetまで
はじめに
これまでの学習で、私たちはDeploymentでアプリを動かし、Serviceでネットワークを繋ぎ、ConfigMap/Secretで設定を外部化しました。
しかし、前回のWordPressの演習で、データベース(MySQL)のデータ保存に使ったのはemptyDir: {}でした。
emptyDirは、Podが起動するときに作成され、Podが削除されると一緒にデータも消えてしまう、非常に一時的なストレージです。これではデータベースのような永続性が求められるアプリは運用できません。
この記事では、この問題を解決するKubernetesのデータ永続化の仕組みを、「手動編」と「自動化編」の2ステップで学びます。
-
PersistentVolume(PV) /PersistentVolumeClaim(PVC): 「ロッカー」と「申請書」の仕組みを使った、永続化の基本を学びます。 -
StatefulSet: データベースなど「状態を持つ」アプリのために、PV/PVCの作成を自動化する強力なリソースを学びます。
Part 1: PVとPVCの概念:「ロッカー」と「申請書」
Kubernetesのストレージ管理は、「管理者がやること」 と 「利用者がやること」 が明確に分離されています。
これは「ロッカー」と「利用申請書」の関係に例えると非常に分かりやすいです。
1. PersistentVolume (PV) = 「ロッカー」
- 役割: クラスター管理者(インフラ担当)が用意した、利用可能なストレージ(ロッカー) そのものです
-
宣言: 「1GBで
manualクラスのロッカーが1つ空いています」と、Kubernetesに登録しておきます
2. PersistentVolumeClaim (PVC) = 「利用申請書」
- 役割: アプリケーション開発者(利用者)が提出する、ストレージの「利用申請書」 です
-
宣言: 「最低500MBで
manualクラスのロッカーを使わせてください」と、Kubernetesに申請します
3. バインド (Binding) = 「紐付け」
-
役割: Kubernetesが「申請書(PVC)」を見て、その要件(500MB以上,
manualクラス)を満たす空きロッカー(PV)を探し、自動で2つを「紐付け(Bind)」 します
4. Podでの利用
Podは、物理的なストレージ(PV)の存在を意識する必要はありません。ただ 「この申請書(PVC)を使います」と宣言するだけで、自動的に紐付いたロッカー(PV)がマウントされます。
Part 2: 実践!データを永続化してみる (hostPath編)
この「申請→紐付け→利用」の流れを、k3sサーバーのローカルディスク(hostPath)を使って実践してみましょう。
# k8s-practice ディレクトリに移動
cd ~/k8s-practice
# このフェーズ用の新しいディレクトリを作成して移動
mkdir storage-practice
cd storage-practice
Step 1: PV(ロッカー)の準備
PVマニフェストの作成 pv-hostpath.yamlを作成します。ここではhostPathという、ノード(k3sサーバー)のローカルディレクトリをストレージとして使う、開発環境向けのPVを作成します。
# pv-hostpath.yaml を作成
cat > pv-hostpath.yaml << 'EOF'
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-hostpath
spec:
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath:
path: /mnt/data
EOF
- capacity.storage: 1Gi: このPVは1GBの容量を提供します。
- accessModes: [ReadWriteOnce]: (RWO)このPVは、一度に1つのノードからのみ読み書きマウントできます。hostPathはこれしかサポートしません。
- persistentVolumeReclaimPolicy: Retain: 申請書(PVC)が削除されても、ロッカー(PV)と中のデータは保持(Retain) します。
- storageClassName: manual: これが重要です。「manual」という名前のストレージクラスを申請したPVCとのみ紐付くようにします。
- hostPath.path: /mnt/data: このPVの実体は、k3sサーバーの/mnt/dataディレクトリです。
hostPathは物理的なディレクトリなので、k3sサーバー側で手動で作成する必要があります。
PVの適用と、ホスト側ディレクトリの作成 PVを適用し、PVが使うディレクトリをk3sサーバー(ubuntu-server-01)上に手動で作成します。
# PVをクラスターに登録
k apply -f pv-hostpath.yaml
# PVが "Available" (利用可能) 状態であることを確認
k get pv
k describe pv pv-hostpath
# --- k3sサーバー(ubuntu-server-01)側で実行 ---
# PVが使うディレクトリを物理的に作成
sudo mkdir -p /mnt/data
# Pod(コンテナ)がこのディレクトリに書き込めるよう、権限を緩める(開発用)
sudo chmod 777 /mnt/data
Step 2: PVC(申請書)の提出とバインド
PVCマニフェストの作成 pvc-claim.yamlを作成します。「500MBのmanualクラスのストレージが欲しい」という申請書です。
# pvc-claim.yaml を作成
cat > pvc-claim.yaml << 'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-claim
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 500Mi
storageClassName: manual
EOF
- resources.requests.storage: 500Mi: 500MiBのストレージを要求します。
- storageClassName: manual: manualクラスのPVを探すよう指定します。
k apply -f pvc-claim.yamlを実行すると、k get pvcのSTATUSがBound(紐付け済)に、k get pvのSTATUSもBoundに変わります。
疑問①:1GiのPV vs 500MiのPVC。容量は「切り出される」の?
答え:いいえ、「ロッカー丸ごと(1Gi)」が割り当てられます。
PVCの500Miという要求は、「500MiBを切り出してくれ」という意味ではなく、「最低でも500MiB以上の空きがあるロッカーを探してください」という**検索条件(フィルター)**です。
Kubernetesは、この条件を満たす1GiのPVを見つけ、そのPV全体を、そのPVC専用として丸ごと割り当て(Bind)します。Podがマウントする際、Podから見えるボリュームのサイズはPVの全容量である1Giになります。
Step 3: PodからPVCを利用し、永続化をテストする
Podマニフェストの作成 pod-with-pvc.yamlを作成します。PodはPVの存在を意識せず、申請書(PVC)の名前だけを指定します。
# pod-with-pvc.yaml を作成
cat > pod-with-pvc.yaml << 'EOF'
apiVersion: v1
kind: Pod
metadata:
name: pod-with-storage
spec:
containers:
- name: app
image: nginx:alpine
volumeMounts:
- name: data-volume
mountPath: /usr/share/nginx/html
volumes:
- name: data-volume
persistentVolumeClaim:
claimName: pvc-claim
EOF
- volumes.persistentVolumeClaim.claimName: pvc-claim: ConfigMapやSecretの代わりに、persistentVolumeClaimを指定し、申請書(pvc-claim)の名前を渡します。
Podの作成とデータ永続化テスト Podを起動し、データを書き込み、Podを削除してもデータが残ることを確認します。
# Podを作成
k apply -f pod-with-pvc.yaml
# [テスト1] PodがRunningになったら、データを書き込む
# (Runningになるまで少し待つ)
k exec pod-with-storage -- sh -c 'echo "<h1>Persistent Data</h1>" > /usr/share/nginx/html/index.html'
# [テスト2] Podを削除する
k delete pod pod-with-storage
# [テスト3] もう一度同じ定義でPodを再作成する
k apply -f pod-with-pvc.yaml
# [テスト4] 新しいPodでデータが残っているか確認
# (Runningになるまで少し待つ)
k exec pod-with-storage -- cat /usr/share/nginx/html/index.html
<h1>Persistent Data</h1>が再び表示されれば、データの永続化は成功です!
k3sの1台構成では、hostPathでもデータが永続化できることが確認できました。
Part 3: なぜ本番でhostPathはNGなのか?(3ノード構成で考える)
hostPathで永続化できましたが、これはk3sが1台構成(司令塔と作業場を兼任)だから動いているにすぎません。
この構成がなぜ本番で使えないのか、実際の複数ノード構成を例に解説します。
本番環境の前提:3ノードクラスター
本番環境の最小構成として、以下の3台のワーカーノード(作業場)と、司令塔(コントロールプレーン)があると想像してください。
Pod(NginxやMySQLなど)は、worker-1〜worker-3のどれかに配置されます。
シナリオ1:hostPathを使った場合の「障害」
あなたがPart 2と同じhostPath: /mnt/dataのPVを使ったとします。
-
Podの初回起動:
k applyを実行。司令塔のSchedulerが「worker-1が最適だ」と判断します -
データ書き込み: Podはworker-1上で起動し、worker-1のローカルディスク(/mnt/data) に
index.htmlを書き込みます -
障害の発生: ある日、
k8s-worker-1がハードウェア障害でクラッシュしました - Kubernetesの自己修復: 司令塔はPodの障害を検知し、「worker-1は死んだから、次はworker-2で再起動しよう」と決定します
-
データ消失: Podはworker-2上で新しく起動します。しかし、worker-2の
/mnt/dataは空っぽです。データはクラッシュしたworker-1のディスクにしか存在しません
結果、Podは起動しますが、データはすべて失われます。
シナリオ2:本番のあるべき姿「共有ストレージ」
この問題を解決するのが、「どのワーカーノードからでもアクセスできる、外部の共有ストレージ」 を使う方法です。(例:NFS, Ceph, クラウドストレージ(AWS EBS/GCP PD)など)
PVの準備: 管理者は、hostPathではなく、nfs(ネットワーク上の共有ストレージ)を指すPVを作成します。
# PVの定義(イメージ)
spec:
storageClassName: nfs
nfs:
path: /data/mysql # NFSサーバー上のパス
server: 192.168.3.250 # NFSサーバーのIP
- Podの初回起動: Podはworker-1に配置されます。worker-1はネットワーク経由でnfs-server(192.168.3.250) をマウントし、そこにデータを書き込みます
-
障害の発生:
k8s-worker-1がクラッシュします - Kubernetesの自己修復: 司令塔はPodをworker-2で再起動します
- データ保持: worker-2もネットワーク経由で、worker-1が使っていたものと全く同じnfs-serverをマウントします
結果、Podはnfs-server上に保存されていたデータを正常に読み込み、何事もなかったかのようにサービスを継続します。
結論
hostPathは、Podが別のノードに移動(再スケジュール)されるとデータを失うため、本番では絶対に使えません。
本番環境の永続化には、Podがどのノードに移動しても同じデータにアクセスできる「共有ストレージ」(NFSやクラウドストレージ)が必須となります。
疑問②:複数のPodやノードで1つのPVを共有できる?
答え:はい、可能です!ただし、PVのaccessModes(アクセスモード)によります。
PVには、そのストレージがどのような共有をサポートしているかを示す3つのaccessModesがあります。
ReadWriteOnce (RWO)
- 1つのノードからのみ、読み書き(ReadWrite)マウントできます
- 複数のPodから共有できますが、それらのPodはすべて同じノードにいなければなりません
- (hostPathやAWS EBSなど、多くのストレージがこれです)
ReadOnlyMany (ROX)
- 複数のノードから、**読み取り専用(ReadOnly)**でマウントできます
ReadWriteMany (RWX)
- これこそが真の共有です
- 複数のノードから、同時に読み書き(ReadWrite) マウントできます
- DeploymentでPodが複数のノードに分散していても、全員が同じデータを読み書きできます
- これを実現するには、Part 3で紹介したNFSのような、同時書き込みに対応したネットワークストレージがPVの実体として必要です
Part 4: 実践(自動化編)StatefulSetで永続化を自動化する
PV/PVCの仕組みは分かりましたが、DeploymentはPod名がランダムでした。データベースのように 「Pod名(mysql-0)とストレージ(mysql-data-0)が常に1対1で対応」 する必要があるアプリには不向きです。
そこで使うのがStatefulSetです。
StatefulSetは、Deploymentと似ていますが、以下の重要な特徴を持ちます。
-
安定したPod名: Pod名が
mysql-0、mysql-1のように固定され、順序通りに(0, 1, 2...)起動します -
安定したストレージ:
volumeClaimTemplatesという機能で、mysql-0にはmysql-data-0を、mysql-1にはmysql-data-1を、というようにPodごとに専用のPVCを自動で作成・紐付けします
Step 1: StatefulSetとHeadless Serviceの作成
StatefulSetには、Pod名を解決するためのHeadless Service(clusterIP: Noneに設定されたService)が必須です。
# mysql-statefulset.yaml を作成
cat > mysql-statefulset.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
name: mysql
spec:
ports:
- port: 3306
name: mysql
clusterIP: None
selector:
app: mysql
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
spec:
serviceName: mysql
replicas: 1
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:8.0
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
ports:
- containerPort: 3306
name: mysql
volumeMounts:
- name: mysql-data
mountPath: /var/lib/mysql
volumeClaimTemplates:
- metadata:
name: mysql-data
spec:
accessModes: [ "ReadWriteOnce" ]
storageClassName: local-path
resources:
requests:
storage: 1Gi
EOF
-
kind: Service:
clusterIP: Noneに設定されたHeadless Serviceです。 -
kind: StatefulSet:
env.valueFrom: ConfigMap/Secretの記事で学んだSecret(db-credentials)からパスワードを安全に注入します。 -
volumeClaimTemplates:これがStatefulSetの核です。
metadata.name: mysql-dataというPVCのテンプレートを定義しています。 - storageClassName: local-path: これがk3sで便利な点です!
k3sにはlocal-pathというストレージクラスが標準で入っており、PVCが申請されると自動的にPVを作成・バインドしてくれます。Part 2のように手動でPVを作る必要がありません。
Step 2: StatefulSetの展開と永続化テスト
Secret(db-credentials)が作成済みであることを確認し、StatefulSetを適用します。
# Secretがなければ作成 (パスワードは適宜変更)
k create secret generic db-credentials \
--from-literal=password=RootP@ssw0rd123 --dry-run=client -o yaml | k apply -f -
# StatefulSetを適用
k apply -f mysql-statefulset.yaml
# PodとPVCの作成状況を監視
k get pods -l app=mysql -w
別のターミナルで以下も実行します。
k get pvc -w
mysql-0というPodがRunningになり、mysql-data-mysql-0というPVCが自動で作成・Boundになっていることを確認します。
Step 3: 永続化テスト
mysql-0 Podにデータベースを作成し、Podを削除してデータが残るか確認します。
# [テスト1] "testdb" というDBを作成
k exec mysql-0 -- mysql -uroot -pRootP@ssw0rd123 -e "CREATE DATABASE testdb;"
k exec mysql-0 -- mysql -uroot -pRootP@ssw0rd123 -e "SHOW DATABASES;"
# [テスト2] Podを強制削除
k delete pod mysql-0
# StatefulSetが即座に "mysql-0" を(同じ名前で)再作成するのを監視
k get pods -l app=mysql -w
# [テスト3] Podが再起動したら、再度DB一覧を表示
# (Runningになるまで少し待つ)
k exec mysql-0 -- mysql -uroot -pRootP@ssw0rd123 -e "SHOW DATABASES;"
testdb が再び表示されていれば、StatefulSetとvolumeClaimTemplatesによる動的な永続化は成功です!
まとめ
Kubernetesの最後の難関であった「データ永続化」について、2つのステップでマスターしました。emptyDirとは異なり、Podが消えてもデータが残り続ける仕組みを具体的に学ぶことができました。
PV(ストレージ)とPVC(申請)の役割を分離することで、開発者はインフラの詳細を意識することなく、必要な容量を申請するだけで安全にデータ永続化ができることを学びました。 hostPathは開発用であり、本番環境ではRWXモードを持つNFSなどの「共有ストレージ」が不可欠である理由も理解できました。
さらにStatefulSetを学ぶことで、手動でのPV作成をvolumeClaimTemplatesとstorageClassName: local-pathで自動化し、データベースのような「状態を持つ」アプリもKubernetesで安全に管理できることを確認しました。
Discussion