インフラエンジニアの羅針盤

インフラエンジニア1〜3年目のための技術ガイド

CKA編⑫ LonghornでDB移行

公開更新

新卒インフラエンジニア向け「Kubernetes 実践教科書 ② CKA クラスタ構築・運用編」(全16回)の第12回です。前回で https://fanclub.local の外部公開が復活し、アプリは本番と同じ経路で動くようになりました。ただ、足元には第4回から放置している弱点があります——fanclub の DB は、たった 1 台の Workload Node のディスクに縛られているのです。そのノードが落ちれば、Pod を他のノードへ動かしてもデータには届きません。本回はここに手を入れます。PV/PVC/StorageClass の仕組みを運用者の視点で押さえ、分散ストレージ Longhorn を構築し、稼働中の fanclub-db を安全に引っ越します。データベースの移行は手順を 1 つ間違えるとデータを失うので、本回は「なぜその順番なのか」を確かめながら進めます。

本回は作業端末とノードの両方を使います。プロンプトの $ は一般ユーザー developer# は root を表し、各 VM へは手元のマシンから developer で SSH ログインし、root が必要な作業は sudo -i で root シェルに切り替えます(第4・6回と同じ流儀です)。各コマンドの前に実行先のホスト名を明記します。kubectlhelm は作業端末 k8s-ops192.168.1.122)から developer で実行し、第4回で配布した admin.conf(cluster-admin)を使います。ノードで行うのは Longhorn の前提パッケージ導入と、ストレージの実体確認だけです。

本回作るファイル(StatefulSet の退避 YAML と DB のバックアップ)は、k8s-ops の developer のホーム配下 ~/storage にまとめます(所有者は developersudo は使いません)。以降の kubectl のリダイレクトはこのディレクトリにいる前提です(chart を編集するときだけ ~ へ移動します)。

実行コマンド(k8s-ops・developer):

$ mkdir -p ~/storage && cd ~/storage
目次
  1. 今ここマップ(全 16 回中の現在地)
  2. この回のゴール
  3. PV / PVC / StorageClass の関係を押さえる
  4. アクセスモードと Reclaim Policy
  5. Longhorn を構築する
  6. 既定の StorageClass を付け替える
  7. fanclub-db を Longhorn へ移行する
  8. 移行できたことを確かめる
  9. やってみよう
  10. まとめ
  11. 理解度チェック(○×形式・全 9 問)
  12. 次回予告

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

本シリーズは 6 部構成です。現在地は第4部「ストレージ」の第12回です。

  • 第1部 クラスタ構築(第1〜5回)
  • 第2部 ワークロード管理(第6〜8回)
  • 第3部 ネットワーク(第9〜11回)
  • 第4部 ストレージ(第12回)← 今ここ
  • 第5部 監視・運用(第13〜14回)
  • 第6部 トラブルシュート(第15〜16回)
  • Kubernetes v1.36.2
  • kubectl v1.35.6(作業端末)
  • Longhorn v1.11.1
  • local-path-provisioner v0.0.30
  • PostgreSQL 18
  • Helm v4.1.4
  • AlmaLinux 10.2
  • PV
  • PVC
  • StorageClass の APIv1storage.k8s.io/v1)は安定版で、マイナー番号が変わっても同じ
  • 確認日 2026-07-17

この回のゴール

本回を終えると、次のことができるようになります。到達できたかは記事末の「やってみよう」と「理解度チェック」で確認します。

  • PV/PVC/StorageClass と動的プロビジョニングの関係を説明できる
  • アクセスモード(RWO/ROX/RWX)と Reclaim Policy(Retain/Delete)を使い分けられる
  • Longhorn を構築し、レプリカ 2 の分散ストレージを提供できる
  • StatefulSet の PVC を別の StorageClass へ、データを保ったまま移行できる

PV / PVC / StorageClass の関係を押さえる

まず用語を整理します。3 つの登場人物の関係はこうです。

  • PVC(PersistentVolumeClaim)=要求:アプリ側が「1Gi の読み書きできる領域がほしい」と申請する。Pod が参照するのはこれ。
  • PV(PersistentVolume)=実体:実際に確保されたボリューム。どのディスクのどこか、という現物。
  • StorageClass(SC)=実体を作る係:PVC が来たときに PV を自動で作るプロビジョナの設定。これが動的プロビジョニングです。

アプリ担当は PVC を書くだけで、実体(PV)は SC が用意する——第11回の Gateway API と同じ役割分離の考え方です。では現状を見ます。

実行コマンド(k8s-ops・developer):

$ kubectl get storageclass

実行結果:

NAME                   PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  4d10h

SC は local-path ひとつだけで、(default) が付いています。これは第4回で「kubeadm 素のクラスタには既定の StorageClass が無い」ため、fanclub-db の PVC を Bound させる目的で暫定的に入れたものです。読み方の要点は 3 つあります。

  • PROVISIONER rancher.io/local-path:ノードのローカルディレクトリを PV として切り出す実装。
  • RECLAIMPOLICY Delete:PVC を消すと PV も実体も消える。DB では危険な側の設定です(後述)。
  • VOLUMEBINDINGMODE WaitForFirstConsumer:Pod がどのノードに載るか決まってから PV を作る。=PV がそのノードに縛られる

この 3 つ目が本回の急所です。実際に PV/PVC と、DB がどのノードに居るかを突き合わせます。

実行コマンド(k8s-ops・developer):

$ kubectl get pv
$ kubectl get pvc -n fanclub
$ kubectl get pod fanclub-db-0 -n fanclub -o wide

実行結果(抜粋):

NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                       STORAGECLASS   AGE
pvc-064eb47d-3cbd-4b83-8883-b65cb683faaf   1Gi        RWO            Delete           Bound    fanclub/data-fanclub-db-0   local-path     4d10h

NAME                STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
data-fanclub-db-0   Bound    pvc-064eb47d-3cbd-4b83-8883-b65cb683faaf   1Gi        RWO            local-path     4d10h

NAME           READY   STATUS    RESTARTS   AGE   IP               NODE        NOMINATED NODE   READINESS GATES
fanclub-db-0   1/1     Running   2          27h   10.244.119.179   k8s-wl-02   <none>           <none>

PVC data-fanclub-db-0(1Gi・RWO)が PV に Bound し、db-0 は k8s-wl-02 で動いています。PVC 名が data-fanclub-db-0 という形なのは、StatefulSet の volumeClaimTemplates(名前 data)が Pod ごとに <テンプレート名>-<StatefulSet 名>-<序数> で PVC を作るためです。

この PV の「実体」がどこにあるのかを、ノード側から見ます。local-path はノードのディレクトリを使うので、現物がファイルシステム上にあります。SSH する先は、いま -o wideNODE 列に出たノードです(この例では k8s-wl-02。読者の環境では k8s-wl-01 になっていることもあります。その場合はそちらへ入ってください——データはそのノードにしか無いので、別のノードで探しても見つかりません)。

実行コマンド(db-0 が居るノード=この例では k8s-wl-02・root):

# du -sh /opt/local-path-provisioner/*

実行結果:

47M	/opt/local-path-provisioner/pvc-064eb47d-3cbd-4b83-8883-b65cb683faaf_fanclub_data-fanclub-db-0

会員データの現物は、wl-02 のローカルディスクに 47M のディレクトリとして存在している——これが今の姿です。wl-02 が落ちたら、Pod を wl-01 へ動かしてもこのデータには届きません第7回で「Pod をどこに載せるか」を制御しましたが、ローカルディスクの PV は逆にPod をノードに縛る側にまわります)。本番の DB としては、ここが弱点です。

PVC が要求・PV が実体・StorageClass が実体を作る係という動的プロビジョニングの関係図と、local-path では PV がノードのローカルディスクに作られて Pod がそのノードに縛られるのに対し、Longhorn では複数ノードにレプリカを持つため Pod がどのノードでも同じデータに届くことを対比した図
図1:PVC / PV / StorageClass の関係と、local-path と Longhorn の違い

アクセスモードと Reclaim Policy

移行の前に、PVC を書くときに必ず決める 2 つの設定を押さえます。CKA でも問われる基本です。

アクセスモードは「誰が同時に読み書きできるか」です。

  • RWO(ReadWriteOnce)1 つのノードから読み書き。ブロックストレージ系の既定で、PostgreSQL のような DB はこれ(複数から同時に書くと壊れるため)。
  • ROX(ReadOnlyMany):複数ノードから読み取り専用。静的コンテンツの配布などに使う。
  • RWX(ReadWriteMany):複数ノードから同時に読み書き。NFS 系が必要で、共有アップロード領域などに使う。

注意したいのは、RWO は「1 ノードから」であって「1 Pod から」ではないことです。同じノードに載った複数の Pod は同じ RWO ボリュームを使えます。fanclub-db は RWO で、これは移行後も変わりません。

Reclaim Policyは「PVC を消したとき、実体をどうするか」です。

  • Delete:PVC を消すと PV も実体も消える。使い捨て用途では便利。現在の local-path はこれ
  • Retain:PVC を消しても PV とデータが残る(PV は Released 状態になり、手動で後始末する)。DB のように失えないデータ向け

ここが本回の移行で効いてきます。今の SC は Delete なので、旧 PVC を消した瞬間に、先ほど見たあの 47M は消えます。だからこの後の手順では、PVC を消す前に必ず pg_dump でデータを手元に退避します。順番が逆なら、それは復旧不能な事故です。

Longhorn を構築する

Longhorn は、各ノードのディスクを束ねてレプリカを複数ノードに分散する分散ストレージです。1 台落ちても他のノードにレプリカがあるので、Pod がどこへ動いてもデータに届きます。

ここで 3 つ目の拡張インターフェースが登場します。第2回CRI(containerd)、CNI(Calico)を見ましたが、ストレージは CSI(Container Storage Interface)で差し替えます。Longhorn はその CSI ドライバとして動きます。Kubernetes 本体は「PVC が来たら CSI ドライバに作らせる」だけで、実装が誰であるかは知りません——CKA の D1「CRD を理解し operator をインストールする」とも地続きの、拡張の思想です。

まず前提パッケージを入れます。Longhorn はボリュームを iSCSI 経由で Pod に見せるため、ノード側に iSCSI のイニシエータが要ります。これを入れずに進めると、後で Pod が ContainerCreating のまま止まりますWorkload Node の 2 台(k8s-wl-01・k8s-wl-02)それぞれで実行してください(Control Plane には taint があり Longhorn のワークロードが載らないため不要です)。

実行コマンド(k8s-wl-01・k8s-wl-02 の両方・root):

# dnf install -y iscsi-initiator-utils nfs-utils cryptsetup
# systemctl enable --now iscsid
# systemctl is-active iscsid

実行結果:

active

nfs-utils は RWX ボリュームを使う場合に、cryptsetup は暗号化ボリュームを使う場合に必要です。本回では使いませんが、Longhorn が前提として要求するので一緒に入れておきます。

次に Helm で Longhorn を導入します。その前に alma-proxy の whitelist に chart の配信元 charts.longhorn.io を足します第9回の MetalLB(metallb.github.io)と同じで、これを忘れると helm repo addForbidden で弾かれます。

実行コマンド(alma-proxy・root):

# echo 'charts.longhorn.io' >> /etc/squid/whitelist.txt
# systemctl reload squid

k8s-ops に戻って導入します。データの置き場所は既定の /var/lib/longhorn(各ノードのルートファイルシステム上)です。本番では専用ディスクを割り当てることもありますが、本回はストレージ機能の理解に集中するため既定のままにします(ラボのルート領域には 27 GB 以上の空きがあり、1Gi のボリュームには十分です)。

実行コマンド(k8s-ops・developer):

$ helm repo add longhorn https://charts.longhorn.io
$ helm repo update
$ helm install longhorn longhorn/longhorn \
    --namespace longhorn-system --create-namespace \
    --version 1.11.1 \
    --set persistence.defaultClassReplicaCount=2 \
    --set defaultSettings.defaultReplicaCount=2

2 つの --setこの環境に合わせるための指定です。Longhorn は既定でレプリカを 3 本持とうとしますが、レプリカは別々のノードに置かれるため、Workload Node が 2 台しかない本環境では 3 本目を置く先がありません。そのままだとボリュームが degraded(冗長性が足りない)状態から抜けられないので、最初から 2 本に合わせます。第9回の MetalLB で frrk8s を無効化したのと同じ、chart の既定を自分の環境に合わせる作業です。

2 つある理由も押さえてください。効く先が違いますpersistence.defaultClassReplicaCount は、この後 chart が作る StorageClass longhorn のパラメータnumberOfReplicas)になります——PVC 経由で作られるボリュームのレプリカ数はこちらが決めます。一方 defaultSettings.defaultReplicaCountLonghorn 全体の既定設定で、UI から直接作るボリュームなど、その StorageClass を通らない場合に効きます。本回の fanclub-db は PVC 経由なので、効いているのは前者です(後者だけ指定しても StorageClass は 3 のままになります)。

実行結果(抜粋):

NAME: longhorn
LAST DEPLOYED: Fri Jul 17 08:53:41 2026
NAMESPACE: longhorn-system
STATUS: deployed
REVISION: 1

Pod が出そろうまで数分かかります(イメージの pull があるため、ContainerCreating が続いても慌てず待ちます)。

実行コマンド(k8s-ops・developer):

$ kubectl get pods -n longhorn-system

実行結果(抜粋・全 23 Pod):

NAME                                                READY   STATUS    RESTARTS   AGE
csi-attacher-8d6dc7d97-4fpm9                        1/1     Running   0          2m1s
csi-provisioner-774bbfdf4-cmnm7                     1/1     Running   0          2m1s
csi-resizer-5948464f46-6wx7h                        1/1     Running   0          2m1s
csi-snapshotter-6bf57ccd5f-5689p                    1/1     Running   0          2m1s
engine-image-ei-75a03ec3-2dm2v                      1/1     Running   0          2m44s
instance-manager-c36384cab41c5643fb3ac92529788a0c   1/1     Running   0          2m14s
longhorn-csi-plugin-89q8v                           3/3     Running   0          2m
longhorn-driver-deployer-574df6b766-lglkk           1/1     Running   0          3m10s
longhorn-manager-5g472                              2/2     Running   0          3m10s
longhorn-ui-9769bcdf8-67c2g                         1/1     Running   0          3m10s

数が多くて面食らうかもしれませんが、役割ごとに分かれているだけです。longhorn-manager が各ノードでディスクとレプリカを管理し(Workload Node の 2 台に 1 つずつ)、csi-* が Kubernetes と Longhorn をつなぐ CSI の部品、instance-manager がボリュームの実体を動かし、longhorn-ui が管理画面です。すべて Workload Node(wl-01・wl-02)に載り、Control Plane には 1 つも載りません——第7回で見た taint が効いているからです(kubectl get pods -n longhorn-system -o wide の NODE 列で確認できます)。

CSI ドライバとして登録されたことも確認できます。

実行コマンド(k8s-ops・developer):

$ kubectl get csidrivers

実行結果:

NAME                 ATTACHREQUIRED   PODINFOONMOUNT   STORAGECAPACITY   TOKENREQUESTS   REQUIRESREPUBLISH   MODES        AGE
driver.longhorn.io   true             true             true              <unset>         false               Persistent   2m10s

既定の StorageClass を付け替える

Longhorn は自分の StorageClass を作ります。確認します。

実行コマンド(k8s-ops・developer):

$ kubectl get storageclass

実行結果(longhorn 系が 2 つ増えた):

NAME                   PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  4d18h
longhorn (default)     driver.longhorn.io      Delete          Immediate              true                   2m56s
longhorn-static        driver.longhorn.io      Delete          Immediate              true                   2m53s

local-path との違いが読み取れます。Immediate(Pod の配置を待たずに PV を作れる=特定ノードに縛られない)と、ALLOWVOLUMEEXPANSION true(後から容量を拡張できる)です。分散ストレージらしい性質がそのまま表に出ています。longhorn-static は、あらかじめ作った Longhorn ボリュームを手動で PV に紐づける(静的プロビジョニング)ための SC で、本回では使いません。

ここで見逃せないのが、(default) が 2 つあることです。Longhorn の chart は自分の SC を既定として作るため、第4回で入れた local-path既定が二重になっています。既定の SC は「PVC で storageClassName を書かなかったときに使われる SC」ですが、では二重のときはどうなるのか——Kubernetes は「最後に作られた既定」を採用します(つまり今は longhorn)。エラーにはなりません。

エラーにならないからこそ厄介です。どちらが使われるかが作成順という見えにくい要素で決まるため、「意図しない SC で PV が作られていた」という事故につながります。既定は 1 つに揃えるのが運用の作法なので、local-path の既定を外します(longhorn は既に既定なので、こちらへの操作は要りません)。

実行コマンド(k8s-ops・developer):

$ kubectl patch storageclass local-path -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'
$ kubectl get storageclass

実行結果((default) が longhorn だけになった):

storageclass.storage.k8s.io/local-path patched

NAME                 PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path           rancher.io/local-path   Delete          WaitForFirstConsumer   false                  4d18h
longhorn (default)   driver.longhorn.io      Delete          Immediate              true                   4m
longhorn-static      driver.longhorn.io      Delete          Immediate              true                   4m

なお 既定を付け替えても、既に Bound している PVC は動きませんdata-fanclub-db-0local-path のままです。既定 SC は「これから作る PVC」にしか効かないからです。だから移行が必要——ここが次の節の出発点になります。

Longhorn の管理画面も見ておきます。longhorn-frontend Service を port-forward して、手元のブラウザから開きます(第11回で外部公開は Gateway 経由に統一したので、管理用 UI は外に出さず port-forward で覗きます)。

実行コマンド(k8s-ops・developer):

$ kubectl port-forward -n longhorn-system svc/longhorn-frontend --address 0.0.0.0 8081:80

手元のブラウザで http://192.168.1.122:8081 を開くと、ノード一覧やディスクの空き容量が見えます。--address 0.0.0.0 は「k8s-ops 以外のマシン(読者のホスト OS)から見る」ために付けています。確認が済んだら Ctrl+C で止めます。

fanclub-db を Longhorn へ移行する

ここからが本番です。先になぜ単純な helm upgrade で済まないのかを押さえます。理由は 2 つあります。

  • StatefulSet の volumeClaimTemplates は不変フィールド:後から storageClassName を変えて apply しても、次のように拒否されます(変更できるのは replicastemplate 等に限られます)。StatefulSet を作り直すしかありません。
  • chart が storageClassName をパラメータ化していない:第1巻から引き継いだ chart の volumeClaimTemplates には SC の指定が無く、既定 SC 任せです。値を変えられるように chart 側を拡張する必要があります。

参考(storageClassName を足して apply したときに返るエラー):

The StatefulSet "fanclub-db" is invalid: spec: Forbidden: updates to statefulset spec for fields
other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'revisionHistoryLimit',
'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden

そして最大の注意点です。現在の local-path は Reclaim Policy が Delete なので、旧 PVC を削除した瞬間に実体(47M)が消えます。したがって手順は「①データを退避 → ②作り直し → ③データを戻す」の順で、①を飛ばしたら終わりです。

その前に成否を判定できる印を付けておきます。というのも、fanclub の chart は helm upgrade のたびに fanclub-db-migrate という Job(Helm の post-upgrade フック)を走らせ、seed の会員 2 名を投入し直すようになっているからです。つまり移行後に空のボリュームで DB を作り直すと、復元をしなくても会員が 2 名に戻ってしまいます。これでは「2 名居るから移行成功」とは言えません。そこで移行前に、seed には無い会員を 1 名足しておき、それが移行後に戻っているかで判定します。

実行コマンド(k8s-ops・developer):

$ kubectl get statefulset fanclub-db -n fanclub -o yaml > fanclub-db-backup.yaml
$ kubectl exec fanclub-db-0 -n fanclub -- psql -U appuser -d fanclubdb -c "INSERT INTO members (name,email,plan) VALUES ('移行テスト','migrate-test@example.com','premium');"
$ kubectl exec fanclub-db-0 -n fanclub -- psql -U appuser -d fanclubdb -c "SELECT count(*) FROM members;"

実行結果(seed 2 名+印の 1 名=3 名が移行前の状態。この 3 名が移行後にも居れば成功):

INSERT 0 1
 count
-------
     3
(1 row)

①データを退避します。pg_dump の出力を、作業ディレクトリ ~/storage にファイルとして落とします。ポイントが 2 つあります。kubectl exec には -i は付けますが -t は付けません-t を付けると TTY の制御文字が混ざり、復元時に壊れます)。そして --clean を付けます——復元先の DB には先ほどの Job が seed 2 名を入れた状態になっているため、素のダンプを流すと「テーブルが既にある」「メールアドレスが重複する」と衝突します。--clean を付けるとダンプの先頭に「既存のテーブルを削除する」文が入り、退避した時点の状態へ確実に戻せます

実行コマンド(k8s-ops・developer):

$ kubectl exec -i fanclub-db-0 -n fanclub -- pg_dump --clean -U appuser fanclubdb > fanclubdb-backup.sql
$ ls -lh fanclubdb-backup.sql
$ grep -E "DROP TABLE|migrate-test" fanclubdb-backup.sql

実行結果(ダンプに DROP TABLE と印の会員が含まれている):

-rw-r--r--. 1 developer developer 3.1K  7月 17 08:57 fanclubdb-backup.sql
DROP TABLE public.members;
7	移行テスト	migrate-test@example.com	premium	2026-07-16 23:57:15.77726	\N

ファイルが空でないこと、そして「印」の会員がダンプに含まれていることを必ず目視してください(所有者は developer・場所は ~/storage です)。ここを飛ばして先へ進むと、戻すデータがありません。

②作り直しに入ります。まず StatefulSet だけを消します。--cascade=orphan を付けると、管理下の Pod を残したまま StatefulSet の定義だけが消えます(付けなければ Pod も道連れで止まります)。ここでは「DB をいきなり止めない」ために付けています——ダンプの中身に不安があれば、この段階ならまだ動いている Pod から取り直せます。

なお PVC は --cascade の指定に関係なく残ります。StatefulSet が volumeClaimTemplates で作った PVC の運命は persistentVolumeClaimRetentionPolicy という別のフィールドが決めており、その既定が Retain(保持)だからです(データを不用意に失わせないため)。kubectl get statefulset fanclub-db -n fanclub -o jsonpath='{.spec.persistentVolumeClaimRetentionPolicy}' を実行すると {"whenDeleted":"Retain","whenScaled":"Retain"} と返り、実際に確認できます。つまりこのコマンドで消えるのは StatefulSet の定義だけで、Pod は --cascade=orphan が、PVC は Retain 既定が守っている、という関係です。

実行コマンド(k8s-ops・developer):

$ kubectl delete statefulset fanclub-db -n fanclub --cascade=orphan
$ kubectl get pod fanclub-db-0 -n fanclub
$ kubectl get pvc -n fanclub

実行結果(StatefulSet は消えたが Pod と PVC は残る):

statefulset.apps "fanclub-db" deleted from fanclub namespace

NAME           READY   STATUS    RESTARTS   AGE
fanclub-db-0   1/1     Running   2          27h

NAME                STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
data-fanclub-db-0   Bound    pvc-064eb47d-3cbd-4b83-8883-b65cb683faaf   1Gi        RWO            local-path     4d10h

次に、残った Pod と旧 PVC を削除します。ここで、先ほど見たノード上の実体(47M のディレクトリ)が消えます(Reclaim Policy が Delete のため)。~/storage/fanclubdb-backup.sql が手元にあり、中に印の会員が入っていることを、もう一度確かめてから実行してください。ここから先は、このファイルだけが会員データの拠り所です。

実行コマンド(k8s-ops・developer):

$ kubectl delete pod fanclub-db-0 -n fanclub
$ kubectl delete pvc data-fanclub-db-0 -n fanclub
$ kubectl get pv

実行結果(PVC を消した直後は PV が Released で残っている):

pod "fanclub-db-0" deleted from fanclub namespace
persistentvolumeclaim "data-fanclub-db-0" deleted from fanclub namespace

NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS     CLAIM                       STORAGECLASS   AGE
pvc-064eb47d-3cbd-4b83-8883-b65cb683faaf   1Gi        RWO            Delete           Released   fanclub/data-fanclub-db-0   local-path     4d18h

Released は「PVC との結びつきが切れた」状態です。Reclaim Policy が Delete なので、この後プロビジョナが PV と実体を非同期に片づけます(十数秒かかります)。少し待ってから見ると、PV は消えています。

実行コマンド(k8s-ops・developer):

$ kubectl get pv

実行結果(PV が消えた=Delete の挙動):

No resources found

ノード側の実体も無くなっています。先ほど 47M あったディレクトリは、du -sh /opt/local-path-provisioner/0 を返す空の状態になりました。これが Delete の意味です——ここまで来ると、頼れるのは手元の fanclubdb-backup.sql だけです。

chart を拡張します。~/fanclub-chart/templates/db-statefulset.yamlvolumeClaimTemplatesstorageClassName の行を足し、値を values.yaml から渡せるようにします。vi などのエディタで開き、末尾の volumeClaimTemplates を次の形にします(追加するのは storageClassName の 1 行だけです)。

  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      storageClassName: {{ .Values.db.storageClass }}
      resources:
        requests:
          storage: {{ .Values.db.storage }}

続いて ~/fanclub-chart/values.yamldb: セクションに既定値を足します。

db:
  image:
    repository: postgres
    tag: "18"
  storage: 1Gi
  storageClass: longhorn              # 追加:第12回で local-path から移行

helm upgrade で作り直します。--set を毎回付けるのを忘れないでください——省くと values.yaml の既定に戻り、第11回で有効化した Gateway が無効になって https://fanclub.local が落ち、frontend のイメージも古いタグに戻ります

実行コマンド(k8s-ops・developer):

$ cd ~
$ helm upgrade fanclub ./fanclub-chart \
    --namespace fanclub \
    --set gateway.enabled=true \
    --set frontend.image.tag=1.0.0

実行結果(抜粋):

Release "fanclub" has been upgraded. Happy Helming!
NAME: fanclub
NAMESPACE: fanclub
STATUS: deployed
REVISION: 3

新しい PVC が longhorn で作られ、db-0 が起動します。

実行コマンド(k8s-ops・developer):

$ kubectl get pvc -n fanclub
$ kubectl get pod fanclub-db-0 -n fanclub -o wide

実行結果(STORAGECLASS が longhorn に変わった):

NAME                STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
data-fanclub-db-0   Bound    pvc-97c4cbed-9efb-418b-af9c-55f5d6fabbcb   1Gi        RWO            longhorn       70s

NAME           READY   STATUS    RESTARTS   AGE   IP               NODE        NOMINATED NODE   READINESS GATES
fanclub-db-0   1/1     Running   0          70s   10.244.119.131   k8s-wl-02   <none>           <none>

ここで復元前の中身を見ておきます。ボリュームは新品なのに、DB は空ではありません。

実行コマンド(k8s-ops・developer):

$ kubectl exec fanclub-db-0 -n fanclub -- psql -U appuser -d fanclubdb -c "SELECT name FROM members;"

実行結果(seed の 2 名だけが居る=印の「移行テスト」は居ない):

   name
----------
 鈴木花子
 佐藤一郎
(2 rows)

これが先ほど触れた fanclub-db-migrate フックの仕業です。空のボリュームにテーブルが作られ、Job が seed を入れ直したので、会員は 2 名まで「勝手に」戻っていますもしここで「2 名居るから移行できた」と判断していたら、印の 1 名を失ったことに気づけません。判定に印を使う理由がここにあります。

③データを戻します。退避しておいた SQL を流し込みます(--clean 付きで取ったので、先ほどのテーブルは削除されてから作り直されます)。

実行コマンド(k8s-ops・developer):

$ cd ~/storage
$ kubectl exec -i fanclub-db-0 -n fanclub -- psql -U appuser -d fanclubdb < fanclubdb-backup.sql
$ kubectl exec fanclub-db-0 -n fanclub -- psql -U appuser -d fanclubdb -c "SELECT count(*) FROM members;"
$ kubectl exec fanclub-db-0 -n fanclub -- psql -U appuser -d fanclubdb -c "SELECT name FROM members WHERE email='migrate-test@example.com';"

実行結果(3 名に戻り、印の会員が居る=データが引き継がれた証拠):

 count
-------
     3
(1 row)

    name
------------
 移行テスト
(1 row)

移行できたことを確かめる

3 つの角度から確認します。まずストレージ層——PVC が longhorn になり、Longhorn 側でボリュームが Healthyレプリカが 2 本あることです。

実行コマンド(k8s-ops・developer):

$ kubectl get volumes.longhorn.io -n longhorn-system
$ kubectl get replicas.longhorn.io -n longhorn-system -o custom-columns=NAME:.metadata.name,NODE:.spec.nodeID,STATE:.status.currentState

実行結果(healthy=必要なレプリカが揃っている・レプリカは 2 ノードに 1 本ずつ):

NAME                                       DATA ENGINE   STATE      ROBUSTNESS   SCHEDULED   SIZE         NODE        AGE
pvc-97c4cbed-9efb-418b-af9c-55f5d6fabbcb   v1            attached   healthy                  1073741824   k8s-wl-02   4m4s

NAME                                                  NODE        STATE
pvc-97c4cbed-9efb-418b-af9c-55f5d6fabbcb-r-110c15a4   k8s-wl-01   running
pvc-97c4cbed-9efb-418b-af9c-55f5d6fabbcb-r-5db06e6c   k8s-wl-02   running

healthy は「必要な数のレプリカが揃っている」状態です。NODE 列を見ると、レプリカが wl-01 と wl-02 に 1 本ずつ置かれています——DB の Pod は wl-02 に居ますが、データの複製は wl-01 にもある。これが local-path との決定的な違いです。Longhorn UI の Volume 画面でも同じことが図で確認できます。

もし degraded と表示されたら、レプリカを置く先が足りていない合図です。install のときに persistence.defaultClassReplicaCount=2 を付け忘れた場合がこれにあたります(StorageClass が 3 本を要求し、3 本目の行き先が無い状態)。kubectl get storageclass longhorn -o jsonpath='{.parameters.numberOfReplicas}' で確認できます。入れ直さなくても、そのボリュームのレプリカ数を 2 に変えれば healthy に戻せます

実行コマンド(k8s-ops・developer・degraded のときだけ):

$ VOL=$(kubectl get pvc data-fanclub-db-0 -n fanclub -o jsonpath='{.spec.volumeName}')
$ kubectl patch volumes.longhorn.io $VOL -n longhorn-system --type=merge -p '{"spec":{"numberOfReplicas":2}}'

ただし、これはそのボリューム 1 つを直すだけです。StorageClass の設定は 3 のままなので、以降に作る PVC はまた 3 本になります。恒久的に直すなら、Longhorn を入れ直して defaultReplicaCount=2 を付けるのが確実です。

次にアプリ層ですが、その前にやることが 1 つ残っています。DB を作り直したので、backend は「消えた DB Pod への接続」を掴んだままになっています。この状態でブラウザを開いても会員一覧は空で、backend のログには次のエラーが出ます。

実行コマンド(k8s-ops・developer):

$ kubectl get pods -n fanclub | grep backend
$ kubectl logs -n fanclub -l app=fanclub-backend -c backend --tail=3

実行結果(1/2 のまま準備完了にならず、DB への接続が切れている):

fanclub-backend-6896d48bd9-lxxvj   1/2     Running   4 (21m ago)   35h

Caused by: org.postgresql.util.PSQLException: This connection has been closed.

接続プールは自力では回復しませんので、backend を作り直します。ここで第8回の教訓が効いてきます——fanclub 名前空間には fanclub-quotalimits.cpu=3)があり、kubectl rollout restart は新しい Pod を「先に 1 つ増やしてから古いのを消す」ため、quota を超えて作れず刺さりますexceeded quota: fanclub-quota, requested: limits.cpu=1500m, used: limits.cpu=2300m, limited: limits.cpu=3)。そこでPod を消して作り直させます——ReplicaSet がすぐに新しい Pod を作るので、これが quota 下での定石です。

実行コマンド(k8s-ops・developer):

$ kubectl delete pod -n fanclub -l app=fanclub-backend

実行結果:

pod "fanclub-backend-6896d48bd9-lxxvj" deleted from fanclub namespace

新しい backend が起動して DB に繋ぎ直したら、ブラウザで https://fanclub.local を開きます。移行前と同じ会員一覧(印の「移行テスト」を含む 3 名)が表示され、追加・削除(CRUD)も動くことを確認してください。第11回で通した経路(HAProxy → Traefik → Gateway → frontend → backend → db)はそのまま生きています。

最後に移行の成果を確認します。local-path のときは db-0 が wl-02 に釘付けでしたが、いまは違います。

実行コマンド(k8s-ops・developer):

$ kubectl get pv -o custom-columns=NAME:.metadata.name,SC:.spec.storageClassName,POLICY:.spec.persistentVolumeReclaimPolicy,NODE-AFFINITY:.spec.nodeAffinity

実行結果(ノード固定が消えている):

NAME                                       SC         POLICY   NODE-AFFINITY
pvc-97c4cbed-9efb-418b-af9c-55f5d6fabbcb   longhorn   Delete   <none>

nodeAffinity が消えました。local-path の PV は「この PV は(実体を置いた)そのノードでしか使えない」というノード固定を持っていましたが、Longhorn の PV にはそれがありません。db-0 はどの Workload Node でも起動でき、データはレプリカ経由で付いてくる——これが本回で得たものです。

最後に後始末です。判定のために入れた「移行テスト」は、移行が確認できたら消しておきます(fanclub の会員一覧に検証用のデータを残さないため)。Longhorn・StorageClass・chart の変更はそのまま残します——第13回以降もこの構成で進みます。

実行コマンド(k8s-ops・developer):

$ kubectl exec fanclub-db-0 -n fanclub -- psql -U appuser -d fanclubdb -c "DELETE FROM members WHERE email='migrate-test@example.com';"
$ kubectl exec fanclub-db-0 -n fanclub -- psql -U appuser -d fanclubdb -c "SELECT count(*) FROM members;"

実行結果(seed の 2 名に戻る):

DELETE 1
 count
-------
     2
(1 row)
fanclub-db を local-path から Longhorn へ移行する手順の図。移行前に印のデータを足して pg_dump --clean で退避し、StatefulSet を --cascade=orphan で削除(Pod は残り、PVC は既定で保持される)、旧 PVC を削除すると Reclaim Policy Delete により実体が消えること、chart に storageClassName を追加して helm upgrade で新しい PVC を longhorn で作り直すと Helm フックが seed だけ入れ直すため件数では成否を判定できないこと、psql で復元して印のデータが戻れば成功、という流れと退避を飛ばすとデータを失う危険箇所を示した図
図2:fanclub-db を local-path から Longhorn へ移行する手順

やってみよう

本文で fanclub-db の移行まで済ませているので、演習は移行結果の確認と、使い捨てリソースでの掘り下げです。本回で入れた Longhorn は第13回以降も使い続けるので、後始末で削除しないでください。

移行後のストレージ構成を読む

kubectl get sc,pv,pvc -Akubectl get pod fanclub-db-0 -n fanclub -o wide を実行し、①既定 SC が longhorn になっている、②data-fanclub-db-0longhorn で Bound、③db-0 がどの Workload Node に居るか、を読み取ってください。

使い捨ての PVC でレプリカ分散を確認する

default 名前空間に 1Gi・RWO の PVC(storageClassName: longhorn)と、それをマウントする busybox Pod を作り、ファイルを 1 つ書いてください(YAML は本文と同じ ~/storagedeveloper で作れば十分です)。kubectl get replicas.longhorn.io -n longhorn-system -o custom-columns=NAME:.metadata.name,NODE:.spec.nodeID,STATE:.status.currentStateレプリカが wl-01 と wl-02 に 1 本ずつできていることを確認できます。この一覧には fanclub-db のレプリカも並ぶので、kubectl get pvc で自分の PVC の VOLUME 名(pvc-…)を調べ、その名前で始まる行だけを見てください。確認できたら Pod と PVC を削除して後始末します(fanclub には触れません)。

Reclaim Policy の違いを読む

kubectl get sc の RECLAIMPOLICY 列を見て、local-pathlonghorn の設定を確認してください。そのうえで、本回の移行でなぜ「PVC を削除する前に pg_dump が必須」だったのかを、Reclaim Policy の値を根拠に説明してみてください。DB の PV に Retain を選ぶ運用があるのはなぜか、も考えてみると理解が深まります。

まとめ

本回では fanclub-db を local-path から Longhorn へ移行しました。要点は、①PVC(要求)/PV(実体)/StorageClass(実体を作る係)の役割分離と動的プロビジョニング、②アクセスモード(DB は RWO)とReclaim PolicyDelete は PVC 削除で実体も消える・DB では Retain を選ぶ運用がある)、③local-path は WaitForFirstConsumer で PV がノードに固定され、そのノードが落ちるとデータに届かない——これが移行の動機、④Longhorn は CSI ドライバ(CRI・CNI に続く 3 つ目の拡張インターフェース)で、レプリカを複数ノードに分散し Immediate でノードに縛らない、⑤StatefulSet の volumeClaimTemplates は不変なので、移行は「pg_dump(--clean)で退避 → --cascade=orphan → 旧 PVC 削除 → chart 拡張 → helm upgrade → psql で復元」の順で行い、退避を飛ばせばデータは戻らない、⑥移行の成否は「seed に無い印のデータが戻ったか」で判定する——chart のフックが seed を入れ直すため、件数だけ見ていると「復元できていないのに成功したように見える」、⑦DB を作り直したら、それに繋いでいたアプリ(backend)も作り直す——接続プールは自力で回復せず、しかも quota 下では rollout restart が刺さるので delete pod で入れ替える(第7・8回の統制が効いてくる場面)、でした。第4部「ストレージ」はこれで完結です。

理解度チェック(○×形式・全 9 問)

次の各文が正しいか(○)誤りか(×)を判断してください。下の「解答と解説」を開くと答え合わせができます。

  1. 動的プロビジョニングでは、PVC を作ると StorageClass が PV を自動で作る。
  2. StatefulSet の volumeClaimTemplates は、後から storageClassName を変更して apply できる。
  3. PostgreSQL のような DB では、アクセスモードに RWX が必須である。
  4. Reclaim Policy が Delete の場合、PVC を削除すると PV と実体も消える。
  5. kubectl delete statefulset --cascade=orphan を使うと Pod が残る。一方 PVC は --cascade の指定に関係なく残る。
  6. 既定の StorageClass を付け替えると、既に Bound している PVC も新しい SC に移る。
  7. StorageClass の既定(default)が 2 つある状態になると、PVC の作成はエラーになる。
  8. Longhorn は CSI ドライバとして Kubernetes にストレージを提供する。
  9. VOLUMEBINDINGMODEWaitForFirstConsumer の local-path では、PV がノードに縛られる。
解答と解説

1=○(PVC が要求・SC が実体を作る)/2=×(不変フィールド。作り直しが必要)/3=×(RWO。RWX は複数ノードから同時に書く用途)/4=○(本回で旧 PVC 削除前に pg_dump した理由)/5=○(消えるのは StatefulSet の定義だけ。Pod は --cascade=orphan が、PVC は「StatefulSet を消しても PVC は保持する」という Kubernetes の既定が守る)/6=×(既定 SC はこれから作る PVC にしか効かない=だから移行が要る)/7=×(エラーにはならず最後に作られた既定が使われる。だからこそ意図しない SC が選ばれる事故になりやすく、既定は 1 つに揃える)/8=○(CRI・CNI に続く 3 つ目の拡張インターフェース)/9=○(Pod の配置後に PV を作るため、その PV はノードに固定される)

次回予告

次回・第13回からは第5部「監視・運用」です。ここまでで「動くクラスタ」は完成しましたが、本番では動いていることを確かめ続ける仕組みが要ります。Prometheus + Grafana でメトリクスを、Loki + Fluent Bit でログを集約し、fanclub の状態をダッシュボードで見えるようにします。

前の記事
次の記事