🖥️

【4-6】Kubernetes実践編:データ永続化の基本!PV・PVCとStatefulSetまで

に公開

Kubernetes実践編:データ永続化の基本!PV・PVCとStatefulSetまで

はじめに

これまでの学習で、私たちはDeploymentでアプリを動かし、Serviceでネットワークを繋ぎ、ConfigMap/Secretで設定を外部化しました。
https://zenn.dev/koikoi_infra/articles/30fff047229c31

しかし、前回のWordPressの演習で、データベース(MySQL)のデータ保存に使ったのはemptyDir: {}でした。

emptyDirは、Podが起動するときに作成され、Podが削除されると一緒にデータも消えてしまう、非常に一時的なストレージです。これではデータベースのような永続性が求められるアプリは運用できません。

この記事では、この問題を解決するKubernetesのデータ永続化の仕組みを、「手動編」と「自動化編」の2ステップで学びます。

  1. PersistentVolume (PV) / PersistentVolumeClaim (PVC): 「ロッカー」と「申請書」の仕組みを使った、永続化の基本を学びます。
  2. 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を使ったとします。

  1. Podの初回起動: k applyを実行。司令塔のSchedulerが「worker-1が最適だ」と判断します
  2. データ書き込み: Podはworker-1上で起動し、worker-1のローカルディスク(/mnt/data)index.htmlを書き込みます
  3. 障害の発生: ある日、k8s-worker-1がハードウェア障害でクラッシュしました
  4. Kubernetesの自己修復: 司令塔はPodの障害を検知し、「worker-1は死んだから、次はworker-2で再起動しよう」と決定します
  5. データ消失: 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
  1. Podの初回起動: Podはworker-1に配置されます。worker-1はネットワーク経由でnfs-server(192.168.3.250) をマウントし、そこにデータを書き込みます
  2. 障害の発生: k8s-worker-1がクラッシュします
  3. Kubernetesの自己修復: 司令塔はPodをworker-2で再起動します
  4. データ保持: 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-0mysql-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 ServiceclusterIP: 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の記事で学んだSecretdb-credentials)からパスワードを安全に注入します。
  • volumeClaimTemplates:これがStatefulSetの核です。metadata.name: mysql-dataというPVCのテンプレートを定義しています。
  • storageClassName: local-path: これがk3sで便利な点です!

k3sにはlocal-pathというストレージクラスが標準で入っており、PVCが申請されると自動的にPVを作成・バインドしてくれます。Part 2のように手動でPVを作る必要がありません。

Step 2: StatefulSetの展開と永続化テスト

Secretdb-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