「ローカルもCIも green なのに本番だけ壊れる」を全部潰したら、テンプレートに"第4のゲート"と自己検証ループが生えた話
はじめに
以前、個人開発のモノレポを、プラットフォームエンジニアリング観点でスコアリングしながら育てている話 という記事で、FastAPI + Vue 3 + Terraform の "golden path" テンプレートを 10 軸のスコアカードで評価しながら育てている、という話を書きました。
その記事の時点でのスコアは、骨組み CRUD(items の参照系が中心)に対する評価でした。認証・可観測性・カバレッジゲートは一通り揃っていましたが、「複数エンティティが絡む本物の業務機能を、ドキュメント化された手順だけで作って本番相当環境まで持っていけるか」は一度も通しで検証していませんでした。
そこで立てたのが Epic #364:「受注管理」という業務ドメイン機能を実際にテンプレートの上に実装し、開発〜デプロイ〜実ブラウザでの動作確認まで一周させる検証です。
結果から言うと、実装自体はほぼ手順どおりに進みました。壊れたのはコードではなく、「デプロイされた環境」でした。 ローカル開発でも CI でも絶対に踏めない種類のバグが 4 つ見つかり、それを潰していく過程で、最終的にテンプレートは
- 実ブラウザで本番相当の環境を叩く**「第4のゲート」**
- テンプレート自体が「今 fork したらゼロから動くか」を検証する自己検証ループ
という 2 つの新しい仕組みを獲得しました。この記事はその一部始終の記録です。
このリポジトリは個人開発で、実装・デバッグ・issue の記録は Claude Code(AI コーディングエージェント)と協働しています。人間が判断するのは「採用/不採用」「マージするか」という意思決定で、実装・原因調査・ドキュメント反映は基本的にエージェントに任せる運用です。
Epic #364: 骨組み CRUD を超えられるか
検証の建て付けはこうでした。
| # | 検証項目 | 判定基準 |
|---|---|---|
| 1 | 業務ドメイン機能の追加(複数エンティティ + 関連 + トランザクション) | 既存の CLAUDE.md / docs/ の手順から逸脱せず実装完了 |
| 2 | DB スキーマ変更の運用(マイグレーション) | 2 回以上のマイグレーションを sandbox 環境に適用し、ダウングレードも 1 回成功 |
| 3 | 品質ゲートの実効性 | lint / 型 / テスト / カバレッジゲートを緩和なしで全 PR green |
| 4 | 境界を跨ぐ観察: 実環境での E2E 完走 | 開発コンテナ外(実ブラウザ等)から主要シナリオ(登録→参照→更新→整合性確認)が完走 |
| 5 | 開発リードタイムの実測 | 1 サイクル(issue 起票→PR→merge→deploy 完了)の実測時間を DORA データに反映 |
判定の先には明確な分岐がありました。
-
OK → テンプレートを業務規模開発の標準として採用確定。第2チームでの実証(
#299)に進む -
NG → 詰まった箇所を改修 issue に分割し、
#299は改修が終わるまで保留
題材に選んだのは「受注-明細-在庫」。受注登録時に在庫を原子的に引き当てる処理(item_id 昇順ロックでデッドロックを回避)を含む、それなりに手強いドメインです。SDD(/kiro-* スキル)で要件定義〜設計〜13 タスクへの分解を経て実装し、sandbox/order-management ブランチで実 AWS 環境にデプロイしました。
実機で見つかった、CI では原理的に検出できない 4 つのバグ
実装自体は大きく詰まることなく終わりました。問題は「実 AWS 環境に実際にデプロイして、実ブラウザで Cognito ログインを通す」段階で起きました。
| バグ | 症状 | 原因 | 修正 |
|---|---|---|---|
#365 |
Cognito Hosted UI ログインが実環境でだけ失敗 | CloudFront の CSP connect-src 'self' が Cognito ドメインへの fetch をブロック |
#366 |
#367 |
ログイン後の authority URL が不正な形になる |
cd-app(-sandbox).yml のフロントエンドビルドが VITE_COGNITO_* を一切注入していなかった |
#368 |
#369(要因1) |
認証付きリクエストが 504 | プライベートサブネットに cognito-idp 用 VPC インターフェースエンドポイントが無く、JWKS 取得の経路が存在しない |
#371 |
#369(要因2) |
JWKS 取得後もトークン検証が失敗 | ECS タスク定義に API_COGNITO_* 環境変数が一切注入されていなかった |
#372 |
4 つとも共通しているのは、ローカル開発(Vite の dev proxy 経由)にも CI にも、「CloudFront の CSP」「実際にビルドされた Docker イメージの環境変数」「VPC 内のネットワーク経路」が一切存在しないという点です。pre-commit / Makefile / CI という 3 層の品質ゲートがどれだけ green でも、これらのクラスのバグは原理的に検出できません。
「ローカル green = CI green」は達成できていても、「デプロイ green」はまったく別の主張だった、ということです。
「第4のゲート」を定常化する(#376)
#364 のステップ 24 で「実ブラウザ E2E の定期実行を検討する価値あり」と書き残し、それを具体化したのが #376 です。
デプロイ済み環境 + 実ブラウザという実行文脈そのものを検証するゲートを、5 分以内で完走する 3 ステップに絞り込みました。
| 段 | シナリオ | 捕捉するバグクラス |
|---|---|---|
| S1 | OIDC discovery 取得 → Hosted UI ログイン → トークン取得 | CSP 設定・フロント環境変数注入 |
| S2 | 認証付き書き込み API を 1 本叩く | VPC 経路・バックエンド環境変数注入 |
| S3 | 別セッションからの整合性確認 | セッション分離・キャッシュ・ルーティングの退行 |
実装は段階的に育てました。
-
固定テストユーザーでの素朴なスクリプト(
#373): sandbox デプロイ後に headless Chromium を直接叩き、Cognito ログイン +GET /api/itemsの 2xx を確認するだけの第一歩 -
Playwright
live-smokeプロジェクトへ昇格(PR#383): trace / screenshot / video を常時有効化し、失敗時に原因を追える診断能力を持たせる -
テストユーザーの使い捨て化(PR
#384): 固定ユーザーの使い回しをやめ、AdminCreateUser/AdminSetUserPasswordで per-run に作成し、if: always()で必ず削除する -
本番
cd-app.ymlへの展開(PR#385): sandbox で検証済みのジョブをmainにも追加。失敗時は run URL・デプロイ SHA・失敗ステップを含む issue を自動起票する
デプロイの度に「本当にログインできて、本当に書き込めるか」を機械的に確認する仕組みが、これで CI/CD パイプラインの一部になりました。
本番投入時は、Cognito の Admin API を実際に叩くブロッキングゲートを無条件に有効化すると危険です。このリポジトリでは LIVE_SMOKE_ENABLED というリポジトリ変数で「デフォルト無効のオプトイン」にし、IAM 権限の反映(人力の terraform apply)と動作確認が済んでから有効化する、という安全策を取りました。
本番投入で踏んだ、もう一つの落とし穴 — OIDC の sub クレーム
第4のゲートを main に展開してすぐ、cd-app.yml の migrate ジョブが連続失敗する事象が起きました(#392)。調べると、cd-app.yml(本番)と cd-app-sandbox.yml が同名のリポジトリ変数(ECS_CLUSTER / WEB_BUCKET 等)を参照しており、本番用インフラが存在しない状態でも「設定済み」と誤判定し、sandbox 用のリソースへデプロイを試みていたことが原因でした。sandbox/order-management の Alembic マイグレーションが main の知らないリビジョンまで共有 DB を進めてしまっていたのです。
最初に試したのは GitHub Environments(environment: sandbox の宣言)でリソースをスコープ分離する方法でした。これは一見自然な解決策に見えますが、マージした直後に稼働中の sandbox デプロイを壊しました。
GitHub Actions のジョブに environment: を宣言すると、OIDC トークンの sub クレームが
repo:<org>/<repo>:ref:refs/heads/<branch>
から
repo:<org>/<repo>:environment:<name>
に変わります。IAM の信頼ポリシー(trust policy)がこの新しい組み合わせを許可していなければ、AssumeRoleWithWebIdentity がそのまま失敗します。しかも失敗するのは「ブランチと一致するかどうか」ではなく「environment を宣言したかどうか」なので、既存の sandbox 運用が何も変えていないのに巻き添えで壊れる、という分かりにくい壊れ方をします。
IAM 変更(人力の terraform apply が必要)なしで直せる方式に切り替える必要があったため、即座に revert し、cd-app-sandbox.yml 側のリソース変数だけを SANDBOX_ プレフィックス付きで登録し直す方式に変更しました(#394)。本番用インフラが存在しない間は cd-app.yml の preflight が正しく「未設定」を検知してスキップするようになり、実インフラを用意した際はプレフィックスなしの変数名でその出力を登録するだけで、ワークフロー変更なしに有効化される設計です。
テンプレート自体は今 fork したら動くのか? — golden-path-verify
#392 の根本原因を突き詰めると、もう一段深い問題に気づきました。sandbox/* は「特定の機能開発のために長期稼働する環境」として確立されていくにつれて、DB スキーマや登録済みリソース変数が育っていきます。つまり 「main(テンプレートの現在地)を今 fork したらゼロから動くか」を検証する場所が、いつのまにか無くなっていたのです。この状態のズレが #392 の障害を実際に引き起こしました。
そこで、sandbox/* とは完全に別の Terraform 環境 golden-path-verify を新設しました(PR #401)。sandbox が「特定機能の継続開発」なのに対し、golden-path-verify は実行のたびに
apply → build → migrate → deploy → smoke-test → destroy
を完走し、常にゼロの状態から検証します。environment: は例の OIDC の罠があるため、当然どちらのジョブにも宣言しません。リソース識別子も sandbox のような事前登録済み変数ではなく、Terraform の出力から workflow_call の inputs 経由で受け渡します(環境を毎回作り直すため、AWS 発行の ID が実行のたびに変わるからです)。
実際に 4 回 workflow_dispatch を回して検証し、「実際に teardown しないと分からない」不具合を 3 つ発見・修正しました。
-
environmentの値の不一致(PR#403): Terraform の validation はgolden-path-verifyのままだったが、tfvars 側は AWS リソース名の 32 文字制限を避けるためgp-verifyを使っていた -
destroy時のS3 BucketNotEmpty/ECR RepositoryNotEmptyException(PR#404):force_destroy/force_deleteが未設定で、中身が残っている S3 バケットと ECR リポジトリを Terraform が削除できなかった -
AccessDenied: s3:ListBucketVersions(PR#405): AWS provider のforce_destroy実装は、バージョニング無効のバケットに対しても常にListObjectVersionsを呼ぶ。最小権限ポリシーにこの権限が無く弾かれていた(infra/bootstrap/側の変更のため人力terraform applyが必要)
4 回目でようやくフルサイクルが完走し、terraform destroy のログにはこう出ました。
Destroy complete! Resources: 71 destroyed.
sandbox にも潜在していた同じクラスの不具合(force_destroy / IAM 権限)が、副産物として同時に直ったのも収穫でした。
まとめ
#364 を起点に見つかった問題は、突き詰めると全部同じ形をしていました。「コードは正しいが、コードだけでは判断できない場所(デプロイ環境の構成・実行時の認証情報の解決経路)」に不具合が隠れていた、という点です。
- CSP・環境変数注入・VPC 経路 → ローカルにも CI にも「デプロイされた環境」が存在しないので検出できない → 実ブラウザで叩く第4のゲートを作った
-
environment:宣言による OIDC の sub クレーム変化 → ドキュメントを読んだだけでは気づきにくい AWS 側の仕様 → 稼働中の環境を壊してから学んだ - sandbox の"育ち"によるゼロ地点の喪失 → 長期運用環境とテンプレート健全性検証は別物として切り離す必要がある → golden-path-verify という自己検証ループを作った
#364 は最終的に「採用」判定でクローズし、骨組み CRUD だけでなく複数エンティティ・トランザクションを含む業務機能でもテンプレートが機能することを確認できました。一方で、今回見つかった 3 種類のバグクラス(CSP / 必須 env var 未注入 / VPC エンドポイント欠如)は、Policy as Code(#296)の初期ポリシーとして機械的にゲート化する、という宿題も残っています。次は #376 の週次エフェメラルサイクル(golden-path-verify を土台にした定期実行)と、このテンプレートを他者に使ってもらう第2チーム実証(#299)に進む予定です。
関連 issue(プライベートリポジトリのため番号のみ記載)
-
#364検証: golden path での本格的な業務アプリケーション開発 -
#365/#367/#369実デプロイ環境限定の認証バグ 3 件 -
#373/#376実ブラウザ E2E スモークテストの自動化・第4のゲート化 -
#374/#375インフラ・起動時警告まわりの周辺改修 -
#392/#393/#394sandbox/prod リソース共有バグと OIDC sub クレームの罠 -
#296/#299/#300フォローアップ(Policy as Code・第2チーム実証・IDP 化ロードマップ)