- 対象: AWS CDK v2 (TypeScript) でインフラを管理し、GitHub でホストしているアプリケーションのリポジトリ
- コーディングエージェント (Claude Code / Codex / Devin など) をリポジトリのルートで起動し、区切り線から下を丸ごと貼り付けてください
- 事前にあるとスムーズなもの (無くてもエージェントがセットアップ手順を出力します):
- GitHub Actions から OIDC で assume できるデプロイ用 IAM ロール
- デプロイ先アカウント/リージョンで
cdk bootstrap済み
- 生成されたコードは、マージ前に必ず
cdk diffの内容とワークフローの権限設定をレビューしてください
あなたはこのリポジトリに PR ごとの使い捨てプレビュー環境 を導入します。ゴールは次の 2 つです。
- PR に
/deployとコメントすると、数分後にその PR のコードが動く専用の AWS 環境が立ち、URL が PR にコメントされる - PR が close/merge されると、その環境は自動で片付く
以下の設計原則・実装手順・既知のハマりどころに従って、調査 → 提案 → 実装 → 検証まで進めてください。
- CDK v2 (aws-cdk-lib) + TypeScript、パッケージマネージャは npm、CI は GitHub Actions
- プレビュー環境のデプロイは 5 分以内を目標にする。デプロイ時間は、プレビューに載せられるアーキテクチャを規定する最重要の設計変数。ただし絶対の制約ではなく、検証の厳密性を優先するならデプロイ時間を妥協してプレビューのカバー範囲を広げる (共有を減らして PR ごとに持つ) 判断もあってよい。どちらに倒すかは Step 0 でユーザーと合意する
- プレビュー環境は dev/staging 相当のアカウントにのみ作る (本番アカウントには作らない)
- AWS 認証は OIDC (長寿命アクセスキーをリポジトリ secrets に置かない)
スタックを 2 層に分ける。
- 共有スタック (base): 作成に時間がかかる・高価な・環境で 1 つでよいリソースを置く。main ブランチへの push で従来どおりデプロイされる定常環境 (既存のスタックをそのまま流用してよい)
- プレビュースタック (ephemeral): PR ごとに 1 つ作られ、共有スタックのリソースを参照する。アプリ層 (コンピュート・キュー・静的ホスティングなど) だけを含む
どのリソースをどちらに置くかは、作成時間で機械的に判断する。目安:
| リソース | 作成時間の目安 | 方針 |
|---|---|---|
| VPC + NAT Gateway | 2–5 分 | 共有 |
| RDS / Aurora | 10–20 分 | 共有 (クラスタごと共有。後述の「検証範囲の合意」参照) |
| OpenSearch / ElastiCache / MSK | 15–40 分 | 共有 |
| ACM 証明書 (DNS 検証) | 数分〜数十分 | 共有 (wildcard 1 枚に集約。per-PR 発行は quota も圧迫する) |
| CloudFront distribution | 3–8 分 | PR ごとでも可。初回デプロイだけ 5 分を超えることを許容するか、他リソースと並走させて吸収する |
| ALB + ECS Service | 5–10 分 | プレビューでは API Gateway + Lambda への差し替えを検討 (下記) |
| Cognito / IdP 連携 | 秒 | 共有 (callback URL 問題に注意、後述) |
| DynamoDB / SQS / SNS / S3 / EventBridge | 秒 | PR ごと |
| Lambda (zip) | 秒〜 | PR ごと |
| Lambda (コンテナ image) | build 時間次第 | PR ごと (build 高速化が必須、後述) |
重要な考え方:
- プレビューは本番と同一アーキテクチャでなくてよい。本番が ALB + ECS でも、プレビューは API Gateway + Lambda で動かすなど、「プレビュー」として多少の挙動差は許容しうる。既存の Web サーバーコンテナは Lambda Web Adapter を挟めば、アプリのコード変更ほぼなしで Lambda にそのまま載せられる
- 共有によって検証できなくなる範囲を明文化してユーザーと合意する。例: DB を共有するなら「DB スキーマ変更 (migration) はプレビューでは検証できない。main マージ後の dev デプロイで当たる」と README に明記する。プレビュースタックには migration を含めない
- サーバーレス中心なら、プレビュー環境の追加ランニングコストはほぼゼロになる
実装前に以下を調査し、結果を分類表と決定事項リストとしてユーザーに提示すること (対話できない環境なら、安全なデフォルトを選び、前提として README に明記して進める)。
- 既存の CDK app 構成 (エントリポイント、スタック分割、環境の分け方) を読む
- 全リソースを上の表に従って「共有 / PR ごと / プレビューでは差し替え or 省略」に分類する
- 以下の決定事項を確認する:
- デプロイ時間とカバー範囲のトレードオフ: 5 分目標を守るために共有を増やすか、検証範囲を優先してデプロイ時間を許容するか
- ドメイン戦略 (後述の A/B から選択)
- トリガー方式:
/deployコメント式 (デフォルト推奨) か、PR push ごとの自動デプロイか - デプロイ先アカウント/リージョンと、OIDC ロールの有無
- 認証プロバイダ (Cognito / Auth0 など) がある場合、per-PR URL の callback 登録方法
cdk synthが現状で通ることを確認してから着手する
- A. カスタムドメインなし (シンプル): CloudFront のデフォルトドメイン、API Gateway の execute-api URL、Lambda Function URL をそのまま使う。Route53 / ACM が一切不要。URL はデプロイ後にしか分からないので、CfnOutput で出して PR コメントに載せる
- B. wildcard サブドメイン (Route53 hosted zone がある場合に推奨): 共有スタックで
*.dev.example.comの wildcard 証明書を 1 枚だけ発行し、PR ごとにapp-pr-<slug>.dev.example.comのような 第 1 階層のラベルに slug を畳んだ FQDN を割り当てる (wildcard 証明書は 1 階層しかカバーしないため)。URL が予測可能になり、IdP の wildcard callback 設定とも相性がよい
環境変数 PREVIEW_BRANCH_NAME が立っているときだけ、プレビュースタックを共有スタックに「重ねて」宣言する。CI からはプレビュースタックだけを deploy する。
// bin/app.ts (テンプレート — 既存の構成に合わせて調整)
const previewBranch = process.env.PREVIEW_BRANCH_NAME;
const base = new BaseStack(app, "Dev-Base", {
env: { account: DEV_ACCOUNT, region: REGION },
// ...既存どおり
});
if (previewBranch) {
const slug = branchToSlug(previewBranch);
new PreviewStack(app, `Preview-${slug}`, {
env: { account: DEV_ACCOUNT, region: REGION },
slug,
// 共有リソースは construct の直接参照で渡す。deploy 時に CDK が
// CFN の cross-stack export/import に展開してくれる
base,
// slug はハッシュで潰れていて逆引きできないので、cleanup ジョブが
// closed PR を特定するための tag として生ブランチ名を必ず付ける
tags: { PreviewBranch: previewBranch },
});
}ブランチ名はスタック名にも DNS ラベルにも使えない文字を含むので、決定的な slug に変換する。決定的であることが重要で、同じブランチに push し直すと同じスタックが更新される (= 環境が使い回される)。
なお、識別子を単純に pr-<PR番号> にする案もある (変換不要で一意性も自明、cleanup の逆引き tag も不要になる)。slug 方式は URL やスタック名にブランチ名の情報が残るぶん、複数のプレビュー環境を並行運用するときに URL を見るだけでどの変更の環境か区別しやすいのが利点。どちらでもよいので、リポジトリの運用に合わせてユーザーと決めること。以下は slug 方式のテンプレート。
// lib/utils/slug.ts
import { createHash } from "node:crypto";
/**
* ブランチ名から一意かつ DNS-safe な slug を作る。
* 形式: `<sanitized-prefix (最大25文字)>-<sha1 先頭4文字>`
* hash は生のブランチ名全体から計算するので、sanitize で同一に潰れる
* ブランチ同士 (`foo-bar` と `foo_bar` など) も衝突しない。
* 全体で最大 30 文字。DNS ラベル 63 文字制限・スタック名 128 文字制限に収まる。
*/
export function branchToSlug(branch: string): string {
const hash = createHash("sha1").update(branch).digest("hex").slice(0, 4);
const sanitized = branch
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-");
const truncated = sanitized.slice(0, 25).replace(/-+$/g, "");
// 全文字が sanitize で潰れたとき (日本語ブランチ名など) のフォールバック
const prefix = truncated.length > 0 ? truncated : "b";
return `${prefix}-${hash}`;
}(1) プレビュースタックが参照する値を恒久 export する。 これをやらないと、プレビュースタックが 1 つでも生きている状態で共有スタックを普通にデプロイした瞬間、Export cannot be deleted as it is in use で共有スタックの更新が失敗する。原因: PREVIEW_BRANCH_NAME なしの synth ではプレビュースタックが存在しないため、CDK の自動 export が全部 drop され、CFN が「使用中の export を消そうとしている」と判断するため。
// BaseStack のコンストラクタ末尾:
// PreviewStack が消費する値を恒久 export しておく。
// exportValue は CDK の自動 export と同じ export 名を使うので、
// プレビュースタック同梱時もそのまま再利用され、重複 export にならない。
// PreviewStack 側で参照を増やしたら、このリストにも必ず追加すること。
[
this.vpc.vpcId,
this.dbCluster.clusterEndpoint.hostname,
cdk.Token.asString(this.dbCluster.clusterEndpoint.port), // 数値 Token は文字列化が必要
this.appSecurityGroup.securityGroupId,
// ... PreviewStack が base 経由で参照する値をすべて列挙
].forEach((v) => this.exportValue(v));(2) 共有リソースの Security Group にプレビュースタック側から触らない。 CDK は dbCluster.connections.allowFrom(previewLambda, ...) のような暗黙の参照で、共有 RDS の SG に per-PR の ingress rule を生やしてしまう (= PR の増減のたびに共有スタックに diff が出る)。対策: アプリ用 SG を共有スタック側で 1 つだけ定義し、DB への ingress もそこで 1 本だけ張り、プレビュースタックの Lambda/ECS はその SG を借用する。プレビュースタック側では SG rule を一切作らない。
(3) クロスリージョン参照 (us-east-1 の CloudFront 証明書など) は共有スタックで「再 export」する。 プレビュースタックから us-east-1 のスタックを直接参照すると、crossRegionReferences の仕組み上、共有側 us-east-1 スタックの custom resource に PR ごとのエントリが追加され、PR の増減で共有スタックに diff が出る。対策: 共有スタック (メインリージョン側) で手書きの CfnOutput として export し直し、プレビュースタックはそれを import する。
// BaseStack にて。Stack.exportValue は他スタック所有のリソースを引数に
// 取れないため、CfnOutput を手書きする。
new cdk.CfnOutput(this, "CertificateArn", {
exportName: `${this.stackName}-CertificateArn`,
value: usEast1Certificate.certificateArn, // crossRegionReferences で受けた値
});
this.certificate = acm.Certificate.fromCertificateArn(
this, "ReExportedCertificate",
cdk.Fn.importValue(`${this.stackName}-CertificateArn`),
);
// PreviewStack には this.certificate を渡すcdk destroy/delete-stack一発で完全に消えること。 スタック全体にcdk.RemovalPolicies.of(this).destroy()を適用する (古い aws-cdk-lib にRemovalPoliciesが無ければ upgrade するか、Aspect で全リソースの RemovalPolicy を DESTROY に倒す)。S3 バケットは加えてautoDeleteObjects: trueが必要- 固定の物理名を持つリソースを置かない。 名前付き IAM ロール、固定名の SSM パラメータ、固定名の LogGroup などは 2 つ目の PR スタックで衝突する。物理名は CFN の自動命名に任せるか、必ず slug を含める
- 環境で単一であるべきものを含めない。 DB migration、シングルトンの cron、外部システムへ値を publish する仕組みなどは共有スタックに置いたまま、プレビュースタックからは除外する
- 本番用スタックとコードを共有する場合は、アプリ層を Construct として切り出し、共有スタックとプレビュースタックの両方からインスタンス化する形にすると重複がない
fromLookup(VPC / hosted zone) を使う場合はcdk.context.jsonをコミットし、CI での synth を決定的にする- コンテナ image の Lambda を使う場合: GitHub Actions ランナーのアーキテクチャを image の platform と一致させる (arm64 image なら arm ランナー)。QEMU エミュレーションが挟まるとビルドが数倍遅くなる。それでも遅ければ、プレビューだけ zip 形式の
NodejsFunction等に差し替えることも検討 - IdP の callback URL: Auth0 のように wildcard callback (
https://app-pr-*.dev.example.com) を許可できる IdP ならドメイン戦略 B と組み合わせて設定一発で済む。Cognito は wildcard 不可なので、プレビュースタック内の custom resource で自分の URL を UserPoolClient の callback リストに追加/削除する
SPA のビルドには「API の URL」などデプロイしてみないと分からない値が要る、という鶏と卵の問題がある。解決策は 2 つ。どちらかを採用する:
- a. deploy-time build:
@cdklabs/deploy-time-buildのNodejsBuildを使い、CDK デプロイ中に CodeBuild 上で SPA をビルドする。ビルド env に CFN Token (API URL 等) をそのまま渡せる (デプロイ時に解決される)。ビルドが他リソースの作成と並走するので、デプロイ時間の節約にもなる- 注意:
NodejsBuildに CloudFront distribution を渡すと distribution の作成完了 (初回は時間がかかる) を待ってからビルドが走る直列関係になる。distribution は渡さず、ビルド完了を示す値を physicalResourceId にしたAwsCustomResourceで CloudFront invalidation を別途発行し、ビルドと distribution 作成を並走させる
- 注意:
- b. runtime config: ビルドは環境非依存にしておき、
config.jsonを S3 に置いて SPA が起動時に fetch する。ビルドを CI ランナー側で 1 回で済ませられる
デフォルトは /deploy コメントによるオンデマンドデプロイとする (push ごとの自動デプロイはコストと fork PR のセキュリティ面で慎重に。信頼できるメンバーだけの private リポジトリなら pull_request: types: [opened, synchronize] への変更も可)。
テンプレート (.github/workflows/deploy-preview.yml)。コメントに書いた理由ごと維持すること:
name: Deploy-Preview
on:
issue_comment:
types: [created]
# 検証用の手動トリガ。workflow_dispatch は GHA 側で write 権限が要るので追加の gating 不要
workflow_dispatch:
inputs:
pr_number:
description: deploy 対象の PR 番号
required: true
type: number
# 同一 PR の連続 deploy は直列化、別 PR は並列。前段の deploy を途中で殺すと
# CloudFormation が半端な状態になるので cancel-in-progress は false
concurrency:
group: deploy-preview-${{ inputs.pr_number || github.event.issue.number }}
cancel-in-progress: false
env:
ROLE_ARN: arn:aws:iam::<DEV_ACCOUNT_ID>:role/<GHA_DEPLOY_ROLE>
AWS_REGION: <REGION>
permissions:
id-token: write # OIDC で AWS にアクセス
contents: read # checkout
pull-requests: write # 結果コメント
issues: write # コメントへの reaction (PR コメントは issue comment 扱い)
jobs:
deploy:
# PR 上のコメントかつ /deploy 始まり、かつ write 以上を持つ人だけに限定
# (fork PR からの誤発火・外部ユーザーによる勝手な deploy を防ぐ)
if: |
github.event_name == 'workflow_dispatch'
|| (github.event.issue.pull_request != null
&& startsWith(github.event.comment.body, '/deploy')
&& contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
runs-on: ubuntu-latest # コンテナ image Lambda が arm64 なら arm ランナーに変える
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ inputs.pr_number || github.event.issue.number }}
steps:
# 受け付けたことを最速で伝える (deploy は数分かかるので)
- name: React to comment
if: github.event_name == 'issue_comment'
run: |
gh api --method POST \
repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \
-f content=eyes
# issue_comment / workflow_dispatch の checkout は default branch になる。
# PR の head を API から取り直し、SHA で pin する (走行中に force push
# されてもこの run は固定 commit を deploy する)
- name: Resolve PR head
id: pr
run: |
PR_DATA=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER")
{
echo "branch=$(echo "$PR_DATA" | jq -r .head.ref)"
echo "sha=$(echo "$PR_DATA" | jq -r .head.sha)"
} >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.pr.outputs.sha }}
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- run: npm ci
- name: Deploy preview stack
id: deploy
env:
PREVIEW_BRANCH_NAME: ${{ steps.pr.outputs.branch }}
run: |
# --exclusively が無いと base 参照経由で共有スタックまで巻き込んで
# update される。プレビュースタックだけに絞る
npx cdk deploy "Preview-*" \
--exclusively \
--require-approval never \
--outputs-file preview-outputs.json
# 結果コメントは marker 付きで投稿し、再 deploy 時に過去分を削除して
# PR の履歴を綺麗に保つ。掃除は成否に関わらず走らせる
- name: Delete previous result comments
if: always()
run: |
gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" --paginate \
--jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains("<!-- preview-deploy-result -->"))) | .id' \
| while read -r id; do
gh api -X DELETE "repos/${{ github.repository }}/issues/comments/$id" || true
done
- name: Comment on success
if: success()
env:
PR_BRANCH: ${{ steps.pr.outputs.branch }}
PR_SHA: ${{ steps.pr.outputs.sha }}
run: |
STACK_NAME=$(jq -r 'keys[0]' preview-outputs.json)
{
echo "<!-- preview-deploy-result -->"
echo "✅ Deployed \`$PR_BRANCH\` (\`${PR_SHA:0:7}\`) to **$STACK_NAME**"
echo
jq -r '.[keys[0]] | to_entries[] | "- \(.key): \(.value)"' preview-outputs.json
} > pr-comment.md
gh pr comment "$PR_NUMBER" --body-file pr-comment.md
- name: Comment on failure
if: failure()
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
{
echo "<!-- preview-deploy-result -->"
echo "❌ Deploy failed. See [Actions run]($RUN_URL)."
} > pr-comment.md
gh pr comment "$PR_NUMBER" --body-file pr-comment.md注意 (README にも書くこと): issue_comment トリガーのワークフローは default branch 上の定義が実行される。つまりこのワークフロー自体が main にマージされるまで /deploy は効かない。ワークフロー自体を変更する PR も /deploy では検証できないので、workflow_dispatch を gh workflow run --ref <branch> で叩いて検証する。
GitHub OIDC provider + デプロイロールのセットアップ手順 (CloudFormation テンプレート or CDK スタック or CLI コマンド) を別途生成し、管理者が一度だけ実行するよう README に記載する。trust policy の sub 条件は必ず対象リポジトリに絞る。ロール権限は CDK デプロイに必要な範囲 (最低限 cdk bootstrap の deploy ロール群を assume できる権限) にする。
PR の close をトリガーにするのではなく、**日次 cron で「スタック一覧と open PR の突き合わせ」**を行う方式にする (close イベント取りこぼしや削除失敗があっても、次回の cron が拾うので自己修復的)。
テンプレート (.github/workflows/cleanup-preview.yml) の要点:
name: Cleanup-Preview-Stacks
on:
schedule:
- cron: "0 18 * * *" # CI のピーク時間帯を外す
workflow_dispatch:
inputs:
dry_run:
description: 削除対象を出すだけで delete-stack は呼ばない
type: boolean
default: true
# describe → delete の間に別 run が走ると競合するので常に直列化
concurrency:
group: cleanup-preview-stacks
cancel-in-progress: false
permissions:
id-token: write
contents: read
pull-requests: readジョブのロジック (bash + aws cli + gh cli で実装):
gh pr list --state open --json headRefNameで open な PR のブランチ一覧を取る。空行は除去する (空行が混じると後段のマッチが誤爆して全スタックが「open 扱い」になる)aws cloudformation describe-stacksでスタック名がPreview-で始まるものを列挙。IN_PROGRESS状態のスタックは除外 (deploy ワークフローが触っている最中の可能性がある)- 各スタックの
PreviewBranchtag を読む。tag が無いスタックは warning を出して skip (手動作成スタックの巻き込み防止) - tag のブランチが open PR 一覧に無ければ
aws cloudformation delete-stack。削除完了は待たない (ランナーの課金時間を節約。失敗や残骸は翌日の cron が拾う) - dry_run 時は削除せず対象を列挙するだけ。結果 (deleted / kept / skipped) を
$GITHUB_STEP_SUMMARYに表として出力する
オプション: CDK の custom resource (S3 autoDeleteObjects 等) が使う Lambda は、CFN 削除中に Lambda サービスが LogGroup を自動再作成する race があり、スタック削除後も /aws/lambda/Preview-* の LogGroup が残ることがある。気になる場合は cleanup ジョブの最後に、現存スタックに紐付かない該当 prefix の LogGroup をスイープするステップを足す。
実装後、以下を順に確認すること:
PREVIEW_BRANCH_NAMEなしでnpx cdk synthが通る (従来のデプロイが壊れていない)PREVIEW_BRANCH_NAME=feature/test-branchありでnpx cdk synthが通り、cdk listにPreview-<slug>が出るPREVIEW_BRANCH_NAMEありの状態で、共有スタックに対するcdk diffが no changes であること。 ここに diff が出るなら暗黙の cross-stack 参照 (SG rule、cross-region export、自動 export の増減) が漏れている。プレビュースタックの追加が共有スタックを 1 文字も変更しない状態になるまで直す- 逆方向: プレビュースタックが参照する値がすべて恒久 export リストに載っていることを、synth 結果 (共有スタックの Outputs) を目で見て確認する
- 可能ならユーザーに実デプロイしてもらい (ワークフローを main にマージ →
/deploy)、CloudFormation コンソールのタイムライン表示で時間を食っているリソースを特定する。目標 (5 分) を超える場合は「共有に移す / 別アーキテクチャに差し替える / 初回のみ許容 or 検証範囲優先と README に明記」のいずれかで解消する - cleanup ワークフローを
dry_run: trueで実行し、削除対象の判定が正しいことを確認する
- 共有 / PR ごとの最終的なリソース分類表
- プレビューで検証できる範囲・できない範囲 (README に書いた内容。例: DB スキーマ変更は対象外)
- 残っている手動セットアップ (OIDC ロール作成、IdP の callback URL 設定、ワークフローの main へのマージが先に必要なこと)
- 概算コスト (PR スタック 1 つあたりのランニングコスト。サーバーレス構成ならほぼゼロのはず)
- 運用方法:
/deployの使い方、同一ブランチへの再 push で同じ環境が更新されること、close 後は翌日の cron で消えること