Claude CodeでPostgreSQLのPITRを訓練する手順 — 復旧時刻を指定して戻す
WALアーカイブとベースバックアップから、指定した時刻へPostgreSQLを戻す訓練をClaude Codeで回す手順です。DBを動かす工程は接続先固定のスクリプト1本に閉じます。
PITR(Point-in-Time Recovery、時点復旧)は、ベースバックアップとWALアーカイブを組み合わせ、指定した時刻の状態までPostgreSQLを戻す仕組みです。誤ってDROP TABLEした直前、といった戻し方ができます。
この訓練をClaude Codeに任せるときの要点は2つです。archive_commandやrecovery_target_timeの設定、手順書づくりはClaudeに書かせます。一方で、DBサーバーを起動して接続する工程は、接続先を固定したスクリプト1本に閉じ込めて、承認付きでだけ外に出します。
PITRの訓練はpg_restoreの訓練と何が違うか
pg_dump系の論理バックアップは、取った時点の状態にしか戻せません。PITRは、ベースバックアップを取った後の任意の時刻に戻せます。PostgreSQL 17のドキュメントも、ベースバックアップ以降のどの時刻でも復元できる点を挙げています。
| pg_dump系(論理) | PITR(物理 + WAL) | |
|---|---|---|
| 戻せる時点 | pg_dump系(論理)ダンプを取った時点 | PITR(物理 + WAL)ベースバックアップ以降の任意の時刻 |
| 材料 | pg_dump系(論理)ダンプファイル | PITR(物理 + WAL)ベースバックアップと、途切れのないWALアーカイブ |
| 訓練で確かめること | pg_dump系(論理)ダンプを読み戻せるか | PITR(物理 + WAL)アーカイブが欠けていないか、目標時刻で止まるか |
ダンプ側の演習はClaude Codeでpg_restoreのリストアを検証用DBで検証する手順にあります。ここから先は、WALを使う側だけを扱います。
訓練の流れと、用意する3つのディレクトリ
訓練は5段です。置き場所はすべてリポジトリ直下のdrill/に固定します。
PITR訓練の5段
- 1
setup
使い捨てのクラスタを作り、WALアーカイブを有効にして、ベースバックアップを取ります。
- 2
mark
バックアップ後にデータを足し、復旧目標の時刻とその時点の行数を記録します。
- 3
incident
テーブルを消して事故を起こし、WALセグメントを強制的に切り替えてアーカイブに送ります。
- 4
restore
ベースバックアップを別ディレクトリへ複製し、目標時刻を指定して起動します。
- 5
verify
テーブルが戻り、行数が記録と一致するかを確かめます。
ディレクトリは3つです。drill/primaryが訓練用の本体、drill/archiveがWALの保管先、drill/baseが取ったベースバックアップになります。復元先のdrill/restoredは、restoreのたびに作り直します。
sandboxの線をどこに引くか
Claude Codeのsandboxは、Claudeが実行するシェルコマンドを囲います。書き込めるのは、既定では作業ディレクトリと一時ディレクトリ、追加したディレクトリです。drill/をリポジトリ直下に置けば、書き込み許可を足さずに済みます。
困るのは接続です。sandbox内のコマンドには外へ出る直接の経路がなく、ほとんどのDBドライバーはプロキシ環境変数を使いません。LinuxとmacOSで挙動が違う点はClaude Codeのsandboxから繋がらないにまとめています。LinuxとWSL2では、sandbox内のlocalhostはコマンドごとに独立していて、ホスト側のサーバーには届きません。
そこで、サーバーの起動、psql、pg_basebackupといった接続を伴う工程を、まとめて1本のスクリプトに入れます。そのスクリプトだけをexcludedCommandsに載せ、実行のたびにaskルールで承認を求めます。
{
"sandbox": {
"enabled": true,
"allowUnsandboxedCommands": false,
"excludedCommands": ["scripts/pitr-drill.sh *"]
},
"permissions": {
"ask": ["Bash(scripts/pitr-drill.sh *)"],
"deny": ["Edit(scripts/pitr-drill.sh)"]
}
}excludedCommandsに載ったコマンドは、ファイルもネットワークも制限なしで動きます。接続先を絞る壁はsandboxではなく、スクリプトの中のガードと承認プロンプトが担います。スクリプトを書き換えられる状態では外で何でも動かせてしまうため、Editのdenyを入れます。この設計の根拠はpg_restoreの記事に詳しく書きました。
archive_commandとベースバックアップを設定する
WALアーカイブを有効にする設定は3つです。wal_levelをreplica以上にし、archive_modeをonにし、archive_commandを書きます。archive_commandの%pはアーカイブ対象のパス、%fはファイル名に置き換わります。
訓練用のスクリプトは次のとおりです。PostgreSQL公式の例に沿った最小形で、あくまで訓練用の一例です。
#!/usr/bin/env bash
# scripts/pitr-drill.sh setup | mark | incident | restore | verify
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DRILL="$ROOT/drill"
case "$DRILL" in */drill) ;; *) echo "refused: $DRILL" >&2; exit 2 ;; esac
PRIMARY="$DRILL/primary"; ARCHIVE="$DRILL/archive"
BASE="$DRILL/base"; RESTORED="$DRILL/restored"
SOCK="$DRILL/sock"; SOCK2="$DRILL/sock-restored"
PSQL=(psql -h "$SOCK" -d postgres -At -v ON_ERROR_STOP=1)
case "${1:-}" in
setup)
rm -rf "$DRILL"; mkdir -p "$ARCHIVE" "$SOCK" "$SOCK2"
chmod 700 "$ARCHIVE"
initdb -D "$PRIMARY" > /dev/null
cat >> "$PRIMARY/postgresql.conf" <<EOF
listen_addresses = ''
unix_socket_directories = '$SOCK'
wal_level = replica
archive_mode = on
archive_command = 'test ! -f $ARCHIVE/%f && cp %p $ARCHIVE/%f'
EOF
pg_ctl -D "$PRIMARY" -l "$DRILL/primary.log" -w start
"${PSQL[@]}" -c "CREATE TABLE orders(id serial PRIMARY KEY, note text)"
"${PSQL[@]}" -c "INSERT INTO orders(note)
SELECT 'before-backup-' || g FROM generate_series(1,1000) g"
pg_basebackup -h "$SOCK" -D "$BASE" -X stream
"${PSQL[@]}" -c "INSERT INTO orders(note)
SELECT 'after-backup-' || g FROM generate_series(1,100) g"
;;archive_commandは、アーカイブ先に同名のファイルがあれば上書きせず失敗します。公式ドキュメントが、別サーバーの出力を同じ保管先へ流してしまう事故を防ぐために、上書きしない設計を勧めているためです。保管先をchmod 700にしているのも同じ流れで、WALにはDBの中身がすべて入っているので、他のユーザーに読ませない扱いが要ります。
archive_commandは終了コード0を返したときだけ成功とみなされます。0以外なら、PostgreSQLはそのWALを残したまま再試行を続けます。
復旧時刻を決めて事故を起こす
markは、事故の直前の時刻と行数を記録します。incidentは、テーブルを消してWALセグメントを切り替えます。次のコードは、上のスクリプトのcase文の続きです。
mark)
sleep 1
"${PSQL[@]}" -c "SELECT clock_timestamp()" > "$DRILL/target.txt"
"${PSQL[@]}" -c "SELECT count(*) FROM orders" > "$DRILL/expected.txt"
sleep 1
;;
incident)
"${PSQL[@]}" -c "DROP TABLE orders"
"${PSQL[@]}" -c "SELECT pg_switch_wal()"
sleep 5
;;pg_switch_wal()を呼ぶ理由は、アーカイブの仕様にあります。archive_commandが呼ばれるのは、書き終えたWALセグメントだけです。事故のときのWALが現在のセグメントに残っていると、アーカイブには届きません。本番ではarchive_timeoutで切り替えの間隔に上限をかけられます。
目標時刻の前後にsleepを入れているのは、時刻の境界にコミットが重ならないようにするためです。
時刻を指定して戻す
復元は、ベースバックアップの複製に3つの設定を足して起動します。restore_commandでアーカイブからの取り出し方を教え、recovery_target_timeで止める時刻を指定し、recovery.signalの空ファイルを置きます。
restore)
T="$(cat "$DRILL/target.txt")"
pg_ctl -D "$PRIMARY" -m fast stop
rm -rf "$RESTORED"; cp -R "$BASE" "$RESTORED"; chmod 700 "$RESTORED"
cat >> "$RESTORED/postgresql.conf" <<EOF
unix_socket_directories = '$SOCK2'
archive_mode = off
restore_command = 'cp $ARCHIVE/%f %p'
recovery_target_time = '$T'
recovery_target_action = 'pause'
EOF
touch "$RESTORED/recovery.signal"
time pg_ctl -D "$RESTORED" -l "$DRILL/restored.log" -w start
;;
verify)
Q=(psql -h "$SOCK2" -d postgres -At -v ON_ERROR_STOP=1)
"${Q[@]}" -c "SELECT to_regclass('orders') IS NOT NULL"
echo "expected=$(cat "$DRILL/expected.txt") actual=$("${Q[@]}" -c 'SELECT count(*) FROM orders')"
;;
*) echo "usage: $0 setup|mark|incident|restore|verify" >&2; exit 2 ;;
esacrestored側でarchive_mode = offにしているのが肝です。ベースバックアップには元のpostgresql.confが入っているので、そのまま起動すると、復元先が同じdrill/archiveにWALを書こうとします。上書きしないarchive_commandなら失敗で済みますが、そもそも保管先を共有しない設計にします。
recovery_target_actionの既定値はpauseです。目標に着いたところで復旧が止まり、クエリで状態を確かめられます。この停止が、verifyを挟める理由です。目標が正しければpg_wal_replay_resume()で復旧を終えます。目標が違ったなら、サーバーを止めて、より後ろの時刻に指定し直して起動します。前には戻れません。
scripts/pitr-drill.sh setup
scripts/pitr-drill.sh mark
scripts/pitr-drill.sh incident
scripts/pitr-drill.sh restore
scripts/pitr-drill.sh verify合格は、to_regclassが真を返し、expectedとactualの行数が一致することです。restoreのtimeが出す所要時間は、復旧にかかる時間の目安として記録します。
Claudeに任せる指示を書く
訓練の運用ルールは、プロジェクトのCLAUDE.mdに短く置きます。例えば次のような形です。
## PITR訓練
- DBに触る操作は scripts/pitr-drill.sh のサブコマンドだけで行う
- drill/ の外に書かない。本番の接続情報は読まない
- サブコマンドを実行するたびに、expected と actual を表にして報告する
- 復旧が目標時刻で止まらないときは、設定を直す前に drill/restored.log を読む最後の1行が効きます。復旧のログには、どのWALを取り出そうとして止まったかが出ます。設定を当てずっぽうに変えるより、ログから直すほうが速く収束します。
目標の決め方は時刻だけではない
事故の時刻が分からないときは、時刻以外の目標を使えます。recovery_target_name、recovery_target_lsn、recovery_target_xidなどがあり、同時に指定できるのは1つまでです。公式ドキュメントは、トランザクションIDを正確に特定する道具がないため、実用的なのは時刻と名前付きリストアポイントだと書いています。
名前付きリストアポイントは、危険な作業の直前にpg_create_restore_point()で自分で打つ目印です。訓練に取り込むなら、incidentの前にSELECT pg_create_restore_point('before_drop')を1行足し、restore側でrecovery_target_name = 'before_drop'に差し替えて、同じverifyが通るかを見ます。時刻指定より境界があいまいになりません。
時刻指定には、もう1つ設定があります。recovery_target_inclusiveの既定はonで、目標ちょうどのコミットを含めて止まります。offにすると、その直前で止まります。「事故のコミットの1つ前に戻す」ことが目的なら、この向きを訓練で試しておくと迷いません。
復旧をやり直すとタイムラインが増える
目標の時刻を外したときは、restoredを捨ててやり直せます。復旧が完了するたびに新しいタイムラインが作られ、WALのファイル名にそのIDが入るため、前の復旧で生まれたWALを上書きしません。公式ドキュメントも、どの時点に戻すか分からないときは、試行錯誤で何度か復旧して分岐点を探る使い方を挙げています。
既定では、アーカイブにある最新のタイムラインまで復旧します。ベースバックアップを取った時点のタイムラインや、特定の子タイムラインへ戻したいときは、recovery_target_timelineで指定します。タイムライン履歴ファイルも、アーカイブに必要です。archive_commandが.historyファイルを取りこぼしていないかを、drill/archiveの中身で確かめます。
ls drill/archiveWALセグメントのほかに、ベースバックアップの履歴ファイルが並びます。復旧後に再び事故を起こす二周目の訓練では、.historyファイルが増えているはずです。
ベースバックアップを増分にすると何が変わるか
PostgreSQL 17は、pg_basebackupの--incrementalオプションで増分バックアップを取れます。引数には、前回のバックアップのマニフェストを渡します。復元するときは、増分バックアップ本体のほかに、依存する以前のバックアップもすべて要ります。別々のディレクトリに置き、pg_combinebackupで結合します。
訓練に取り込むと、setupが増え、restoreの前に結合の段が入ります。増分でも、バックアップ中とそれ以降のWALが必要であり、recovery.signalと復旧設定も同じです。つまり、増分の訓練は「バックアップの連鎖が切れていないか」を足して確かめる作業です。
Claudeの作業が止まったときの切り分け
Claudeがscripts/pitr-drill.shを直接実行して失敗する場合は、次の順に見ます。
- 承認画面に「unsandboxed」と出ているか。出ていなければ、
excludedCommandsのパターンが呼び出しと一致していません。ワイルドカードがないパターンは完全一致になります psqlが接続できない。unix_socket_directoriesがdrill/sockを指しているか、listen_addressesが空のままかを見ます- 復元後に
archiveへ書き込もうとしていないか。restored.logにarchive関連のエラーがあれば、archive_mode = offが効いていません - 目標時刻で止まらず、最新まで進む。
recovery_target_timeのスペルと、postgresql.confへの書き込み位置を見ます
特に3つ目は、ベースバックアップが元の設定ファイルを丸ごと持っていることから起きます。Claudeに設定を直させるときは、「復元先のpostgresql.confの末尾に何が足されたか」を表示させると、原因が見えます。
つまずきやすい点
落とし穴
時刻に略称を書くと拒否される
recovery_target_timeには、JSTのようなタイムゾーン略称を書けません。公式ドキュメントは、UTCからの数値オフセットか、Asia/Tokyoのような完全な名前を勧めています。上のスクリプトはclock_timestamp()の出力をそのまま使い、数値オフセット付きの形で渡しています。
ほかに押さえる挙動が4つあります。
- 復旧目標は1つだけ指定できます。
recovery_target_timeとrecovery_target_xidを同時に書くとエラーになります - 復旧が終わると、新しいタイムラインが作られます。やり直しを繰り返しても、前回のWALを上書きしません
- 起動直後に
00000001.historyの取得失敗が出ても、単純な復旧では異常ではありません recovery_target_actionをshutdownにするとrecovery.signalが残るため、次の起動はすぐ止まります
本番に近づけるときに変える点
訓練用のcpは、公式ドキュメントも「例であって推奨ではない」としています。本番では、保管先を別の記憶装置にし、失敗を運用者へ通知する構成を組みます。アーカイブが詰まるとデータ損失の窓が広がり、pg_wal/が溜まり続ける点が、設計の出発点です。
訓練はこの形のまま、archive_commandだけを差し替えて再実行できます。スクリプトのsetupに差し替え対象を集めてあるのは、そのためです。Claudeには「差し替えた後で5段を回し、expectedとactualが一致するか」を成功条件として渡します。
sandboxの選択肢そのものを見直したいときは、Claude Codeのサンドボックスの選び方が比較に使えます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。