バックアップは、復元するまでただの仮説だった ― PostgreSQLテストリストアと「件数が一致して一瞬焦った」話

家庭内の分散AI推論基盤「Coordinator」では、タスクの状態・結果・モデル統計を PostgreSQL に貯めている。日次の pg_dump バックアップは 2026-06-06 に整備済みで、毎日 02:00 に AI-Core から公開サーバー mars へ転送し、7世代を保持している。仕組みとしては完成している――はずだった。

だが、ひとつ宿題が残っていた。一度も復元したことがない。このシリーズで繰り返してきた合言葉どおり、「復元したことのないバックアップは、ただの仮説」である。今回はその仮説を実測で潰す。

大原則:テストリストアで本番DBに触ってはいけない

結論から言うと、テストリストアの最大の罠は「本番DBへ流し込んでしまうこと」だ。バックアップは 02:00 時点のスナップショットなので、本番の coordinator に上書きすれば、その後に増えたデータを古い状態で潰しかねない。これは、このシリーズで何度も痛い目を見てきた「サイレント故障」の典型例になり得る。

そこで設計の芯はシンプルにした。テストは必ずスクラッチDB coordinator_restore_test に入れ、本番 coordinator は件数照合のために読むだけ。スクリプトには「テストDB名が本番DB名と一致したら即中止」という安全弁も入れた。徹底的に、本番には指一本触れない。

着手前に、まず実機の pg_backup.sh を読む

引き継ぎ資料にはバックアップの要約しか書いていなかったため、復元手順を仮説で書く前に、配備済みの pg_backup.sh を直接読んだ。前回も学んだとおり、引き継ぎ資料は実機より古いことがある。確認すべきは「単体 pg_dumppg_dumpall か」「--clean / --create の有無」だ。

PGPASSWORD="..." pg_dump -h localhost -U coordinator coordinator | gzip -9 > coordinator_YYYYMMDD_HHMMSS.sql.gz

判明したのは、単体DBのプレーンSQLを gzip しただけで、--clean--create も付いていないということ。つまり復元は pg_restore ではなく、空のDBへ psql で流し込む形になる。カスタム形式(-Fc)と勘違いして pg_restore を叩いていたら、いきなり詰まっていた。フォーマットの確定は、手順を分ける最初の分岐点だ。

もうひとつ、ダンプの先頭にこんな1行があった。

\restrict PDXesNqIegUpkbFbW0VhkrBrc0jnt4kZKhZ7ToKmDCVWuoKHPK8LfwPPwDpS1FA

これは比較的新しい pg_dump(PostgreSQL 18系)が付ける psql メタコマンドで、ダンプ経由のコマンド注入を防ぐためのもの。ランダムトークンはダンプごとに変わる。psql 経由なら無加工で素通りするが、逆に言えば「手作業の sed で加工して流す」ような雑なことはできない、という念押しでもある。

非破壊テストリストアを6ステップで回す

実行ホストは PostgreSQL が同居する AI-Core。スクリプトは次の6段で、すべて本番に触れずに完結する。

  • ① mars から最新の .sql.gz を取り戻す。これでリモート保管分の「到達性・可読性」も同時に検証できる。
  • gzip -t で CRC 健全性チェック。転送途中の破損をここで弾く。
  • ③ ダンプ先頭40行を表示。フラグ確定の二重確認。
  • ④ スクラッチDBを drop → 空作成(本番名一致なら即中止の安全弁つき)。
  • zcat | psql -v ON_ERROR_STOP=1 で流し込む。最初のエラーで止めるので、部分復元を「成功」と誤認しない。
  • ⑥ 復元DBの全テーブル件数を本番と照合

流し込みは ON_ERROR_STOP に引っかからず最後まで通り、restore done。ここまでは順調だった。

予測が外れた ― 「件数がぴったり一致」で一瞬手が止まる

事前に手順書へはこう書いていた。「バックアップは 02:00 のスナップショットだから、復元DBの件数は本番よりやや少ないはず」。本番はその後タスクが増えているからだ。ところが実測はこうだった。

  table                 restore     prod
  comparison_results        289      289
  comparison_sessions        58       58
  model_domain_stats         27       27
  tasks                     309      309

全テーブル、完全一致。 一瞬「まさかテスト側が本番DBを読んでしまったのか?」と疑った。だが、ここで早合点しないのがこのシリーズの規律だ。条件反射で「バグだ」と断罪する前に、まず切り分ける。

切り分けると、答えは拍子抜けするほど単純だった。ステップ④で coordinator_restore_test空から作り直し(ログにも does not exist, skippingCREATE DATABASE と出ている)、ステップ⑤で gzip ファイルからそのDBだけに流し込んだ結果が 289件なのだ。つまりこれは、バックアップファイルに実データが確かに入っていて、独立したDBへ正しく復元された動かぬ証拠である。本番を読んだのではない。

では、なぜ一致したのか。理由は「02:00 以降、このクラスタに新規タスクが一件も発生していなかった」だけ。家庭内ラボの、誰も使っていない朝の時間帯。件数の一致は異常ではなく、「スナップショットが最終活動時点まで完全」を意味していた。

ダメ押しの独立性チェックとして、両DBの最新タイムスタンプも見ておく。

SELECT max(created_at) FROM tasks;  -- restore 側 / prod 側

両方が 02:00 以前なら「02:00 以降トラフィックなし」が確定し、一致の説明がつく。計測してから信じる。そして計測結果を、思い込みで断罪しない。予測が外れたときこそ、慌てて結論に飛びつかず、観測事実を一段ずつ積む――この一連が、今回いちばんの収穫だった。

ついでに見えた、自動化への伏線

スクラッチDBの作成で sudo のパスワードプロンプトが出た。対話実行では何の問題もないが、将来このテストリストアを定期ジョブ化(CI/CD の「検証規律のジョブ化」)するなら、非対話で CREATE DATABASE を通す必要がある。postgres 限定の passwordless sudo か、coordinator ロールへの CREATEDB 付与か――そのときの宿題として記録しておく。

仮説が、検証済みの事実になった

これで item 9 のコア、「一度、実リストアまで通す」は達成できた。バックアップは「あるはず」から「復元できると確かめた」へ昇格した。あわせて、本番DBが失われたときの破壊的な災害復旧手順(書き込みを止める → 復元 → 「DB → Worker → Coordinator」の順で戻す)も手順書に明文化した。ただしこちらは本番でしか使わない手順なので、今回は実行しない。順序ミスで二次被害を出してきた過去があるからこそ、紙の上で順序を固めておく。

派手な機能追加ではない。だが、「壊れたときに本当に戻せるか」を一度でも実測した基盤は、しなかった基盤とは別物だ。仮説をひとつ、事実に変えた回だった。