運用ランブック¶
前提: AWS CLI プロファイルの設定が必要です。AWS CLI セットアップ を参照してください。
以降のコマンドは事前に
aws sso login済みでAWS_PROFILEが設定されている前提です。# dev環境 export AWS_PROFILE=rikako-development-sso # shared環境(ECR操作時) export AWS_PROFILE=rikako-shared-sso
環境情報¶
| 項目 | dev環境 | prod環境 |
|---|---|---|
| Lambda (公開API) | rikako-api-development |
rikako-api-production |
| Lambda (管理API) | rikako-admin-api-development |
rikako-admin-api-production |
| Lambda (Slack 通知) | rikako-slack-notifier-development |
rikako-slack-notifier-production |
| 公開API エンドポイント | https://api.dev.rikako.org (API Gateway) |
https://api.rikako.org (API Gateway) |
| 管理画面 | https://admin.dev.rikako.org (Basic Auth) |
https://admin.rikako.org (Basic Auth) |
| 管理API | https://admin.dev.rikako.org/api |
https://admin.rikako.org/api |
| 画像CDN | https://image.dev.rikako.org/ |
https://image.rikako.org/ |
| コンテンツCDN | https://content.dev.rikako.org/ |
https://content.rikako.org/ |
| 画像S3 | rikako-images-development |
rikako-images-production |
| コンテンツS3 | rikako-content-development |
rikako-content-production |
| 管理画面S3 | rikako-admin-development |
rikako-admin-production |
| Neon プロジェクト ID | muddy-tree-64549662 (ap-southeast-1) |
fragrant-poetry-87067174 (ap-southeast-1) |
| Neon エンドポイント | ep-raspy-lab-a1wo0g6n |
ep-misty-unit-aoxkoz1d |
| ECR (shared) | 579039992557.dkr.ecr.ap-northeast-1.amazonaws.com/rikako-api |
同左 |
| CloudWatch Dashboard | rikako-dev |
rikako-prod |
| AWSアカウント | 197865631794 | 211125415945 |
| AWS Profile | rikako-development-sso |
rikako-production-sso |
Shared 環境(ECR を共有): AWS アカウント 579039992557 / プロファイル rikako-shared-sso。
1. デプロイ手順¶
prod を一括で出す(Deploy All Prod)¶
gh workflow run "Deploy All Prod" --repo takoikatakotako/rikako --ref main
初回だけは先に
Apply Terraform Prodが要る。 publish job の invalidation に使うcloudfront:CreateInvalidation/GetInvalidationはterraform/environments/prod/content_cdn.tfで追加したもので、 apply するまで本番の IAM ロールには付かない。未 apply のまま実行すると、 API・管理API・管理画面・LP のデプロイと/publishが終わったあとにInvalidate content CDNがAccessDeniedで落ち、web だけ出ない部分反映になる。
- main へマージ
Apply Terraform Prodを main から実行。plan にaws_iam_role_policy.github_actions_content_invalidationの追加が出ることを 確認して承認・apply- apply 成功後に
Deploy All Prodを実行
公開API / 管理API / 管理画面 / LP / ポータル / 問題集Web をまとめてデプロイする。 このワークフローの主目的は順序の強制で、次の依存関係を保証する。
管理API のデプロイ → /publish(DB → S3)→ CDN の invalidation → 問題集Web のビルド
問題集Web は静的エクスポートで、ビルド時にコンテンツ CDN の JSON を焼き込む。
この順序を外すと古い内容が焼き込まれたサイトが本番に出る(2026-09-06 に
/publish の実行漏れで踏みかけた)。publish が失敗した場合、web のデプロイは
実行されない。
invalidation を挟むのは、publish が S3 を上書きするだけでエッジのキャッシュを
消さないため。 JSON の Cache-Control は max-age=60、コンテンツ CDN の
default_ttl も 60 なので、publish 直前にエッジへ載った古い JSON が最大 60 秒
返り続ける。web のビルドがそれを掴むと、publish した意味が無くなる。
aws cloudfront wait invalidation-completed で完了を待ってから web に進む。
main 以外からは実行できない。 reusable workflow は呼び出し元の ref を
checkout するため、main 以外から起動すると web / portal の祖先チェックが弾くより
前に他のコンポーネントがその ref の内容で本番へ出てしまう。先頭の verify-ref
job で止めている。
Terraform に未適用の差分があると承認を求められる。 デプロイ前に
terraform plan -detailed-exitcode(読み取り専用ロール)で prod の差分を見る。
- 差分なし → そのまま進む(承認は増えない)
- 差分あり →
drift-ackjob がproductionenvironment の承認待ちになる。 変更されるリソースの一覧が job の Summary に出るので、確認してから承認する
止めずに承認制にしているのは、無関係な未適用の差分が 1 つあるだけでアプリを 出せなくなるのを避けるため。「コードは main に入っているが、それが要求する インフラがまだ apply されていない」状態に気づくのが目的(#371 の invalidation 用 IAM ポリシーがまさにこれで、apply 前に一括デプロイしていれば web だけ出ない 部分反映になっていた)。
ポータルはコンテンツを焼き込まない(API を実行時に叩く)ため publish を待たない。
データだけ反映する: Sync Content {Dev,Prod}(#391)¶
data/ の YAML だけを変えたときは Deploy All Prod ではなく Sync Content を使う。
手順が 1 本になっており、順序も強制される。
datasync apply(YAML → Neon)→ /publish(DB → S3)→ CDN invalidation → 問題集Web のビルド
| 起動 | 承認 | |
|---|---|---|
Sync Content Dev (sync-content-dev.yml) |
data/** が main に入ると自動(workflow_dispatch も可、main のみ) |
なし |
Sync Content Prod (sync-content-prod.yml) |
手動 dispatch(main のみ) | production ×2(sync → web) |
datasync applyは CI 上でgo build ./cmd/datasyncして実行し、接続 URL は SSM の/rikako/<env>/database-urlから読む。prod の GitHub Actions ロールにはこのためのssm:GetParameterを付けている(github_actions.tf)- publish + invalidation は
.github/actions/publish-content(composite action)で、 Deploy All Prod の publish job と同じもの - PR 段階の差分は従来通り
plan-datasync.ymlがコメントする。apply を手元で打つ運用は デバッグ時のみ(datasync)
承認は数回に分かれる。 各コンポーネントの job が
productionenvironment を 使うため、同時に走る job の分をまとめて承認したあと、publish、続いてwebの 分を順に承認することになる。ロールバックはこのワークフローでは行わない。 一括で過去へ戻すのは想定して いないため
checkout_refを受け取らない。個別のワークフローを使うこと (ロールバック手順)。
個別に出したいとき、あるいは特定のコンポーネントだけ戻したいときは、以下の 個別ワークフローを直接実行する。
公開API / 管理API¶
Dev: mainブランチへのマージで自動デプロイ(GitHub Actions)。
# 手動トリガー(Dev)
gh workflow run "Deploy API Dev" --repo takoikatakotako/rikako --ref main
gh workflow run "Deploy Admin API Dev" --repo takoikatakotako/rikako --ref main
# 状況確認
gh run list --repo takoikatakotako/rikako --limit 5
Prod: 手動 dispatch のみ(自動デプロイなし)。さらに production environment の
承認を通すまでジョブは waiting で止まる。起動しただけでは本番に出ない。
gh workflow run "Deploy API Prod" --repo takoikatakotako/rikako --ref main
gh workflow run "Deploy Admin API Prod" --repo takoikatakotako/rikako --ref main
# 起動後、Actions の実行ページで "Review deployments" から承認する
gh run list --repo takoikatakotako/rikako --limit 5
管理画面を API + フロントまとめて出すなら
Deploy Admin Prod。呼び出す 2 本が それぞれ承認を要求するため、承認は 2 回必要になる。
デプロイフロー¶
- Dockerイメージをビルド(
app/Dockerfile.lambda/app/Dockerfile.admin) - ECRにプッシュ(タグ:
dev/prod) aws lambda update-function-codeで Lambda 関数のイメージを更新aws lambda wait function-updatedで更新完了まで待機/healthエンドポイントでヘルスチェック
管理画面フロントエンド¶
Dev: mainブランチへのマージで admin/ 配下に変更がある場合に自動デプロイ。
Prod: 手動 dispatch + production environment の承認。
# 手動デプロイ(Dev)
gh workflow run "Deploy Admin Frontend Dev" --repo takoikatakotako/rikako --ref main
# Prod(承認が必要)
gh workflow run "Deploy Admin Frontend Prod" --repo takoikatakotako/rikako --ref main
デプロイフロー¶
npm run buildでビルド- S3にsync(静的アセット: 1年キャッシュ、HTML: キャッシュなし)
- CloudFrontキャッシュを無効化
LP(rikako.org)¶
lp/ の静的ファイルを S3 + CloudFront から配信している。
- dev:
dev.rikako.org(Basic 認証あり)。main へのマージでlp/に変更があれば自動デプロイ - prod:
rikako.org。手動起動 +productionenvironment の承認
# dev(手動で出したいとき)
gh workflow run "Deploy LP Dev" --repo takoikatakotako/rikako --ref main
# prod(起動後、承認するまで waiting のまま止まる)
gh workflow run "Deploy LP Prod" --repo takoikatakotako/rikako --ref main
Basic 認証の資格情報は管理画面と共通(SSM の /rikako/admin-basic-auth-user / /rikako/admin-basic-auth-password)。
アカウントポータル(account.rikako.org)¶
メールログイン・アカウント管理・アカウント削除(/delete、#408。Play Console のデータセーフティに
登録する削除 URL は https://account.rikako.org/delete)の画面(portal/)。
- dev:
account.dev.rikako.org(Basic 認証あり)。main へのマージでportal/に変更があれば自動デプロイ - prod:
account.rikako.org。手動起動 +productionenvironment の承認
gh workflow run "Deploy Portal Dev" --repo takoikatakotako/rikako --ref main
gh workflow run "Deploy Portal Prod" --repo takoikatakotako/rikako --ref main
最後にいつ prod へ出したかはタグで確認する。prod デプロイが成功すると
portal-prod/<日時> のタグと GitHub Release が作られる。
アカウントの削除(#408)¶
ユーザーは DELETE /account(JWT 必須)で自分のアカウントを削除できる。ポータルの削除画面と
アプリ内の「アカウントを削除」がこれを呼ぶ。処理は app/internal/handler/account.go の
DeleteAccount:
- DB(1 トランザクション、sub 単位の
pg_advisory_xact_lockで/account/linkと直列化): deleted_accountsに sub の墓標を残す。認証は JWT の署名と期限しか見ないので、 Cognito のユーザーを消しても発行済み ID token は期限まで有効に見える。その間の/account/linkは墓標を見て401 ACCOUNT_DELETEDで拒否するaccounts行 → それに束ねられた全users行(primary 含む)の順で削除。user_answers/user_app_settingsはusersのON DELETE CASCADEで消える- 各端末の
identity_idに紐づくtransfer_tokens(FK 無し・有効期限 3 年)も削除 - Cognito User Pool のユーザーを
AdminDeleteUser(ID token のcognito:usernameで。 sub からのListUsers検索は結果整合で取りこぼすので使わない)
DB を先に消すのは、Cognito を先に消して DB が失敗すると再ログインできず孤児が残るため。
逆順の途中失敗(DB 消えて Cognito 残り)は、再ログインしてもう一度削除すれば Cognito 側だけ
消えて収束する(冪等)。端末側は 204 を受けたらトークンと匿名 identity を破棄する。
deleted_accounts は sub(不透明な UUID)・削除日時・Cognito 側の削除確認日時を持つ。
Cognito 削除が確認できた行だけ、確認から 7 日(ID token 有効期間 1 時間に余裕)で消える。
掃除は DeleteAccount のたびと、backup-db-prod.yml(毎日、ダンプの前)の
「Purge expired account tombstones」ステップで行う。日次なので実際の上限は 7 日 + 1 日 = 最長 8 日
(プライバシーポリシーもこの表現)。ステップは migration 前なら to_regclass で no-op、
それ以外の失敗はジョブ失敗(→ Slack)にする。
確認できていない行(DB は消えたが Cognito の削除に失敗した状態)は消さない。その状態では
ユーザーが再ログインできてしまうので、墓標が link を拒否し続ける必要がある。ユーザーが再実行
すれば Cognito 側が消えて確認日時が付く。Cognito 削除は成功したのに確認日時の書き込みだけ失敗した
場合は 3 回リトライのうえ 500 を返し(ERROR ログ → Slack)、クライアントの再実行で確認日時が付く。
未確認のまま残っている墓標は次で一覧できる(Cognito に本当に残っているかは list-users --filter 'sub = "…"' で確認し、
残っていなければ確認日時を手で入れる):
SELECT cognito_sub, deleted_at FROM deleted_accounts
WHERE cognito_deleted_at IS NULL AND deleted_at < CURRENT_TIMESTAMP - INTERVAL '1 day';
保持の目的と期間は プライバシーポリシー に明記している。バックアップ(30 日保持)には 削除前のデータが残るため、バックアップから復元したら、復元後にバックアップ取得以降の削除を再適用する (DBバックアップとリストア 参照)。
デプロイ順序: 新しいコードは deleted_accounts と cognito-idp:AdminDeleteUser を前提にするので、
migration → Terraform apply → API deploy の順でないと、更新直後の Lambda で /account/link が
500、DELETE /account が AccessDenied になる。dev は deploy-api-dev.yml が migrate → 同一コミットの
Apply Terraform Dev 待ち → Lambda 更新の順で動く。prod は手動なので、
Run Database Migration (Prod) → Apply Terraform Prod → Deploy API Prod(または Deploy All Prod)の
順で実行する。
メールで削除依頼が来た場合(ログインできない等)は手動で同じことをする:
export AWS_PROFILE=<prod のプロファイル>
POOL=ap-northeast-1_d8LkqgsJU # prod の User Pool(dev は ap-northeast-1_DvsZzCoJw)
# 1) メールアドレスから sub と Username を引く
aws cognito-idp list-users --user-pool-id $POOL --filter 'email = "user@example.com"' \
--query 'Users[].{Username:Username,sub:Attributes[?Name==`sub`].Value|[0]}'
# 2) DB(SSM の database-url で接続)。API の DeleteAccount と同じ順序・同じロックで消す。
# <sub> を 1 か所置き換えるだけでそのまま実行できる(削除対象は一時テーブルに保持)。
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -v sub="'<sub>'" <<'SQL'
BEGIN;
-- API と同じアドバイザリロック(進行中の /account/link と直列化)
SELECT pg_advisory_xact_lock(hashtext(:sub));
INSERT INTO deleted_accounts (cognito_sub) VALUES (:sub)
ON CONFLICT (cognito_sub) DO UPDATE SET deleted_at = CURRENT_TIMESTAMP, cognito_deleted_at = NULL;
-- 束ねられた全端末(primary 含む)
CREATE TEMP TABLE doomed ON COMMIT DROP AS
SELECT u.id, u.identity_id FROM users u
JOIN accounts a ON a.id = u.account_id
WHERE a.cognito_sub = :sub;
SELECT * FROM doomed; -- 確認用
-- accounts.primary_user_id が RESTRICT なので accounts が先
DELETE FROM accounts WHERE cognito_sub = :sub;
DELETE FROM transfer_tokens WHERE identity_id IN (SELECT identity_id FROM doomed);
DELETE FROM users WHERE id IN (SELECT id FROM doomed); -- user_answers / user_app_settings は CASCADE
COMMIT;
SQL
# 3) Cognito のユーザーを消す
aws cognito-idp admin-delete-user --user-pool-id $POOL --username '<Username>'
# 4) Cognito 側の削除を確認したので、墓標に確認日時を付ける(これで 7 日後に自動で消える)
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -v sub="'<sub>'" \
-c "UPDATE deleted_accounts SET cognito_deleted_at = CURRENT_TIMESTAMP WHERE cognito_sub = :sub"
依頼者本人であることは、登録メールアドレスからの依頼であることで確認する(そのメール宛に 確認の返信をしてから実行する)。
git fetch --tags
git tag -l 'portal-prod/*' --sort=-refname | head -3
# そのタグ以降に portal/ が変わっていれば、prod は古い
git log --oneline "$(git tag -l 'portal-prod/*' --sort=-refname | head -1)"..main -- portal/
prod が 2 週間更新されていないことに気づけなかった実例がある(#283 の修正が ポータルだけ取り残された)。iOS や API を出したら、ポータルも要るか確認すること。
学習用 Web(it / chemistry)¶
web/ は 1 つのコードベースを NEXT_PUBLIC_SITE で切り替えてビルドしている。
そのため it と chemistry は同じデプロイで一緒に出す(片方だけ古いと、どちらが最新か分からなくなる)。
- dev: main へのマージで
web/に変更があれば自動デプロイ(Deploy Web Dev) - prod: 自動では出さない。手動起動 +
productionenvironment の承認が必要(Terraform のApply Terraform Prodと同じ)
注意: 2 サイトは同じ実行内の独立した matrix job で、
fail-fast: false。 「一緒に起動する」だけで原子的ではない。片方が失敗すると新旧が分かれた状態で残るので、 失敗した側は必ず再実行して揃えること(実行サマリで両 job の結果を確認する)。
# dev(手動で出したいとき)
gh workflow run "Deploy Web Dev" --repo takoikatakotako/rikako --ref main
# prod(起動後、GitHub 上で承認するまで待機する)
gh workflow run "Deploy Web Prod" --repo takoikatakotako/rikako --ref main
公開コンテンツを変えたときは、
/publish(DB → S3)と web の再デプロイの両方が必要。 web はビルド時にコンテンツを焼き込むため、/publishだけでは表示が変わらない。
2. ロールバック手順¶
Lambda API(公開・管理共通)¶
ECRのイメージタグ dev は上書き可能。直前のイメージに戻すには:
# 直近のイメージダイジェストを確認
aws ecr describe-images \
--registry-id 579039992557 \
--repository-name rikako-api \
--query 'imageDetails | sort_by(@, &imagePushedAt) | [-5:].[imagePushedAt,imageDigest]' \
--output table
# 特定のダイジェストに戻す(公開API)
aws lambda update-function-code \
--function-name rikako-api-development \
--image-uri 579039992557.dkr.ecr.ap-northeast-1.amazonaws.com/rikako-api@sha256:<digest>
# 管理APIも同様
aws lambda update-function-code \
--function-name rikako-admin-api-development \
--image-uri 579039992557.dkr.ecr.ap-northeast-1.amazonaws.com/rikako-admin-api@sha256:<digest>
注意: ECRのライフサイクルポリシーにより最新5イメージのみ保持。古いイメージは自動削除される。
管理画面フロントエンド¶
gitで前のコミットに戻してデプロイワークフローを再実行する。
# 直前のコミットでデプロイ
gh workflow run "Deploy Admin Frontend Dev" --repo takoikatakotako/rikako --ref <commit-sha>
旧チャンクは即削除する(#342)¶
web / portal / admin の dev・prod(6 ワークフロー)は、out/ 全体を 1 本の sync で S3 に同期する。
aws s3 sync out/ s3://<bucket>/ --delete
--delete により stale な HTML も、旧ビルドのハッシュ付きチャンク(_next/static/)も
その場で消える。デプロイの瞬間に開いていた画面が次の遷移で旧チャンクを読みに来ると
失敗するが、
- Next.js の app router はチャンクの読み込み失敗時にフルリロードへフォールバックする (エラー画面にはならず、1 回だけ通常のページ読み込みになる)
- 解答の進捗はサーバー保存で、端末に持つのは認証と deviceId だけ。失うのは「いま選んでいた 1 問」まで
- web のデプロイは月に数回・トラフィックは小さく、この重なりは実質起きない
ため実害は小さい。一時期(2026-08-28〜09-20)は sync を 2 本に分けて旧チャンクを残していたが、
回収する仕組みが無く S3 に無限に溜まる方が問題だったため即削除に戻した。
scripts/check-frontend-env.py が 6 本ともこの形(1 本・--delete あり・--exclude なし)で
あることを検査する。
Cache-Control は CloudFront で付ける(#336)¶
web / portal / admin の Cache-Control は S3 オブジェクトのメタデータではなく、
CloudFront の ResponseHeadersPolicy で付与する(terraform/environments/*/frontend_cache.tf)。
/_next/static/*のordered_cache_behavior→public, max-age=31536000, immutable- それ以外(HTML・
public/のハッシュ無しアセット)→public, max-age=0, must-revalidate
aws s3 sync は「ローカルの方が新しい / サイズが違う」ファイルしか転送しないため、
--cache-control で付ける方式は対象が少しでも重なると 2 本目がスキップされて値が付かない
(#336 の実害)。配信ヘッダを配信基盤の責務にすることで、この取りこぼしを構造的に無くす。
policy は override = true なので、S3 に残っている古いメタデータも上書きされる。
既存オブジェクトを貼り直す必要はない。
ordered_cache_behavior は default_cache_behavior から function_association も
response_headers_policy_id も継承しない。新しいビヘイビアを足すときは viewer-request
関数を必ず付け直すこと(付け忘れると dev の Basic Auth を迂回できてしまう)。
アカウントポータル(account.rikako.org)¶
web と同じ方式。portal-prod/* タグが付いた commit にだけ戻せる。
git tag -l 'portal-prod/*' --sort=-refname | head -5
gh workflow run "Deploy Portal Prod" --repo takoikatakotako/rikako --ref main \
-f checkout_ref=<タグが指す commit>
学習用 Web(it / chemistry)¶
--ref には branch / tag しか渡せず、また古い commit にはワークフロー定義自体が
存在しないことがある。そのため ワークフローは main から起動し、checkout_ref で
ビルド対象の commit だけを戻す。
checkout_ref の条件は次の 3 つ(すべて npm ci の前に検証して弾く)。
- 40 桁の commit SHA(branch 名を渡して未マージのコードを本番権限のジョブで実行させないため)
- main の履歴上にある
- prod のみ:
web-prod/*タグが付いている = 実際に本番へ出した実績がある
3 があるのは、main 履歴上でも未デプロイの commit は動作実績が無いため。障害対応で 「戻す」つもりが未検証のコードを本番へ出す、という事故を防ぐ。
タグは Deploy Web Prod が 両サイトとも成功したときに自動で打たれる(GitHub Release
も作られる)。片方だけ成功した実行では打たれない。
# ロールバック候補(新しい順)
git fetch --tags
git tag -l 'web-prod/*' --sort=-refname | head -5
# タグが指す commit を確認
git rev-list -n 1 web-prod/20260827-120000
gh workflow run "Deploy Web Prod" --repo takoikatakotako/rikako --ref main \
-f checkout_ref=<commit-sha>
# dev も同様
gh workflow run "Deploy Web Dev" --repo takoikatakotako/rikako --ref main \
-f checkout_ref=<commit-sha>
実際にどの commit が出たかは実行サマリの Ref に表示される。
2.5 シークレット管理(SSM Parameter Store)¶
Lambda が読むシークレット(OPENAI_API_KEY / SLACK_WEBHOOK_URL / DATABASE_URL 等)は、Lambda 環境変数には ssm:/path 形式の参照のみを保存し、起動時に app/internal/secrets.Resolve または slack_notifier の _resolve_ssm が実値を取得する仕組み(Issue #199)。
管理対象パラメータ¶
| パス | 内容 | 登録方法 |
|---|---|---|
/rikako/<env>/openai-api-key |
OpenAI API キー | 名前だけ Terraform(ssm.tf)。値は手動 put-parameter |
/rikako/<env>/slack-contact-webhook-url |
お問い合わせ通知用 Slack Webhook | 同上 |
/rikako/<env>/slack-alert-webhook-url |
CloudWatch アラート用 Slack Webhook | 同上 |
/rikako/<env>/firebase/ios/<app_slug> |
Firebase の GoogleService-Info.plist(iOS アプリごと) |
同上。値は scripts/firebase-config.sh push |
/rikako/<env>/firebase/android |
Firebase の google-services.json(プロジェクト単位) |
同上 |
/rikako/admin-basic-auth-user / -password |
管理画面・dev 各サイトの Basic 認証(環境プレフィックス無し) | 同上。CloudFront Function に埋め込むため、変更後は terraform apply が必要 |
/rikako/<env>/database-url |
Neon 接続文字列 | Terraform が neon_project.default.connection_uri を初期値として SecureString 登録。lifecycle.ignore_changes = [value] 指定のため以後の値はローテで上書き可(ローテ手順参照) |
/rikako/neon-api-key |
Neon API キー(Terraform Provider 用) | 手動 put-parameter(Provider 初期化に使うため Terraform 管理外) |
/rikako/cloudflare-api-token |
Cloudflare API トークン(Terraform Provider 用) | 同上 |
「名前だけ Terraform」は terraform/environments/<env>/ssm.tf の aws_ssm_parameter に lifecycle { ignore_changes = [value] } を付けたもの(#394)。存在すべきパラメータの一覧を IaC で持ちつつ、値は構成(tfvars 含む)には書かず、Terraform が上書きもしない。ただし import / refresh で読んだ復号済みの値は remote state に入る(ignore_changes は差分を apply 対象から外すだけで、state への格納は止めない)。state は S3 で暗号化・アクセス制限済みだが、database-url と同じく「シークレットを含む」前提で扱う。参照側(Lambda 環境変数の ssm:...、IAM の resource ARN)は文字列直書きではなく ssm.tf の resource / locals を参照する。
新しいパラメータを足すとき:
ssm.tfに resource を追加 → 先にaws ssm put-parameterで実値を登録 →ssm_imports.tfに import ブロックを追加して apply、の順。resource を書いて apply だけすると placeholder(CHANGE_ME)で作られてしまう。逆に既存パラメータを import 無しで apply すると "already exists" で失敗する。
初回登録¶
# 例: dev に OpenAI API key を登録
AWS_PROFILE=rikako-development-sso aws ssm put-parameter \
--name /rikako/development/openai-api-key \
--value 'sk-...' \
--type SecureString \
--region ap-northeast-1
ローテーション¶
SSM 値を put-parameter --overwrite で更新すれば、次回 Lambda cold start で新しい値が反映される(Lambda 関数の再デプロイ不要)。即時反映が必要な場合は Lambda コンソールから「最新バージョンを発行」または update-function-code で warm container を破棄する。
AWS_PROFILE=rikako-development-sso aws ssm put-parameter \
--name /rikako/development/openai-api-key \
--value 'sk-new-value...' \
--type SecureString \
--overwrite \
--region ap-northeast-1
確認¶
aws lambda get-function-configuration --query 'Environment.Variables' で シークレット実値が出ず ssm:/... リテラルだけが返ることを確認する。実値が露出していたら漏洩リスクがあるので即対応。
Neon DB パスワードのローテーション {#neon-db}¶
/rikako/<env>/database-url の Neon パスワードをローテする手順。
重要な前提 - Neon の role は DB を所有しているため、
terraform apply -replace=neon_role.defaultでの drop/recreate は HTTP 422ROLE_OWNS_OBJECTSで失敗する。ローテは必ず Neon API のreset_password(role を残しパスワードのみ再生成)で行う。 - SSMdatabase-urlはlifecycle.ignore_changes = [value]指定のため、put-parameter --overwriteした値を terraform が巻き戻さない。 - 環境ごとにアプリが使う role/DB が異なるので注意: - dev: rolerikako_owner/ DBrikako- prod: roleneondb_owner/ DBneondb(Neon デフォルト) - リセット直後から旧パスワードは無効になり、warm Lambda は cold start まで DB 接続に失敗する。
# 例: dev(prod の場合は PROFILE/PROJECT/BRANCH/ROLE/DB/SSM 名を読み替え)
export AWS_PROFILE=rikako-development-sso
API_KEY=$(aws ssm get-parameter --name /rikako/neon-api-key --with-decryption --query Parameter.Value --output text)
PROJECT_ID=muddy-tree-64549662
BRANCH_ID=br-calm-rice-a1123e1l
ROLE=rikako_owner
DB=rikako
HOST=$(cd terraform/environments/dev && terraform state show neon_project.default | grep 'database_host ' | sed -E 's/.*= "(.*)"/\1/')
# 1. パスワードを reset(新パスワードを取得、出力しない)
NEWPASS=$(curl -s -X POST \
"https://console.neon.tech/api/v2/projects/${PROJECT_ID}/branches/${BRANCH_ID}/roles/${ROLE}/reset_password" \
-H "Authorization: Bearer ${API_KEY}" -H "Accept: application/json" | jq -r '.role.password')
# 2. SSM を上書き
aws ssm put-parameter --name /rikako/development/database-url --type SecureString --overwrite \
--value "postgres://${ROLE}:${NEWPASS}@${HOST}/${DB}?sslmode=require"
# 3. Lambda を cold start(warm container に旧パスが残るため)
TS=$(date +%s)
for fn in rikako-api-development rikako-admin-api-development; do
aws lambda update-function-configuration --function-name "$fn" --description "db rotation $TS" >/dev/null
aws lambda wait function-updated --function-name "$fn"
done
# 4. 確認(DB を叩くエンドポイントが 200 を返すこと)
curl -s -o /dev/null -w "%{http_code}\n" https://api.dev.rikako.org/workbooks
prod は次のように読み替える: AWS_PROFILE=rikako-production-sso、PROJECT_ID は cd terraform/environments/prod && terraform state show neon_project.default の id、BRANCH_ID は同 default_branch_id、ROLE=neondb_owner、DB=neondb、SSM 名 /rikako/production/database-url、関数は rikako-api-production / rikako-admin-api-production。
Neon 接続プーリング(pooled endpoint){#neon-pooling}¶
公開API / 管理API の Lambda だけが Neon の pooled endpoint(PgBouncer, transaction pooling)を使う。 datasync とマイグレーションは direct 接続(Issue #281 / #288)。
SSM に入っている値は direct ホストで、pooled への切替は環境変数 DB_USE_POOLER=true を見て
dbconn.Pooled がホスト名に -pooler を付ける。この変換を呼ぶのは cmd/server と cmd/admin だけ。
| 用途 | エンドポイント | 接続元 |
|---|---|---|
| 公開API / 管理API Lambda | pooled | SSM /rikako/<env>/database-url + DB_USE_POOLER=true |
| datasync | direct | SSM(dbconn.Pooled を呼ばないため変換されない) |
| マイグレーション(golang-migrate) | direct | terraform output -raw connection_string |
マイグレーションは今後も direct を維持すること。 golang-migrate の advisory lock は セッション単位で、transaction pooling では正しく機能しない。
migrate-dev.yml/migrate-prod.ymlは SSM ではなく terraform のconnection_stringを使っているため、 SSM を pooled にしても影響しない。
transaction pooling と互換な理由¶
transaction pooling ではセッションに依存する機能が使えないが、本アプリは該当機能を使っていない。
- sqlc は
emit_prepared_queries: false。生成コードはQueryContext/ExecContextの直呼びで、永続 prepared statement を持たない - さらに pgx を simple protocol で駆動している(
dbconn.SimpleProtocol、#291 / #292) - advisory lock / 一時テーブル /
LISTEN・NOTIFY/SET SESSIONはアプリ側で未使用 SetMaxOpenConns(10)は pooled でも妥当。PgBouncer が多重化するため Neon の直接接続数は増えない
rollback¶
pooled で問題が出たら direct に戻して Lambda を cold start する(warm container の旧接続を破棄するため)。
export AWS_PROFILE=<prod のプロファイル> # docs/aws-setup.md 参照
# terraform で DB_USE_POOLER を "false" にして apply する。
# SSM の値はもともと direct host なので、SSM を書き換えても direct には戻せない
# (DB_USE_POOLER=true のままだと dbconn.Pooled が再び -pooler を付けるため)。
TS=$(date +%s)
for fn in rikako-api-production rikako-admin-api-production; do
aws lambda update-function-configuration --function-name "$fn" \
--description "pooler rollback $TS" >/dev/null
aws lambda wait function-updated --function-name "$fn"
done
curl -s -o /dev/null -w "%{http_code}\n" https://api.rikako.org/workbooks # 200
DSN をログ・標準出力に出さないこと。 切替の前後で Neon コンソールの接続数とエラーを比較する。
管理画面 Basic 認証(CloudFront Function){#admin-basic-auth}¶
管理画面(admin.<env>.rikako.org / フロント・/api 共通)の Basic 認証は SSM の /rikako/admin-basic-auth-user / /rikako/admin-basic-auth-password を使う。
重要: Lambda シークレットと反映タイミングが違う Lambda のシークレット(2.5)は起動時に SSM を読むためローテだけで反映される。Basic 認証は違う。
terraform/environments/<env>/admin_frontend.tfがterraform apply時に SSM 値をbase64(user:password)して CloudFront Function(rikako-admin-spa-rewrite-<env>/rikako-admin-api-auth-rewrite-<env>)のコードに焼き込む。ランタイムでは SSM を見ない。 - したがって SSM をput-parameter --overwriteしただけでは 401 のまま。ローテ時は必ず対象 env でterraform apply(=関数の再デプロイ)まで行う。 - prod は自動 apply が無い(8. Terraform 操作)。SSM 更新後に prod apply を忘れると「SSM の値で 401」になる。逆に、後から誰かが prod apply すると現在の SSM 値に同期されて解消する。
疎通確認(シークレットを出力しない)¶
curl -u の資格情報は SSM から変数に入れて渡し、標準出力にはステータスコードだけ出す。
export AWS_PROFILE=rikako-production-sso # dev は rikako-development-sso
BASE=https://admin.rikako.org # dev は https://admin.dev.rikako.org
U=$(aws ssm get-parameter --name /rikako/admin-basic-auth-user --with-decryption --query Parameter.Value --output text)
P=$(aws ssm get-parameter --name /rikako/admin-basic-auth-password --with-decryption --query Parameter.Value --output text)
# 認証なし → 401(関数が機能している証拠)/ 認証あり → 200 を期待
echo "no-auth /api/health -> $(curl -s -o /dev/null -w '%{http_code}' $BASE/api/health)"
echo "auth /api/health -> $(curl -s -o /dev/null -w '%{http_code}' -u "$U:$P" $BASE/api/health)"
echo "auth /api/users -> $(curl -s -o /dev/null -w '%{http_code}' -u "$U:$P" $BASE/api/users)"
echo "auth / -> $(curl -s -o /dev/null -w '%{http_code}' -u "$U:$P" $BASE/)"
ドリフト確認(SSM値 と デプロイ済み関数 の照合)¶
401 が出るときに「SSM 更新後の未 apply」かを切り分ける。値そのものは出さず、SHA-256 の先頭だけ比較する。
export AWS_PROFILE=rikako-production-sso
U=$(aws ssm get-parameter --name /rikako/admin-basic-auth-user --with-decryption --query Parameter.Value --output text)
P=$(aws ssm get-parameter --name /rikako/admin-basic-auth-password --with-decryption --query Parameter.Value --output text)
EXPECTED=$(printf '%s:%s' "$U" "$P" | base64)
echo "expected sha256: $(printf '%s' "$EXPECTED" | shasum -a 256 | cut -c1-12)"
for FN in rikako-admin-spa-rewrite-production rikako-admin-api-auth-rewrite-production; do
CODE=$(aws cloudfront get-function --name "$FN" --stage LIVE /dev/stdout 2>/dev/null)
DEP=$(printf '%s' "$CODE" | grep -oE "var CREDENTIALS = '[^']*'" | sed "s/var CREDENTIALS = '//; s/'\$//")
[ "$DEP" = "$EXPECTED" ] && R="MATCH" || R="MISMATCH → 対象 env で terraform apply が必要"
echo "$FN: deployed sha256 $(printf '%s' "$DEP" | shasum -a 256 | cut -c1-12) → $R"
done
MISMATCH なら cd terraform/environments/prod && terraform plan(差分が上記 2 関数の CREDENTIALS だけであることを確認)→ terraform apply。apply 中に資格情報が切り替わるため、旧資格情報で開いていた管理画面は再ログインが必要。
3. マイグレーション実行手順¶
GitHub Actions のワークフローで実行する。dev / prod で別ワークフロー(環境を 引数で選ぶ形にすると、prod のつもりで dev、あるいはその逆を踏みやすいため)。 どちらも自動実行はされず、手動 dispatch のみ。
# Dev
gh workflow run "Run Database Migration (Dev)" \
--repo takoikatakotako/rikako \
-f direction=up
# Prod(起動後、"Review deployments" で承認するまで waiting のまま止まる)
gh workflow run "Run Database Migration (Prod)" \
--repo takoikatakotako/rikako \
-f direction=up
新規マイグレーションは dev のデプロイ後に dev へ適用する運用。
パラメータ¶
| パラメータ | 説明 |
|---|---|
| direction | up(適用)または down(ロールバック) |
| steps | ステップ数。空なら全て |
マイグレーションロールバック¶
# 1ステップ戻す
gh workflow run "Run Database Migration (Dev)" \
--repo takoikatakotako/rikako \
-f direction=down \
-f steps=1
ローカルでのマイグレーション¶
docker compose up -d postgres
docker run --rm -v $(pwd)/migrations:/migrations \
migrate/migrate -path=/migrations \
-database "postgres://rikako:password@host.docker.internal:5432/rikako?sslmode=disable" up
4. データインポート・同期手順¶
datasync(推奨)¶
YAMLデータを正としてDBと差分同期する。
cd app
# ローカルDB
go run ./cmd/datasync -data ../data plan # 差分確認
go run ./cmd/datasync -data ../data apply # 反映
# dev環境
go run ./cmd/datasync -data ../data -env dev plan
go run ./cmd/datasync -data ../data -env dev apply
詳細は データ同期 (datasync) を参照。
画像のS3アップロード¶
# アップロード
aws s3 sync data/images/ s3://rikako-images-development/ --exclude ".DS_Store"
# 確認
aws s3 ls s3://rikako-images-development/ | wc -l
importer(全件再投入)¶
全データを削除して再投入する場合に使用。
cd app && go run ./cmd/importer -data ../data
5. 障害対応フロー¶
Lambda障害¶
症状: APIがエラーを返す、タイムアウトする
# 1. ヘルスチェック(prod は api.rikako.org に置き換え)
curl -s https://api.dev.rikako.org/health
# 2. CloudWatchダッシュボードを確認
# https://ap-northeast-1.console.aws.amazon.com/cloudwatch/home?region=ap-northeast-1#dashboards/dashboard/rikako-dev
# 3. Lambda最新ログを確認
aws logs tail /aws/lambda/rikako-api-development --since 30m
# 4. エラーのみ抽出
aws logs filter-log-events \
--log-group-name /aws/lambda/rikako-api-development \
--start-time $(date -v-1H +%s000) \
--filter-pattern "ERROR"
# 5. 必要に応じてロールバック(→ 2. ロールバック手順)
DB障害(Neon)¶
症状: API が 500 エラー、connection refused
# 1. Neonダッシュボードを確認
# https://console.neon.tech/
# 2. 接続テスト
go run ./cmd/datasync -data ../data -env dev plan
# 3. Neonのステータスページを確認
# https://neonstatus.com/
Neonが応答しない場合: - Neonのステータスページでリージョン障害を確認 - 障害が長引く場合はNeonサポートに連絡
画像配信障害¶
症状: 画像が表示されない、403/404エラー
# 1. CloudFront経由でアクセス確認(prod は image.rikako.org に置き換え)
curl -I https://image.dev.rikako.org/1.png
# 2. S3直接確認
aws s3 ls s3://rikako-images-development/1.png
# 3. CloudFrontキャッシュが古い場合は無効化
aws cloudfront create-invalidation \
--distribution-id E1LVBAGQ7YS8CR \
--paths "/*"
6. ログ調査手順¶
CloudWatch Logs¶
# 直近30分のログ(公開API)
aws logs tail /aws/lambda/rikako-api-development --since 30m --follow
# 直近30分のログ(管理API)
aws logs tail /aws/lambda/rikako-admin-api-development --since 30m --follow
# 特定パターンで検索
aws logs filter-log-events \
--log-group-name /aws/lambda/rikako-api-development \
--start-time $(date -v-1H +%s000) \
--filter-pattern "ERROR"
# 特定リクエストの調査(パスで絞り込み)
aws logs filter-log-events \
--log-group-name /aws/lambda/rikako-api-development \
--start-time $(date -v-1H +%s000) \
--filter-pattern "/questions"
公開API アクセスログ(API Gateway)¶
公開API(api.<env>.rikako.org = API Gateway HTTP API)のアクセスログは以下の Log Group に JSON で出力される(保持: dev 7日 / prod 30日)。
| env | Log Group |
|---|---|
| dev | /aws/vendedlogs/apigateway/rikako-api-development |
| prod | /aws/vendedlogs/apigateway/rikako-api-production |
記録項目は requestId / sourceIp / httpMethod / routeKey / path / status / responseLatency / integrationStatus / integrationErrorMessage / userAgent。Authorization・X-Device-ID・リクエスト本文は記録しない(機微情報を保存しないため。source IP は bot 識別のため既定で記録、不要なら access_log_include_source_ip = false で無効化可)。
4xx/5xx をステータス・パス別に集計(Logs Insights)¶
# prod は rikako-api-production / rikako-production-sso に読み替え
export AWS_PROFILE=rikako-development-sso
LOG_GROUP=/aws/vendedlogs/apigateway/rikako-api-development
QID=$(aws logs start-query \
--log-group-name "$LOG_GROUP" \
--start-time $(date -v-1d +%s) --end-time $(date +%s) \
--query-string 'filter status >= 400 | stats count(*) as cnt by status, path, userAgent | sort cnt desc | limit 50' \
--query queryId --output text)
sleep 5
aws logs get-query-results --query-id "$QID" --output table
主なクエリ例(--query-string に指定):
- ステータス別の全体内訳:
stats count(*) as cnt by status | sort cnt desc - 4xx をパス別に:
filter status >= 400 and status < 500 | stats count(*) as cnt by path, status | sort cnt desc - bot 由来の切り分け:
filter status >= 400 | stats count(*) as cnt by userAgent, path | sort cnt desc - 特定パスのレイテンシ:
filter path like /workbooks/ | stats avg(responseLatency), max(responseLatency) by path
Logs Insights はブラウザからも実行できる(CloudWatch → Logs Insights → 上記 Log Group を選択)。
CloudWatch Dashboard¶
ブラウザで確認:
https://ap-northeast-1.console.aws.amazon.com/cloudwatch/home?region=ap-northeast-1#dashboards/dashboard/rikako-dev
確認できるメトリクス: - Invocations(呼び出し回数) - Duration(平均・p99レイテンシ) - Errors(エラー数) - Throttles(スロットリング数) - ConcurrentExecutions(同時実行数)
注意: ログ保持期間は7日間。それ以前のログは自動削除される。
7. スケーリング手順¶
Neon CU(コンピューティングユニット)変更¶
現在: dev は 0.25〜2 CU、prod は 0.25〜4 CU(auto-scaling)。
auto-suspend は 有効(既定の 5 分)。suspend_timeout_seconds = 0 は
「プラン既定値を使う」の意味で、常時起動ではない(常時起動は -1)。
Free プランではこの値を変更できず、指定して apply すると
412 modifying the suspend interval is not permitted on this account で失敗する。
cd terraform/environments/dev # prod は environments/prod
# terraform で変更(neon.tf の default_endpoint_settings を編集)
terraform plan
terraform apply
neon.tf の該当箇所(neon_project の default_endpoint_settings ブロック):
resource "neon_project" "default" {
# ...
default_endpoint_settings {
autoscaling_limit_min_cu = 0.25 # 最小CU
autoscaling_limit_max_cu = 2 # 最大CU(prod は 4)
suspend_timeout_seconds = 0 # プラン既定値を使う(常時起動は -1。Free では変更不可)
}
}
Lambda設定変更¶
メモリ・タイムアウトの変更は terraform/environments/dev/main.tf を編集:
module "lambda" {
memory_size = 512 # MB(現在値)
timeout = 30 # 秒(現在値)
}
cd terraform/environments/dev
terraform plan
terraform apply
Lambda同時実行数の制限¶
現在は制限なし(AWSアカウントデフォルト: 1000)。制限が必要な場合:
# 一時的に制限(Terraform外)
aws lambda put-function-concurrency \
--function-name rikako-api-development \
--reserved-concurrent-executions 100
# 制限解除
aws lambda delete-function-concurrency \
--function-name rikako-api-development
8. Terraform操作¶
# Plan/Apply(dev)
cd terraform/environments/dev
terraform plan
terraform apply
Shared(ECR)は別リポジトリ
aws-iac(terraform/accounts/shared)で管理。このリポジトリでは扱わない。注意: PRで
terraform/配下を変更するとGitHub Actionsで自動的にplanが実行され、結果がPRにコメントされる。
9. よくある操作¶
新しい問題を追加する¶
data/questions/{id}.ymlを作成- 必要なら
data/images/{id}.pngを追加 data/workbooks/*.ymlのquestionsに追加datasync planで確認 →datasync applyで反映- 画像があれば
aws s3 syncでS3にアップロード
アプリ別に強制アップデートを設定する(minimumVersion)¶
GET /status の minimumVersion / latestVersion は app_slug 別に上書きできる。iOS は全リクエストで X-App-Slug を送り、公開API Lambda は MINIMUM_VERSION_<SLUG> env があればそれを返す(無ければグローバル既定 MINIMUM_VERSION)。これにより アプリ単位で独立に強制アップデートを制御できる(例: 化学版だけ強制更新し、IT版は影響を受けない)。
- env 名は slug のハイフンを大文字+
_に正規化:high-school-chemistry→MINIMUM_VERSION_HIGH_SCHOOL_CHEMISTRY、it-passport→MINIMUM_VERSION_IT_PASSPORT - 設定は Terraform の Lambda 環境変数(
terraform/environments/<env>/main.tfの公開APIenvironment_variables)。グローバルのMINIMUM_VERSIONを上げると全アプリに効くため、片方だけ上げたいときは必ず slug 別 env を使う。
# 例: 化学版だけ 3.0.2 以上を必須にする(IT版は据え置き)
environment_variables = {
MINIMUM_VERSION = "1.0.0" # 既定(未指定アプリ用)
MINIMUM_VERSION_HIGH_SCHOOL_CHEMISTRY = "3.0.2"
# MINIMUM_VERSION_IT_PASSPORT は未設定 → 既定 1.0.0
}
Cognito ユーザー作成¶
aws cognito-idp admin-create-user \
--user-pool-id ap-northeast-1_DvsZzCoJw \
--username <email> \
--user-attributes Name=email,Value=<email>
CloudFrontキャッシュクリア¶
# 画像CDN
aws cloudfront create-invalidation \
--distribution-id E1LVBAGQ7YS8CR \
--paths "/*"
# 管理画面
aws cloudfront create-invalidation \
--distribution-id EIA13UUJ41NKO \
--paths "/*"