- Published on
CloudNativePG ディープダイブ — Kubernetes の中で PostgreSQL の面倒を誰が見るのか
- Authors

- Name
- Youngju Kim
- @fjvbn20031
PostgreSQL は自分の面倒を見ない
PostgreSQL は優れたデータベースですが、コンテナに入れたからといって自分でレプリカを作り、プライマリが死んだらスタンバイを昇格させ、毎日バックアップを取り、昨日の午後三時に巻き戻す、といったことはしません。それは今まで人がやっていました。
CloudNativePG(CNPG)は、その人の仕事を Kubernetes のコントローラが継続的に調整(reconcile)するもの に変えたオペレータです。この記事は CNPG が何をどうするのかを、実際に本番で動いているクラスタの値で説明します。
オペレータ cloudnative-pg 1.30.0 + plugin-barman-cloud v0.14.0
クラスタ labhub-db-prod — PostgreSQL 18.4、インスタンス 2、ボリューム 5Gi(NFS)
現在の状態 primary = labhub-db-prod-1、standby = labhub-db-prod-2、timeline 3
設計が普通と違う二か所
StatefulSet を使わない
Kubernetes でステートフルなものは StatefulSet で動かすのが常識です。CNPG はそうしません。Pod 一つひとつをオペレータが直接作り、消します。
理由はデータベースのインスタンスが互いに同じではないからです。StatefulSet は「0 番から順に、同じテンプレートで」を前提にします。しかしデータベースでは「2 番のインスタンスだけ新しいボリュームで作り直してレプリケーションを最初から受けさせる」「プライマリは最後に触る」といった判断が必要です。その判断をするには Pod を個別に扱えなければなりません。
Pod の PID 1 は postgres ではない
CNPG の Pod の中で 1 番のプロセスは インスタンスマネージャ です。それが postgres を子として起動し、readiness・liveness プローブに答え、昇格命令を受け、WAL をアーカイブへ送り、設定が変われば読み直します。
この構造の利点は二つです。オペレータが死んでもデータベースは動き続けます(マネージャは Pod の中にいます)。そして「このインスタンスは本当に生きているか」を Kubernetes が TCP ポートではなく PostgreSQL の実際の応答 で判断します。
役割ごとに分解する
クラスタは CR 一つ
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata: { name: labhub-db-prod }
spec:
instances: 2
imageName: ghcr.io/cloudnative-pg/postgresql:18.4-system-trixie
storage: { size: 5Gi, storageClass: nfs-synology }
primaryUpdateStrategy: unsupervised
primaryUpdateMethod: restart
postgresql:
parameters: { wal_level: logical, archive_timeout: 5min }
replicationSlots: { highAvailability: { enabled: true } }
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
この一つのオブジェクトが、以下に出てくる Pod・サービス・Secret・証明書・レプリケーション・バックアップをすべて作ります。設定を変える方法も一つだけです — この CR を直せば、オペレータが差分を計算して必要な分だけ動きます。
レプリケーションと役割
インスタンス一つがプライマリで、残りはストリーミングレプリケーションで追随するスタンバイです。プライマリで見た今の状態です。
application_name | state | sync_state
labhub-db-prod-2 | streaming | async
async は、プライマリがコミットを確定するときにスタンバイの確認を待たない、という意味です。速いのですが、プライマリが死んだ瞬間にまだスタンバイへ届いていないトランザクションは失われます。このクラスタの遅延は実質ゼロですが、それは保証ではなく、今の負荷が小さいからです。
レプリケーションスロット HA が有効なので、スタンバイがしばらく切れてもプライマリはそのスタンバイがまだ受け取っていない WAL を消しません。これがないと、ネットワークが数分切れただけでスタンバイを最初から作り直すことになります。
三つのサービス
アプリは Pod 名で接続しません。オペレータが作った三つのサービスのどれかに接続します。
| サービス | 指す先 | 用途 |
|---|---|---|
labhub-db-prod-rw | 今のプライマリ一つ | 書き込み。アプリはここに接続する |
labhub-db-prod-ro | スタンバイのみ | 読み取りの分散 |
labhub-db-prod-r | 任意のインスタンス | 読み取り(プライマリ含む) |
フェイルオーバーが起きると、オペレータは -rw のエンドポイントだけを変えます。アプリは接続が切れたのを見て繋ぎ直すだけです。アプリの設定にプライマリのアドレスが書かれていないこと、それがデータベースを移し替える自由の出発点です。
フェイルオーバーとスイッチオーバー、そして timeline
プライマリが死ぬと、オペレータは最も進んだスタンバイを選んで昇格させます。このとき PostgreSQL は timeline を一つ上げます — 歴史が分岐した印です。このクラスタは timelineID: 3 です。これまでに二度プライマリが変わった履歴がその数字に残っています。
計画的な交代はスイッチオーバーです。kubectl cnpg promote labhub-db-prod labhub-db-prod-2 でスタンバイを指名すると、オペレータが旧プライマリを片付け、新プライマリを立て、旧プライマリをスタンバイに戻します。データ損失なしに役割だけが変わります。
ローリングアップデート
イメージや再起動が必要な設定が変わると、オペレータはスタンバイから入れ替え、プライマリは最後に触ります。その最後の段階をどうするかは二つの値で決まります。
primaryUpdateStrategy: unsupervised— 人の承認なしに進める(supervisedならプライマリの手前で止まって待つ)。primaryUpdateMethod: restart— プライマリをその場で再起動する(switchoverなら先にスタンバイを昇格させてプライマリの停止時間を短くする)。
マイナーバージョンアップ(18.4 → 18.5)は imageName を変えるだけで、この手順で回ります。
設定は CR に、reload と restart はオペレータが振り分ける
postgresql.parameters に書いた値はオペレータが postgresql.conf に写し、その項目が reload で済むか restart が要るかを判断して適切な方で処理します。このクラスタが触ったのは二つです。
wal_level: logical— 論理レプリケーションと変更データキャプチャ(CDC)ができるように。archive_timeout: 5min— 次節の WAL アーカイブ周期。
バックアップ — 二種類が揃わなければ復旧できない
最も誤解が多い部分です。「バックアップを取った」は半分しか言っていません。
WAL アーカイブ。 PostgreSQL はすべての変更をまず WAL(先行書き込みログ)に書きます。CNPG は WAL セグメントが満杯になるか archive_timeout(5 分)が経つたびに、そのセグメントをオブジェクトストレージへ送ります。つまり 直近 5 分以内の変更はまだアーカイブにないかもしれません — これがこのクラスタの RPO です。
ベースバックアップ。 データディレクトリ全体の複製です。このクラスタでは ScheduledBackup が毎日 03:30 UTC に取り、直近三日分はすべて completed です。
復旧は二つを合わせます。 ベースバックアップを展開し、その後の WAL を望む時刻まで再生します。だから「昨日の午後三時」へ戻れ(PITR)、戻れる最も早い時刻が firstRecoverabilityPoint として状態に記録されます。
このクラスタはバックアップを、CNPG 内蔵の barmanObjectStore ではなく CNPG-I プラグイン(plugin-barman-cloud)で行います。そのため spec.backup は空で、代わりに plugins[].isWALArchiver: true が立っています。バックアップのロジックをオペレータ本体から切り離すのが最近の CNPG の方向です。
そのほかの機能
- 宣言的なロール・DB —
managed.rolesとDatabaseCR でユーザーとデータベースを YAML で管理。 - 証明書 — サーバー・クライアントの TLS 証明書をオペレータが発行・更新。
- Pooler —
PoolerCR 一つで PgBouncer を前に立てる。接続が数百に増えたときに。 - メトリクス —
monitoring.enablePodMonitor: trueなら Prometheus が収集する。 - レプリカクラスタ — 別のクラスタ(別のデータセンター)にスタンバイを置く DR 構成。
- ボリュームスナップショットバックアップ、ハイバネーション(ボリュームは残して Pod だけ落とす)、ストレージのオンライン拡張。
実際のクラスタで確認できることと罠
ここからは文書ではなく経験です。
状態フィールドが止まっている。 バックアップは毎日取れているのに、status.lastSuccessfulBackup は 8 月 23 日で止まっています。プラグイン方式へ移した後、Cluster の状態フィールドと cnpg_collector_* メトリクスが更新されなくなるからです。この値でアラートを組むと 誤報 になります。判定は Backup オブジェクトの phase と barman_cloud_* メトリクスで行うべきです。
最初のバックアップは復旧できないことがある。 アーカイブを有効にした直後に取ったバックアップは completed と出ても、そのバックアップの開始 WAL 位置(beginWal)がアーカイブになく、復旧に失敗したことがあります。pg_switch_wal() で WAL を一度切り替えてからバックアップを取り直し、必ず復旧を試す こと。バックアップは「取れた」ではなく「戻せた」でしか確認されません。
async レプリケーションとインスタンス二つ。 上で見たとおり async はフェイルオーバー時に最後の数トランザクションを失うことがあります。同期レプリケーションを有効にすれば解決しますが、インスタンスが二つだけだとスタンバイが死んだ瞬間に 書き込みが止まる 逆の危険が生じます。三つに増やしてから有効にするのが順序です。
NFS 上のデータベース。 ボリュームは NFS(5Gi)です。今のデータは 7.8MB なので容量は問題ではありませんが、WAL の fsync はローカルディスクより遅く、CNPG の文書もローカルストレージを勧めています。負荷が増えたら最初に移すものです。
メトリクスが無効。 enablePodMonitor が無効なので Prometheus に DB のメトリクスがありません。接続数・レプリケーション遅延・WAL アーカイブ失敗を見るには有効にする必要があります。
一行で
CNPG の役割は、「今プライマリは誰か、その前のアドレスは何か、昨日に戻れるか」 に人ではなくコントローラがいつでも答えられるようにすることです。このクラスタはその三つが揃っています。残る宿題は復旧訓練を定期的に行うこと、インスタンス三つと同期レプリケーション、そしてメトリクスを有効にすることです。