コンテンツにスキップ

本番 DB のバックアップとリストア

本番 Neon DB の日次バックアップと、そこからの復旧手順。

なぜ持っているか

Neon の Free プランは Point-in-Time Restore の保持期間が 6 時間しかない。誤操作や不正なマイグレーションを翌日以降に見つけた場合、Neon の機能だけでは戻せない。そのため Neon とは独立したダンプを S3 に置いている。

仕組み

実行 GitHub Actions Backup DB Prod(毎日 18:10 UTC = JST 03:10 / 手動実行も可)
取得方法 pg_dump --format=custom(サーバーに合わせて postgres:18 コンテナで実行)
保存先 s3://rikako-db-backups-production/production/YYYY/MM/DD/rikako-<timestamp>.dump
暗号化 S3 サーバーサイド暗号化(AES256)
保持 30 日で自動削除(S3 Lifecycle)。versioning 有効で、旧バージョンも 30 日で削除
権限 バックアップ専用の OIDC ロール rikako-production-db-backup(trust は main ブランチのみ)。backup prefix への PutObject と検証用の GetObject だけで、ListBucket も DeleteObject も持たない
失敗時 Slack(/rikako/production/slack-alert-webhook-url)へ通知

接続文字列は SSM SecureString /rikako/production/database-url から取得し、GitHub Actions のログに出さない(::add-mask:: でマスクし、コマンドライン引数にも渡さない)。このリポジトリは public のため必須。

接続先が pooler ではない理由

SSM の database-url には Neon の直接エンドポイントが入っている。アプリ(公開 API / 管理 API)は起動時に DB_USE_POOLER=true を見て dbconn.Pooled() でホストを pooler へ変換して使うが、バックアップでは変換しない。

pg_dump は PgBouncer の transaction pooling と相性が悪く、セッションをまたぐ操作で失敗しやすいため。SSM の値をそのまま使えばよい。

バックアップの確認

export AWS_PROFILE=<prod のプロファイル>   # docs/aws-setup.md 参照

# 直近のバックアップ一覧
aws s3 ls s3://rikako-db-backups-production/production/ --recursive | tail -10

鮮度の監視(止まったことに気づく)

backup-db-prod.yml は「実行されて失敗した」ときしか Slack に通知しない。schedule が 実行されない(GitHub の遅延、public リポジトリは 60 日無活動で schedule が自動停止、 Actions の障害)と無音で止まるため、GitHub の外から S3 の実体を見張る(#331)。

EventBridge Scheduler(毎日 21:10 UTC)
  → Lambda rikako-backup-freshness-production
      s3://rikako-db-backups-production/production/ の最新オブジェクトの LastModified を取得
      → 48 時間より古い、または 1 件も無い → SNS rikako-alerts-production → slack_notifier → Slack
  • 定義: terraform/environments/prod/backup_monitor.tf、コード: lambda/backup_freshness/index.py
  • 閾値は日次 + 1 日分の遅延・再実行の余地で 48 時間(local.backup_freshness_max_age_hours)。 実際の schedule は cron の 18:10 UTC から 2 時間以上遅れることがある(GitHub の仕様)
  • 対象は workflow が作る YYYY/MM/DD/rikako-<timestamp>.dump(1024 bytes 以上)だけ。同じ prefix に調査用ファイルや手動アップロードがあっても鮮度の判定に混ざらない
  • 監視の監視は CloudWatch Alarm 2 個: rikako-production-backup-freshness-heartbeat(Lambda が 2 日間呼ばれていない = Scheduler 停止・呼び出し失敗)と ...-errors(Lambda がエラー終了)
  • 手動で確認したいときは Lambda を直接呼ぶ({"status": "ok", "age_hours": ...} が返る):
aws lambda invoke --function-name rikako-backup-freshness-production /dev/stdout
  • 通知が飛ぶことの確認は、Lambda の環境変数 MAX_AGE_HOURS を一時的に 1 にして invoke する (Terraform で戻す)。バックアップ自体を止める必要はない

リストア手順

本番へ直接戻す前に、必ず別のデータベースへ復元して中身を確認すること。

1. ダンプを取得する

export AWS_PROFILE=<prod のプロファイル>
aws s3 cp s3://rikako-db-backups-production/production/2026/08/25/rikako-20260825T181000Z.dump ./restore.dump

2. 検証用のデータベースへ復元する

ローカルの PostgreSQL 18 に復元して内容を確認する。

復元先のデータベースは事前に作る必要がある。 pg_restore --dbname は既存のデータベースへ接続するため、無い状態では接続に失敗する。

docker compose up -d   # ローカルの postgres:18

# 検証用データベースを作る(既にあれば作り直す)
docker run --rm --network host postgres:18 \
  psql "postgres://rikako:password@host.docker.internal:5432/postgres" \
    -c 'DROP DATABASE IF EXISTS rikako_restore_check' \
    -c 'CREATE DATABASE rikako_restore_check'

docker run --rm -v "$PWD:/backup" --network host postgres:18 \
  pg_restore --dbname "postgres://rikako:password@host.docker.internal:5432/rikako_restore_check" \
    --no-owner --no-privileges /backup/restore.dump

作ったばかりの空のデータベースへ入れるので --clean --if-exists は不要。

行数や最新の更新時刻を見て、期待する時点のデータかを確認する。

SELECT count(*) FROM users;
SELECT count(*) FROM user_answers;
SELECT max(created_at) FROM user_answers;

3. 本番へ戻す

削除の再適用: バックアップには、取得後に DELETE /account(#408)で削除されたアカウントの データ(メールアドレス・学習データ・墓標)が残っている。復元したら、復元時点で deleted_accounts に 残っている墓標(SELECT cognito_sub FROM deleted_accounts)と、復元前の DB から控えておいた 「バックアップ取得以降の削除」を、runbook の「アカウントの削除」の手動手順で再適用する。 プライバシーポリシーで「復旧後にバックアップ取得以降の削除を再度適用する」と約束している。

pg_restore --clean で今の DB を上書きする方法は使わない。

--clean が削除するのはアーカイブに含まれるオブジェクトだけで、バックアップ取得後のマイグレーションが追加したテーブル・制約・関数などは古いダンプに存在しないため削除されずに残る。「不正なマイグレーションを翌日以降に見つけて戻す」という本来の用途では、新旧スキーマが混ざった状態になり「バックアップ時点へ戻った」とは言えない。

代わりに、空の新しい DB へ復元してから接続先を切り替える。元の DB を壊さないので、問題があれば戻せる。

3-1. 利用者へ告知する(これだけでは書き込みは止まらない)

app_status.is_maintenance を立てると、iOS アプリは起動時にメンテナンス画面へ切り替わる。

curl -u 'ユーザー名:パスワード' -X PUT https://admin.rikako.org/api/app-status \
  -H 'Content-Type: application/json' \
  -d '{"isMaintenance": true, "maintenanceMessage": "データ復旧作業中です"}'

このフラグは告知用であって、書き込みを拒否する仕組みではない。 API のハンドラは通常どおり動くため、以下からは書き込めてしまう。

  • 既に起動していて /status を再取得していない iOS アプリ
  • Web(it.rikako.org)
  • API の直接呼び出し
  • 管理 API

そのため、次の 3-2 で技術的に止める。

3-2. API を止めて書き込みを遮断する

公開 API と管理 API はどちらも Lambda なので、予約同時実行を 0 にすると新規の実行が止まる。

for FN in rikako-api-production rikako-admin-api-production; do
  aws lambda put-function-concurrency \
    --function-name "$FN" --reserved-concurrent-executions 0
done

実行中のリクエストが終わるまで少し待つ(数十秒)。止まったことを確認する。

curl -s -o /dev/null -w '%{http_code}\n' https://api.rikako.org/status   # 502/503

途中で中断する場合も、必ず 3-8 の復帰手順を実行すること。 予約同時実行 0 のまま放置すると API は停止したままになる。

3-3. 復元先の空 DB を用意する

Neon のコンソール(または API)で、本番プロジェクトに新しいブランチを作る(例: restore-20260825)。ブランチなら既存の本番ブランチに触れずに済み、切り戻しも容易。

リージョンに注意。 AWS リソース(Lambda / API Gateway / S3)は ap-northeast-1 だが、Neon プロジェクトだけ ap-southeast-1(Singapore)(terraform/environments/prod/neon.tf の region_id = "aws-ap-southeast-1")。接続先ホストも ...ap-southeast-1.aws.neon.tech になる。

作成後、そのブランチの接続文字列(直接エンドポイント)を控える。

BRANCH_URL はブランチの既定 DB(管理操作用)、RESTORE_DB_URL はこれから作る復元先の DB。ホストは同じで、データベース名だけが違う。

BRANCH_URL='postgres://USER:PASS@ep-xxxx.ap-southeast-1.aws.neon.tech/neondb?sslmode=require'
RESTORE_DB_URL='postgres://USER:PASS@ep-xxxx.ap-southeast-1.aws.neon.tech/neondb_restore?sslmode=require'

新しいブランチは作成元の時点のデータを含む。ダンプを入れる前に、専用の空データベースを作ること。

docker run --rm -e BRANCH_URL postgres:18 \
  sh -c 'psql "$BRANCH_URL" -c "DROP DATABASE IF EXISTS neondb_restore" \
                            -c "CREATE DATABASE neondb_restore"'

3-4. ダンプを復元する

--exit-on-error を付ける。 付けないとエラーがあっても最後まで進み、欠けたまま「成功」して見える。

docker run --rm -e RESTORE_DB_URL -v "$PWD:/backup" postgres:18 \
  sh -c 'pg_restore --dbname "$RESTORE_DB_URL" --exit-on-error --no-owner --no-privileges /backup/restore.dump'

3-5. 復元先を直接確認する

API はまだ止めたままなので、復元先の DB に直接つないで確認する。

SELECT count(*) FROM users;
SELECT count(*) FROM accounts;
SELECT count(*) FROM user_answers;

-- マイグレーションの版が期待どおりか(不正なマイグレーションを戻す場合は特に重要)
SELECT * FROM schema_migrations;

-- account_id が指す accounts が存在するか
SELECT count(*) FROM users u LEFT JOIN accounts a ON a.id = u.account_id
WHERE u.account_id IS NOT NULL AND a.id IS NULL;

3-6. アプリコードとの互換性を確認する

古いバックアップへ戻すと schema_migrations も過去へ戻る。 障害の原因が「マイグレーションと同時にリリースしたコード」だった場合、現在の Lambda コードのまま再開すると、存在しない列やテーブルを参照して再び落ちる。

3-5 で確認した schema_migrations の版に対して、いま動いているコードが動作するかを判断する。合わない場合は、Lambda のイメージも当時のものへ戻してから再開する。

# いま Lambda が使っているイメージ(digest 付き)
aws lambda get-function --function-name rikako-api-production \
  --query 'Code.ImageUri' --output text

デプロイは :production という動くタグを上書きする方式なので、過去のイメージはタグでは辿れない。digest で指定する。

# ECR は shared アカウント。push 日時の新しい順に並べる
AWS_PROFILE=<shared のプロファイル> aws ecr describe-images \
  --repository-name rikako-api --region ap-northeast-1 \
  --query 'reverse(sort_by(imageDetails,&imagePushedAt))[:5].[imageDigest,imagePushedAt,imageTags]' \
  --output table

戻す場合(concurrency を解除する前に行う)。

REG=579039992557.dkr.ecr.ap-northeast-1.amazonaws.com
aws lambda update-function-code --function-name rikako-api-production \
  --image-uri "$REG/rikako-api@sha256:<digest>"
aws lambda wait function-updated --function-name rikako-api-production

管理 API(rikako-admin-api-production / rikako-admin-api)も同様に判断する。

コードを戻した場合、復旧後に main の内容と食い違ったままになる。落ち着いたらリバートやマイグレーションのやり直しを含めて、コード側の整合も取ること。

3-7. 接続先を切り替える

切り替える前に、現在の値を必ず控える(切り戻し用)。

# 現在の値を退避(画面に出さない)
aws ssm get-parameter --name /rikako/production/database-url --with-decryption \
  --query 'Parameter.Value' --output text > ./previous-database-url.txt
chmod 600 ./previous-database-url.txt

aws ssm put-parameter --name /rikako/production/database-url \
  --type SecureString --overwrite --value "$RESTORE_DB_URL"

このパラメータは Terraform で lifecycle.ignore_changes = [value] になっているため、手動で上書きしても次の apply で巻き戻らない(運用ランブックのローテ手順と同じ扱い)。

3-8. Lambda を再開する

SSM を書き換えただけでは切り替わらない。 Lambda は起動時に SSM を解決し、warm な実行環境は古い接続を持ち続けるため、実行環境を作り直す必要がある。ローテ手順と同じく --description を更新する。

TS=$(date +%s)
for FN in rikako-api-production rikako-admin-api-production; do
  aws lambda update-function-configuration --function-name "$FN" \
    --description "db restore $TS" > /dev/null
  aws lambda wait function-updated --function-name "$FN"
  # 予約同時実行の設定を削除して再開(元は予約なし)
  aws lambda delete-function-concurrency --function-name "$FN"
done

疎通を確認する。

curl -s -o /dev/null -w '%{http_code}\n' https://api.rikako.org/status      # 200
curl -s -o /dev/null -w '%{http_code}\n' https://api.rikako.org/workbooks   # 200(DB を叩く)

3-9. メンテナンスを解除する

復元した DB の app_status はダンプ時点の値(通常は is_maintenance = false)。確認し、必要なら明示的に戻す。

curl -s https://api.rikako.org/status   # isMaintenance を確認

curl -u 'ユーザー名:パスワード' -X PUT https://admin.rikako.org/api/app-status \
  -H 'Content-Type: application/json' \
  -d '{"isMaintenance": false, "maintenanceMessage": ""}'

ログイン済みのアカウントで学習記録が見えることも確認する。

3-10. 問題があれば切り戻す

元の DB は触っていないので、SSM を戻せば元に戻る。ただし 切り替えのときと同じく、先に書き込みを止めること。

止めずに戻すと、Lambda 2本の設定更新が終わるまでの間、あるリクエストは復旧 DB へ、別のリクエストは旧 DB へ書き込む(split-brain)。どちらにも欠けたデータが残り、あとから突き合わせるのは難しい。

# 1. 書き込みを止める
for FN in rikako-api-production rikako-admin-api-production; do
  aws lambda put-function-concurrency \
    --function-name "$FN" --reserved-concurrent-executions 0
done

# 2. 実行中のリクエストが終わるまで待つ(数十秒)
curl -s -o /dev/null -w '%{http_code}\n' https://api.rikako.org/status   # 502/503

# 3. SSM を旧 URL へ戻す
aws ssm put-parameter --name /rikako/production/database-url \
  --type SecureString --overwrite --value "$(cat ./previous-database-url.txt)"

# 4. 実行環境を作り直す(SSM の書き換えだけでは切り替わらない)
TS=$(date +%s)
for FN in rikako-api-production rikako-admin-api-production; do
  aws lambda update-function-configuration --function-name "$FN" \
    --description "db rollback $TS" > /dev/null
  aws lambda wait function-updated --function-name "$FN"
done

# 5. 再開する
for FN in rikako-api-production rikako-admin-api-production; do
  aws lambda delete-function-concurrency --function-name "$FN"
done

# 6. 接続先と主要データを確認する
curl -s -o /dev/null -w '%{http_code}\n' https://api.rikako.org/workbooks   # 200

3-6 で Lambda のイメージも戻していた場合は、それも元へ戻すこと。

落ち着いたら previous-database-url.txt を削除し、Terraform 側(neon_project のブランチ構成)も実態に合わせるか検討する。

注意点

  • 今の DB を pg_restore --clean で上書きする方法は使わない。 --clean が削除するのはアーカイブに含まれるオブジェクトだけで、バックアップ取得後のマイグレーションが追加したものは残る。新旧スキーマが混ざるため「その時点へ戻った」とは言えない
  • 部分的に戻したい場合(特定テーブルだけなど)は、新しい DB へ復元したうえで必要な範囲を移す
  • ダンプは pg_dump 実行時点のスナップショット。それ以降の書き込みは失われる
  • 保持は 30 日。それより前へ戻す必要がある要件が出たら、保持期間か保存先を見直す
  • dev のバックアップは取得していない(本番のみ)
  • 失敗通知は「ワークフローが動いて失敗した」ときしか出ない。 スケジュール自体が遅延・停止した場合は無通知になる。public リポジトリでは 60 日間アクティビティが無いとスケジュールが自動で無効化される点にも注意(最新バックアップの鮮度監視は別途検討)