ステータス: 実測(2026-10-31)。 TT Lab 実習用 VM(8 vCPU・8GiB 3台と取り込みサーバー1台)内に k0s v1.37.1+k0s.0 を構築して測定した数値です。同じ正解手順を修正しながら最初から4回構築し、1回目は途中で手順が途切れて段階が混ざったため未実測としました。数値のばらつきの幅がこの環境の実態の幅となります。VM のディスクは HDD 上にありました。ノードのアドレスは例示用アドレス(192.0.2.0/24)に変更して記載しています。記述ルール: 実測データを基本とし、測定できなかったものは「未実測」、原因を確認できなかった解釈は「推定」と表示します。
目次
- 1. 結論から
- 2. 3台をどのように分けるか
- 3. イメージを社内レジストリにミラーリングする
- 4. インストール: k0sctl はコントローラー兼ワーカーの Ready を待機しない
- 5. コントローラーを1台、2台と停止した場合
- 6. Rook-Ceph
- 7. CloudNativePG
- 8. 時間のかかる段階を実行する方法
- 9. リソース
- 10. 制限事項と未実測
- 11. まとめ
1. 結論から
- 3台ともコントローラー兼ワーカーとして構成しました。 etcd の3メンバー構成は1台の障害に耐えられます。API アドレスは外部ロードバランサーを使わずに k0s に内蔵されている CPLB(keepalived 仮想 IP)と NLLB(ノードごとに envoy)で束ねました。
- 仮想 IP(VIP)を持つノードを停止すると、4回すべて0〜3秒以内に API が復旧しました。 書き込みを測定した2回はそれぞれ1秒と3秒でした。2台を停止すると30秒間書き込みがすべて失敗し、再起動すると1〜2秒で復旧しました。
- イメージ18個(1.8GB)を社内レジストリにミラーリングし、containerd の
hosts.tomlで運用しました。 チャートやマニフェストのイメージ名は一切修正しませんでした。 - Rook-Ceph はチャートのインストールから HEALTH_OK になるまで437〜605秒(7〜10分)かかりました。k0s の kubelet パスを1箇所合わせるだけで対応できました。
- CloudNativePG はプライマリ Pod を削除してから35.4〜62.6秒で新しいプライマリが書き込みを受け付け、行数をカウントした2回はいずれも一致していました。 ただし削除された旧プライマリは4回中3回で5分以上 Terminating 状態にとどまりました。Ceph のオブジェクトストレージへのバックアップ(4〜53秒)後、新しいクラスターへの復元(1分54秒〜4分6秒)を行い、バックアップ以降に追加された行まで WAL によって復元されました。
- 4つの罠: k0sctl はコントローラー兼ワーカーの Ready を待機しません。PostgreSQL 17 から
pg_walfile_nameの境界ルールが変更されたため、WAL の保管を待つスクリプトが終了しませんでした。バケット名が生成される前にバックアップ設定を作成すると WAL アーカイビングが破損します。systemctl is-activeは実行中の oneshot ユニットに対して「いいえ」と答えます。
2. 3台をどのように分けるか
ノード3台の構成には2つの方法があります。
| (a) コントローラー兼ワーカー3台(今回のシナリオ) | (b) コントローラー1台 + ワーカー2台 | |
|---|---|---|
| etcd | 3メンバー、1台の障害に耐える | 1メンバー、コントロールプレーンが単一障害点となる |
| ワークロードを配置するノード | 3台 | 2台 |
| Ceph(mon 3、3重複製)、PostgreSQL 3台 | ノードごとに1つずつ、1台の障害に耐える | ストレージノードが2台しかないため3重複製ができない |
| API アドレス | CPLB VIP + NLLB、外部ロードバランサー不要 | コントローラーのアドレス1つ |
| 代償 | コントロールプレーンとワークロードでメモリを共有する。ノード8GiB、Ceph デーモンのメモリを削減 | シンプルである |
| 検証 | この記事全体 | 第2部 第5節で実際に構築 |
(b) の方法では、この記事の目的であるストレージの高可用性を満たせません。Ceph のデフォルトのブロックプールは異なるホストに3つのレプリカを配置し(failureDomain: host)、mon は過半数を維持するために3つをそれぞれ異なるノードに配置します。ストレージノードが2台しかない場合、3つのレプリカを配置する場所がなくなりデータが undersized+degraded の状態のままになります。レプリカ数を2に減らすと、1台の障害時に書き込みが停止するか(min_size 2)、1つのレプリカだけで耐えることになります(min_size 1)。同じ理由により、2台以下の環境には Ceph と PostgreSQL 3台構成をデプロイしませんでした。Ceph の配置原理は
Rook-Ceph 解説 シリーズに詳しく記載されています。
2.1 API アドレスを1つにまとめる3つの方法
コントローラーが3台ある場合、kubectl やノードがどのアドレスを使って API にアクセスすべきかを決定する必要があります。
- CPLB(control plane load balancing)。k0s が keepalived を起動して VRRP により仮想 IP をいずれかのノードに割り当て、そのノードが停止した場合は別のノードに引き継ぎます。 CPLB のドキュメントでは、これを「クラスター外からのアクセス」用であると説明しています。外部からの取り込み項目が増えない一方で、ノードが同一の L2 ネットワーク上に存在する必要があります(ドキュメント: virtual router ID はブロードキャストドメイン内で一意である必要があります)。コントローラー兼ワーカー構成では、k0s のユーザー空間リバースプロキシ方式のみがサポートされます。
- NLLB(node-local load balancing)。ワーカーごとにループバック(127.0.0.1:7443)で envoy を起動し、kubelet などのノードコンポーネントが稼働中の apiserver 3台に均等に接続できるようにします。 NLLB のドキュメントでは、NLLB はクラスター内部の耐障害性を目的としており、外部からのアクセスはサポートしないと記載されています。
- 外部ロードバランサー(HAProxy など)。 HA のドキュメントの例では、6443(API)・8132(konnectivity)・9443(JOIN API)をまとめます。そのロードバランサー自体を二重化する必要があり、導入すべきパッケージが増加します。今回は測定を行いませんでした(未実測)。
CPLB のドキュメントでは、外部ロードバランサーがない場合は CPLB と NLLB を併用することを推奨しています。今回の構成がまさにそれにあたります。
2.2 k0sctl の設定
ホストを1台のみ記述します。残りの2台はアドレスのみが異なります。
- role: controller+worker
noTaints: true # 컨트롤러에도 워크로드를 놓는다
ssh: {address: 192.0.2.3, user: root, keyPath: /root/.ssh/id_ed25519}
hostname: ctrl01
privateInterface: eth1
privateAddress: 192.0.2.3
uploadBinary: true
k0sBinaryPath: /opt/airgap/bin/k0s-v1.37.1+k0s.0-amd64
installFlags: [--kubelet-extra-args=--node-ip=192.0.2.3]
files:
- {name: k0s-airgap-bundle, src: /opt/airgap/images/k0s-airgap-bundle-v1.37.1+k0s.0-linux-amd64.tar, dstDir: /var/lib/k0s/images/, perm: "0644"}
- {name: apps-bundle, src: /opt/airgap/images/apps-bundle.tar, dstDir: /var/lib/k0s/images/, perm: "0644"}
- name: cri-registry
dst: /etc/k0s/containerd.d/cri-registry.toml
data: |
version = 3
[plugins."io.containerd.cri.v1.images".registry]
config_path = "/etc/k0s/containerd.d/certs.d"
- name: mirror-quay.io # docker.io·ghcr.io·registry.k8s.io·gcr.io 도 같은 모양
dst: /etc/k0s/containerd.d/certs.d/quay.io/hosts.toml
data: |
server = "https://quay.io"
[host."http://192.0.2.2:5000/v2/quay.io"]
capabilities = ["pull", "resolve"]
override_path = true
k0s:
version: v1.37.1+k0s.0
config:
spec:
api: {onlyBindToAddress: true, sans: [192.0.2.100]}
network:
provider: kuberouter
podCIDR: 172.20.0.0/16
serviceCIDR: 172.21.0.0/16
controlPlaneLoadBalancing:
enabled: true
type: Keepalived
keepalived:
vrrpInstances:
- {virtualIPs: ["192.0.2.100/24"], authPass: <클러스터마다 다른 값>, interface: eth1}
nodeLocalLoadBalancing: {enabled: true, type: EnvoyProxy}
images: {default_pull_policy: Never}
telemetry: {enabled: false}
ここでも NIC が2つあるという事実が3箇所に関わってきます。
onlyBindToAddress: trueは第2部第3.1節の理由により追加しました。コントローラーが3台の場合、ホストごとにaddressを記述する場所がないため、これが唯一の方法となります。- VRRP の
interface: eth1も記述する必要があります。CPLB のドキュメントによると、空欄にした場合 k0s は デフォルトルートを持つ NIC を選択します。この VM ではそれが NAT NIC であり、そこに VIP を割り当てても他のノードからアクセスできません。 sansに VIP を設定します。設定しない場合、VIP 経由で接続する際に API サーバーの証明書にそのアドレスが含まれていないため TLS が失敗します。
authPass はドキュメントにもある通り、暗号化のためではなく、同じネットワーク上の他のクラスターと誤って混ざるのを防ぐための値です。同一ネットワーク内の他のクラスターと重複しないように決定します。インストール後には
k0sctl kubeconfig が出力した kubeconfig のサーバーアドレスを VIP に変更しました。単一のコントローラーを指定した場合、そのノードが停止すると kubectl も一緒に動作しなくなります。
3. イメージを社内レジストリにミラーリングする
k0s のシステムイメージは第1部と同様にアーカイブとして一括導入しました。しかし Rook-Ceph、CSI ドライバー、CloudNativePG、cert-manager のイメージは数が多く、サイズも大きいです。ノードごとにアーカイブファイルを転送する代わりに、取り込みサーバーにレジストリを1つ立ててミラーリングしました。
| イメージアーカイブ | 数 | 例 |
|---|---|---|
| Rook・Ceph | 2 | rook/ceph:v1.21.1(565MB)、ceph/ceph:v20.2.4(543MB) |
| Ceph CSI | 7 | ceph-csi-operator v1.1.0、cephcsi v3.18.1、sig-storage サイドカー5個 |
| CloudNativePG | 4 | オペレーター 1.30.1、postgresql 18.6-minimal-trixie、barman-cloud プラグインおよびサイドカー v0.15.1 |
| cert-manager | 3 | v1.21.3 の controller・webhook・cainjector |
| アプリ | 2 | nginx 1.30.6-alpine、busybox 1.36.1-1 |
取り込みサーバーが skopeo copy を 192.0.2.2:5000/quay.io/ceph/ceph:v20.2.4 のように 元のレジストリ名をパスの先頭に付与して 格納しました。ノード側の containerd は、レジストリごとに hosts.toml を1つ指定してこのパスを指し示します。containerd の hosts.mdによると、
override_path = true は「API のルートが URL パスに記述されている」という意味になるため、containerd は /v2/ を再度付与せず http://192.0.2.2:5000/v2/quay.io/ceph/ceph/manifests/...
のようにリクエストします。そのため、 チャートやマニフェストのイメージ名は一切修正しませんでした。 閉域網においてバリューファイルのイメージ名を1つずつ書き換えると必ず1つ見落としが発生し、その1つが数分後に ImagePullBackOff として表面化します。
ドロップインファイルの形式は k0s ランタイムドキュメントに従いました。このバージョンの k0s は version = 3 と
io.containerd.cri.v1.images のプラグインパスを使用する必要があり、 version = 2 や古いプラグインパスを使用すると事前チェックで停止するとドキュメントに記載されています。
ミラーリングするリストは Rook リポジトリの deploy/examples/images.txtを基準にし、使用しない COSI、csi-addons サイドカー、snapshot-metadata は除外しました。除外した項目に不足があれば ImagePullBackOff としてすぐに判明するため、リストを削る際に恐れる必要はありません。
| 項目 | 実測 |
|---|---|
| 持ち込み(ツール 27秒 → バイナリ・バンドル → helm・チャート・マニフェスト 57秒 → イメージ 18個ミラー → 封印) | 182秒、レジストリ 1.8GB |
| rook-ceph のコンテナ 46個 | イメージがすべてミラーリスト内 |
| レジストリのアクセス記録 | ノードごとに 96〜104件(ノードセットすべてミラーから取得) |
制限事項も記載しておきます。テストのレジストリは社内網内の HTTP でした。現場では TLS を使用し、 hosts.toml に社内 CA(ca = ...)を指定します(未計測)。k0s のシステムイメージまでレジストリから取得する方式(spec.images.repository)も計測していません(未計測)。システムイメージをバンドルに入れた理由は、持ち込みサーバーのレジストリが停止しても、ノードの再起動時に CNI・kube-pressY・coredns が起動するようにするためです。
4. インストール:k0sctl はコントローラー兼ワーカーの Ready を待機しない
| 回数 | k0sctl apply(k0sctl のレポート) |
3つすべて Ready(apply 開始から) |
|---|---|---|
| 1 | 89秒 | 未計測(正解データが途切れ、ステージが混在) |
| 2 | 93秒 | ノード登録が apply 終了後 +5秒・+104秒・+125秒 |
| 3 | 72秒 | 217秒 |
| 4 | 109秒 | 324秒 |
apply が完了したからといって、クラスターの構築が完全に終わったわけではありませんでした。第 2 回目では apply は 93秒で成功に終わりましたが、2台のノードはその後の 104秒と 125秒を経過してようやく kubelet が「Successfully registered node」のログを残しました。
k0sctl のソースコードを確認すると、その理由は明快でした。 install_workers.goの「waiting for node to become ready」は Workers() でのみ実行され、この関数は役割が正確に worker であるホストのみを選択します。 controller+worker はコントローラーのインストール段階で終了するため、kubelet が Ready になるのを待機しません。そのため、apply に続く自動化処理では、ノードセットが Ready であるかを自ら確認する必要があります。そのまま次の段階に進むと、ノードが1台欠けた状態で Ceph がインストールされることになります。
5. コントローラーを1台、2台と停止した場合
5.1 計測方法
持ち込みサーバーから VIP に対して次の2つの項目を 1秒ごとに計測しました。
- API:
kubectl get --raw /readyz(タイムアウト 4秒)。 - 書き込み:ConfigMap の作成(タイムアウト 6秒)。毎回新しい名前を生成します。同じ名前を再度作成すると
AlreadyExistsエラーとなり、「過半数を失ったことによる失敗」と区別がつかなくなります。最初の試行では実際にこのような偽りの失敗が記録されました。
まず VIP を保持するノード(社内網用 NIC に VIP がバインドされているノード)を特定して k0scontroller を停止し、停止した時点から API と書き込みが復旧するまでを計測しました。続いて 2台目のコントローラーも停止して 30秒間書き込みが一度でも可能かを確認し、両方を再起動して書き込みが復旧するまでを計測しました。
5.2 結果
| 回数 | VIP の移動 | API の復旧 | 書き込みの復旧 | 2台停止(1/3) | 再起動後の書き込み |
|---|---|---|---|---|---|
| 1 | ノード 3 → ノード 2 | 0秒 | 1秒 | 30秒間すべて失敗 | 1秒 |
| 2 | ノード 1 → ノード 3 | 3秒 | 3秒 | 30秒間すべて失敗 | 1秒 |
| 3 | ノード 1 → ノード 3 | 0秒 | 記録なし | 30秒間すべて失敗 | 2秒 |
| 4 | ノード 1 → ノード 3 | 1秒 | 記録なし | 30秒間すべて失敗 | 1秒 |
1台を停止した際、稼働中のノードの k0s etcd member-list にはメンバーセットがそのまま残っていました。停止したノードはメンバーから除外されたわけではないため、過半数は依然として 2であり、稼働中の 2台がその過半数を満たして書き込みを受け付けました。2台を停止すると残りの 1台では過半数を満たせないため、書き込みが停止します。第 2部の etcd の表の通りです。
1点注意すべきことがあります。kubelet を停止しても Node オブジェクトの Ready はしばらく古い値のまま残ります。コントローラーマネージャーがノードを「応答なし」に変更するまで待機する時間(--node-monitor-grace-period, ドキュメント上はデフォルト 50秒)が存在するためです。「障害中もノードが Ready であった」ことは高可用性の証明にはなりません。そのため、上記の表は Node の状態ではなく実際の要求によって計測しました。
6. Rook-Ceph
6.1 k0s だからこそ異なる点:kubelet のパス
k0s の kubelet は作業ディレクトリとして /var/lib/kubelet ではなく /var/lib/k0s/kubelet を使用します(k0s ストレージドキュメント)。CSI ノードプラグインは kubelet のディレクトリを hostPath としてマウントしてボリュームをアタッチするため、このパスが異なると PVC はバインドされてもポッドにアタッチされません。
Rook v1.21 では CSI ドライバーが ceph-csi-operator と別のチャート(ceph-csi-drivers)に分割されました。rook-ceph チャートの値では kubelet のパスが見つからなかったため、ドライバーチャートの値に指定しました。
# ceph-csi-drivers 1.1.0 의 values(Rook 이 권하는 값에 이 줄만 더함)
operatorConfig:
driverSpecDefaults:
nodePlugin:
kubeletDirPath: /var/lib/k0s/kubelet
もう一つの方法は k0sctl ホストの kubeletRootDir で kubelet 側を /var/lib/kubelet に変更することですが、計測はしていません(未計測)。ドライバーのデーモンセットは operator が Driver オブジェクトを参照して作成するため、チャートインストール直後(23秒)にはまだ存在していませんでした。インストール直後にデーモンセットを確認する自動化処理では、作成されるまで待機する必要があります。
6.2 実測
チャートセット(rook-ceph、ceph-csi-drivers、rook-ceph-cluster v1.21.1、Ceph v20.2.4)を helm で持ち込んだ tgz からインストールしました。ノードごとに空きディスク 17GiB を 1つ OSD として使用し、ノードがワークロードも兼ねるため OSD の osd_memory_target を 1GiB に引き下げました。
| 項目 | 値 |
|---|---|
| チャートインストール → HEALTH_OK(OSD 3 up・in、mon 3) | 546秒・605秒・437秒(第 2・3・4回) |
| 生容量 / 3レプリカブロックプールの利用可能容量 | 51GiB / 15GiB |
| プールと PG | プール 10個(ブロック 1、RGW オブジェクトストレージ 8、.mgr)、PG 121個 active+clean |
HEALTH_OK は Ceph v20 が出力する cephx キー形式の警告群(AUTH_INSECURE_*)の原因を確認し、ミュート処理した後の値です。最後まですべてのステージを実行したあとには、
BLUESTORE_SLOW_OP_ALERT(遅いディスクの警告)によって HEALTH_WARN になりました。HDD 上の VM に PostgreSQL の負荷をかけた結果です。現場のハードウェアではディスクから見直す必要がある値です。
7. CloudNativePG
7.1 インストール順序とバックアップ方式
cert-manager → CNPG オペレーター(1.30.1) → barman-cloud プラグイン(v0.15.1)の順序で持ち込んだマニフェストを適用しました。CNPG のマニフェストは
インストールドキュメントに従って kubectl apply --server-side で適用します。
バックアップにプラグインを採用した理由は、バージョンの流れのためです。CNPG 1.30 の バックアップドキュメントの原文には、本体に含まれる barmanObjectStore 方式は 1.26 からサポートが終了しており、1.31 がそれをサポートする最後のバージョンであり、1.32 で削除されると記載されています。新しく構築するクラスターを、間もなく廃止される方式で始める理由はありません。プラグインには cert-manager が必要です(プラグインインストールドキュメント)。バックアップツールがプラグインのサイドカーに含まれているため、PostgreSQL のイメージは軽量な 18.6-minimal-trixie で十分でした。
バックアップの保存先は同じクラスター内の Ceph オブジェクトストレージ(RGW)です。ObjectBucketClaim でバケットを作成すると、Rook が同名の ConfigMap(バケット名・アドレス)と Secret(キー)を生成します。ただし、 同じクラスター内の RGW は別の障害ドメインではありません。 クラスター全体を失った場合はバックアップも同時に失われるため、運用においては外部ストレージへもう一度転送する必要があります。
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata: {name: pg, namespace: db}
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:18.6-minimal-trixie
storage: {size: 2Gi, storageClass: ceph-block}
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters: {barmanObjectName: ceph-rgw}
インスタンスセットがすべて Ready になるまでに第 2回目では 約 13分 40秒がかかりました(initdb 70秒、残りはレプリカのイメージ取得と合流)。HDD 上の VM という条件が大きく影響している値のため、そのまま一般化することはできませんが、自動化の待機上限を設定する際は「数分」ではなく「十数分」を見込む必要があるということは明確です。
7.2 罠:バケット名が生成される前に ObjectStore を作成した場合
最初の試行では ObjectBucketClaim が 2分以内に Bound にならなかったものの、スクリプトは待機終了後にそのまま次へ進んでしまいました。そのため、バケット名が空のまま ObjectStore を作成し、 destinationPath が s3:/// になりました。WAL のアーカイビングが Invalid s3 URL address でエラーし続けました。
この状態で primary ポッドを削除したところ、事態が深刻化しました。新しい primary は 39.8秒で起動したものの、古い primary が保持しきれなかった WAL を抱えたまま 18分以上 Terminating の状態にとどまりました。CNPG ポッドの terminationGracePeriodSeconds が 1800秒であるため、Kubernetes がその時間だけ待機したためです。現在は ConfigMap の BUCKET_NAME が生成され、OBC が Bound になるまで待機し、最終的に空であれば停止するように修正しています。
7.3 primary を削除した場合
インスタンスセットがすべて Ready のときに primary ポッドを削除し、0.5秒ごとに新しい primary に select pg_is_in_recovery() を問い合わせ、 f(書き込み可能)が返されるまでを計測しました。
すべてが Ready のときのみ実行することが重要です。 途切れた正解データの後にレプリカが1つも起動していない状態で primary を削除し、クラスターを喪失した回数がありました。
| 回数 | 新しい primary への書き込みまで | 行 | 古い primary |
|---|---|---|---|
| 2 a(アーカイビングが破損した状態、7.2) | 39.8秒 | 記録なし | 18分以上 Terminating |
| 2 b | 49.5秒 | 1001行を維持 | レプリカとして復帰 |
| 3 | 62.6秒 | 記録なし | 1418秒(23分)以上 Terminating |
| 4 | 35.4秒 | 1001行を維持 | 320秒以上 Terminating(5分までのみ計測) |
新しい primary の昇格と書き込みに問題はありませんでした。問題は古い primary です。アーカイビングが正常であった 3回のうち 2回も 5分以上 Terminating であり、そのポッドのログは次のように終了していました。
"msg":"unable to run controller-runtime manager",
"error":"failed waiting for all runnables to end within grace period of 30s: context deadline exceeded"
PostgreSQL はすでに server stopped で正常終了したものの、CNPG の instance manager が 30秒以内に内部処理を完了できなかったというエラーを残したままプロセスが終了しませんでした。子プロセスの postgres はゾンビ(<defunct>)として残り、ポッドは猶予期間(grace period)の 1800秒を使い切る挙動を示しました。原因は特定できていません。barman-cloud サイドカーを有効にすると primary ポッドの終了に約 3分かかるという問題のissue plugin-barman-cloud#1039(オープン)と同系統に見えますが、今回の観測はそれよりもはるかに長時間に及びます(推測)。
なぜ気にする必要があるのか。古い primary が Terminating の間、クラスターは 2つの Ready インスタンスで耐え忍びます。その間にさらにノードが 1台停止すると、レプリカが 1つだけの状態になります。そのポッドが配置されているノードを drain しようとしても完了しません。フェイルオーバー後には、古い primary が実際にレプリカとして復帰したかどうかも確認する必要があります。この現象を軽減する設定(grace period の調整など)は検証していません(未計測)。
7.4 バックアップとリストア、そして WAL を待機する方法
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata: {name: pg-b1, namespace: db}
spec:
cluster: {name: pg}
method: plugin
pluginConfiguration: {name: barman-cloud.cloudnative-pg.io}
バックアップが完了したら行を1行(1002)追加し、WALファイルを切り替えて、そのWALがバケットにアップロードされたことを確認し、 bootstrap.recovery で新しいクラスター pg-restore を作成しました。
バックアップ時点(1001行目)の次に追加した1002行目まで復元できれば、WALの復元まで完了していることになります。
| 回数 | バックアップ | 復元クラスター作成 → Ready | 復元元の count, max(id) |
|---|---|---|---|
| 2(初回) | 53秒 | 4分6秒 | 1001、1001(WALを15秒間のみ待機) |
| 2(再挑戦) | 4秒 | 3分15秒 | 1002, 1002 |
| 3 | 記録なし | 2分0秒 | 1002, 1002 |
| 4 | 37秒 | 1分54秒 | 1002, 1002 |
初回はWALを切り替えて15秒しか待機しなかったため、復元元はバックアップ時点(1001)で停止しました。復元はその時点までにバケットにアップロードされたWALまでしか読み込まないためです。そこで「切り替えたWALが保管されるまで待機する」処理を追加したのですが、今度はその待機が300秒間終わらなくなりました。
最初に書いたコードは pg_walfile_name(pg_switch_wal()) で待機するファイル名を取得していました。 PostgreSQLのドキュメントによると、 pg_switch_wal() は「今終了したWALファイルの終了位置 + 1」、すなわち 次のファイルが始まる境界を返します。境界位置を pg_walfile_name に渡すとどのような名前になるかはバージョンによって異なります。
- PostgreSQL 16のドキュメントまでは、「位置が正確に境界である場合、 前 のファイル名を返す。アーカイビングを扱う場合、通常これが望ましい動作」と記載されていました。そのため、過去のブログ記事にあるこのイディオムは正しかったのです。
- PostgreSQL 17リリースノートの「17への移行」セクションには、これら2つの関数が境界において前のファイルではなく 現在のファイルを返すように変更されたと記載されています。今回のテストで使用したPostgreSQLのバージョンは18.6でした。
したがって、17以上では pg_walfile_name(pg_switch_wal()) はまだ書き込みすら始まっていない次のファイルの名前であり、そのファイルは次の切り替えが発生するまでアーカイブされません。これが待機が終わらなかった理由です。修正方法は順序を入れ替えることです。
-- 1002 를 넣은 뒤, 넘기기 「전에」 지금 파일의 이름을 적는다
select pg_walfile_name(pg_current_wal_lsn()); -- 예: ...0B
select pg_switch_wal();
-- 그다음 last_archived_wal 이 적어 둔 이름 이상이 될 때까지 기다린다
select last_archived_wal from pg_stat_archiver;
このように修正した後、3回とも1002行目まで復元されました。復元が完了するとインスタンスが一度再起動する点も記憶しておく価値があります(「Primary instance is being restarted without a switchover」)。Readyを一度見ただけで終了すると、すぐ後ろのReadyが空になっている瞬間に出くわします。「healthy」を10秒間隔で2回確認して終了としました。
8. 時間のかかるステップを実行する方法
apply、Ceph、CNPG、障害テスト、バックアップと復元には数分から十数分かかります。ターミナルに張りついて待つことのないよう、 systemd-run --property Type=oneshot でバックグラウンド実行し、完了したかどうかを個別に問い合わせました。ここで1つの落とし穴と2つの小さな驚きに直面しました。
systemctl is-activeは実行中のoneshotユニットに対して「いいえ」と答えます。 systemd.serviceのドキュメントによると、Type=oneshotユニットはプログラムが終了するまで「activating」状態になります。is-activeは「active」のときのみ真を返すため、「まだ実行中」であるという判定が機能せず、待機すべき箇所で失敗してしまいました。systemctl show -p ActiveState --value <유닛>がactivatingであるかを確認します。- helm v4.3.0 에서는
helm list -aの-aがないため、unknown shorthand flag: 'a'で終了します。 - と同様のバージョンの
helm status -o jsonは、リリースごとに19秒かかりました。ステータス確認にはhelm list -o jsonを1回実行する方が確実です。
9. リソース
すべてをデプロイした後の3台のノード(8 vCPU・8GiB)の状況です。
| 項目 | 値 |
|---|---|
| メモリ使用量 | 2.2〜2.6 / 7.9GiB |
/var/lib/k0s |
6.4〜6.9GiB(作業ディスク) |
| ルートディスク(2.4GiB) | 74~94% |
/var/lib/k0s とポッドログは作業ディスクに移動させましたが、journaldとk0sのログはルートに蓄積されました。ルートディスクが小さいマシンの場合、journaldの上限をあらかじめ設定する必要があります。上限を設定して長期間稼働させたテストは行っていません(未計測)。
10. 制限事項と未計測項目
- 環境:KubeVirt VM上のk0sであるため、絶対時間は物理サーバーより遅い可能性があり、ディスクはHDDでした。特にCephとCNPGの処理時間はディスク性能に大きく左右されます。
- 実行回数:3台構成シナリオを4回、フェイルオーバーを4回実施しました。統計的分布ではなく事例としてお読みください。
- 計測しなかった項目:外部HAProxyによるAPIの負荷分散構成、
spec.images.repositoryによるシステムイメージの社内レジストリからの取得、k0sctl apply --restore-fromの復元、k0sのバージョンアップ、ノード再起動後の動作、TLSレジストリ、kubeletRootDirによるkubeletパスの変更、旧プライマリのTerminatingを短縮する設定、journaldの上限を設定した長時間の運用。
11. まとめ
| チェック項目 | 理由(本記事の実測値) |
|---|---|
| 3台構成なら3台すべてコントロールプレーン兼ワーカー | etcdを3メンバーにして1台の障害に耐え、CephおよびPostgreSQLを各ノードに1つずつ配置 |
VRRPの interface と sans を記述する |
空欄にするとデフォルトの経路NICにVIPが立ち、sansがないとTLSに失敗する |
| kubeconfigはVIPを指定 | VIP保持ノードを停止させてもAPIは0〜3秒、書き込みは1〜3秒で応答 |
| apply後にノードのReadyを直接確認 | k0sctlはコントロールプレーン兼ワーカーの準備完了を待たない(+104秒・+125秒) |
イメージ名はそのまま、 hosts.toml でミラーリング |
18個、1.8GBをコードの変更なしで取得 |
| Ceph CSIのkubeletパス | k0sでは /var/lib/k0s/kubelet |
| バックアップはbarman-cloudプラグインを使用 | ネイティブ方式は1.32で削除される |
| バケット名を確認した後にObjectStoreを作成 | 空の場合、 s3:///、アーカイビングが破損し、旧プライマリが18分以上残存した |
| 切り替え前にWAL名を記録する | PostgreSQL 17以降、 pg_walfile_name(pg_switch_wal()) は次のファイルを指す |
| フェイルオーバー後に旧プライマリの状態まで確認 | 4回中3回で5分以上Terminating状態が継続 |
oneshotユニットは ActiveState で判定 |
is-active は実行中である状態を「いいえ」と返す |
本記事の実測は、TT Labの実習コース「閉域網k0s:1・2・3台のノードとその上のCeph・PostgreSQL」(サイトパス /course/k0s-airgap-multinode)の第3モジュール「3ノードHA」にて、同一のバージョンおよび同一のマニフェスト一覧を用いて実際に追体験していただけます。VIPの移動、Rook-Ceph、CNPGのフェイルオーバーとバックアップ・復元を実際のVM上で実行し、自動採点システムがステップごとに結果を検証します。
ティティの一言。 学級委員を3人選んでおけば、1人が体調を崩しても2人で話し合って決められます。でも2人とも体調を崩してしまうと、1人では何も決められません。それに、お引越しした委員長(旧プライマリ)が荷物をまとめきれずにドアのところで30分立ち尽くしているかもしれないので、新しい委員長が決まったからといってそこで終わりにせず、古い委員長がちゃんと自分の席に戻ったかどうかもぜひ確認してあげてください!