0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【API Gateway + Lambda】構成をAWS CDKでコード化する

0
Posted at

はじめに

今回は、前回の記事でAWSコンソール上で手動で設定したバックエンド(API Gateway + Lambda)構成を、AWS CDKを使ってコード化してみました。

※より実務での運用に近づけるために、前回の手動構築の際には導入していなかったLambdaエイリアスを導入しています。

今回、追加/編集したファイル

Project/
├── .github/
│   └── workflows/
│       ├── deploy-backend.yml ①        (追加)
│       └── deploy-frontend.yml ②       (追加/改名: deploy.yml → deploy-frontend.yml)
└── cdk/
  ├── bin/
  │   └── app.ts ③                      (変更)
  ├── lambda-placeholder/
  │   └── index.mjs ④                   (追加)
  └── lib/
      ├── constructs/
      │   ├── backend-api.ts ⑤          (追加)
      │   └── static-site.ts ⑥          (変更)
      └── stacks/
          ├── backend-stack.ts ⑦        (追加)
          ├── frontend-stack.ts ⑧       (変更)
          └── github-oidc-stack.ts ⑨    (変更)

各ファイル内容

※プログラミング言語はTypeScriptを使用しています
※ベストプラクティスというわけではないため、あくまで参考にしていただけると幸いです
※各ファイルの詳細な説明は省略いたしますが、気になった方はAIに投げてみてください
※機密情報を扱う際は、別途.envSecretManagerなどと連携する必要があります

① .github/workflows/deploy-backend.yml

このファイルは Nitro Server BFF(バックエンド)をAWS LambdaへデプロイするGitHub Actionsワークフローです。
フロントエンドのデプロイワークフロー:deploy-frontend.ymlとは分離されています。

.github/workflows/deploy-backend.yml
name: Deploy LIFF Backend (BFF Lambda)
run-name: Deploy Backend 【${{ inputs.target_env }}】

# ============================================================
# バックエンド(Nitro BFF を Lambda にデプロイ)専用ワークフロー。
# フロントエンド(S3 + CloudFront 静的サイト)は別ワークフロー
# `.github/workflows/deploy-frontend.yml` を使う。
#
# このワークフローは AWS Lambda 関数のコード本体(`.output/server/` を zip 化)
# と環境変数のみを更新する。インフラ自体(関数の枠 / API Gateway /
# IAM Role / CloudFront behavior)の作成・変更は `cdk deploy` で行う。
# ============================================================

on:
  workflow_dispatch:
    inputs:
      target_env:
        description: 'デプロイ先環境'
        required: true
        type: choice
        options:
          - dev
          - stg
          - prod

# 同一環境への並行デプロイを防止。
# Lambda の update-function-code は逐次でしか走らないため、
# 並行起動するとレースで上書きが起きうる。
concurrency:
  group: deploy-backend-${{ github.event.inputs.target_env }}
  cancel-in-progress: false

permissions:
  contents: read
  id-token: write # OIDC AssumeRole に必須

jobs:
  deploy:
    name: Deploy Backend to ${{ inputs.target_env }}
    runs-on: ubuntu-latest
    timeout-minutes: 15

    environment:
      name: ${{ inputs.target_env }}

    steps:
      - name: Validate deploy ref
        env:
          TARGET_ENV: ${{ inputs.target_env }}
          DEPLOY_REF: ${{ github.ref_name }}
        run: |
          if [ "$TARGET_ENV" = "prod" ] && [ "$DEPLOY_REF" != "main" ]; then
            echo "::error::prod deploys must use main (got: $DEPLOY_REF)"
            exit 1
          fi

      # ----------------------------------------------------------
      # 必須 GitHub Variables の存在 + 形式チェック
      # ----------------------------------------------------------
      # build / test を回す前にここで弾く。Lambda Version は code + env の
      # 凍結 snapshot なので、空の env で publish された Version が AWS 上に
      # 永続的に残り続けるのを構造的に防ぐ。
      #
      # NUXT_LINE_LOGIN_CHANNEL_ID の形式: LINE Developers が発行する Channel ID は
      # 10 桁前後の数字。8〜12 桁の数字以外を弾くことで、LIFF ID
      # (`2001234567-abcdefgh` 形式) を誤投入する事故を捕捉する。
      # 変数名は Nuxt の runtime override 規約 (`NUXT_<KEY>`) に揃えてある。
      - name: Validate required GitHub Variables
        env:
          LIFF_ID: ${{ vars.LIFF_ID }}
          NUXT_LINE_LOGIN_CHANNEL_ID: ${{ vars.NUXT_LINE_LOGIN_CHANNEL_ID }}
          LAMBDA_FUNCTION_NAME: ${{ vars.LAMBDA_FUNCTION_NAME }}
          AWS_DEPLOY_ROLE_ARN: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
          AWS_REGION: ${{ vars.AWS_REGION }}
          CLOUDFRONT_DOMAIN_NAME: ${{ vars.CLOUDFRONT_DOMAIN_NAME }}
          TARGET_ENV: ${{ inputs.target_env }}
        run: |
          set -eo pipefail
          fail=0
          for v in LIFF_ID NUXT_LINE_LOGIN_CHANNEL_ID LAMBDA_FUNCTION_NAME AWS_DEPLOY_ROLE_ARN AWS_REGION; do
            if [ -z "${!v:-}" ]; then
              echo "::error::Required GitHub Variable '$v' is not set on environment '$TARGET_ENV'."
              fail=1
            fi
          done
          if [ -n "${NUXT_LINE_LOGIN_CHANNEL_ID:-}" ] && ! [[ "$NUXT_LINE_LOGIN_CHANNEL_ID" =~ ^[0-9]{8,12}$ ]]; then
            echo "::error::NUXT_LINE_LOGIN_CHANNEL_ID must be an 8-12 digit numeric value. Got something that does not match (likely a LIFF ID was pasted by mistake)."
            fail=1
          fi
          # prod では CLOUDFRONT_DOMAIN_NAME を必須にする。
          # 未設定だと後段のスモークテストがスキップされ、未検証の Lambda Version が
          # そのまま live alias に張り付く(= 自動ロールバックの安全装置が無効化される)。
          # dev / stg では初回構築時の bootstrap 経路を残すため optional のまま。
          if [ "$TARGET_ENV" = "prod" ] && [ -z "${CLOUDFRONT_DOMAIN_NAME:-}" ]; then
            echo "::error::CLOUDFRONT_DOMAIN_NAME is required on 'prod' (smoke test would otherwise be skipped, leaving an unverified Lambda Version on the 'live' alias)."
            fail=1
          fi
          [ "$fail" -eq 0 ] || exit 1

      - name: Checkout (${{ github.ref_name }})
        uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          run_install: false

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Type check
        run: pnpm typecheck

      - name: Lint
        run: pnpm lint

      - name: Test (vitest)
        run: pnpm test

      # ----------------------------------------------------------
      # Nitro が `aws-lambda` preset で `.output/server/index.mjs` を生成する。
      # フロントエンドの静的アセット(.output/public/)も同時に生成されるが、
      # このワークフローでは server/ のみを Lambda に上げる。
      # ----------------------------------------------------------
      - name: Build (nuxt build, nitro preset=aws-lambda)
        env:
          # ssr=false の prerender でも runtimeConfig.public.liffId は
          # 静的バンドルへ焼き込まれるため、build 時点で必須。
          # NUXT_LINE_LOGIN_CHANNEL_ID はサーバー専用 (Lambda env 経由で runtime 注入)
          # のためビルド env には含めない。バンドルへの secret 焼き込みを避ける。
          NUXT_PUBLIC_LIFF_ID: ${{ vars.LIFF_ID }}
        run: pnpm build

      # ----------------------------------------------------------
      # `.output/server/` の **中身** を zip 化する。
      # ルートに index.mjs が来るようにする必要があるため、ディレクトリごと
      # 包まない(`cd .output/server && zip -r ../../bff-lambda.zip .`)。
      # ----------------------------------------------------------
      - name: Package Lambda bundle
        run: |
          set -euo pipefail
          cd .output/server
          zip -qr ../../bff-lambda.zip .
          cd -
          ls -lh bff-lambda.zip

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}
          role-session-name: gha-backend-${{ inputs.target_env }}-${{ github.run_id }}

      # ----------------------------------------------------------
      # Lambda コード更新 → Version 凍結 → Alias 切替 の原子的デプロイ
      # ----------------------------------------------------------
      # 概要:
      #   1. $LATEST にコードと env を反映
      #   2. publish-version で snapshot を Vn として凍結
      #   3. update-alias で 'live' を Vn に張り替え(ここで初めて本番トラフィック切替)
      # API Gateway は 'live' alias を invoke しているので、ステップ 1〜2 の
      # 過渡状態は本番には**露出しない**。ステップ 3 が唯一の切替ポイント。
      # ----------------------------------------------------------

      - name: Update Lambda function code ($LATEST)
        run: |
          aws lambda update-function-code \
            --function-name "${{ vars.LAMBDA_FUNCTION_NAME }}" \
            --zip-file fileb://bff-lambda.zip \
            --no-cli-pager \
            >/dev/null

      - name: Wait for code update to settle
        run: |
          aws lambda wait function-updated \
            --function-name "${{ vars.LAMBDA_FUNCTION_NAME }}"

      # 環境変数(NUXT_LINE_LOGIN_CHANNEL_ID)の同期。
      # Version は code + env + memory + timeout 全部の snapshot を取るので、
      # publish-version より **前**に env を確定させておく必要がある。
      #
      # 変数名は Nuxt の runtime override 規約 (`NUXT_<KEY>`) に揃えてある。
      # 別名(例: NUXT_LINE_LOGIN_CHANNEL_ID)で渡すと Nuxt は拾わず、
      # middleware が `lineLoginChannelId is not configured` で 500 を返す。
      #
      # CLI shorthand (`Variables={K=V,K=V}`) ではなく **jq で JSON を組み立てて
      # --cli-input-json に渡す**。値に `,` や `=` が含まれた場合の shell 展開
      # 事故をゼロにする。上流の Validate ステップで存在 / 形式は担保済み。
      - name: Sync Lambda environment variables ($LATEST)
        env:
          FN_NAME: ${{ vars.LAMBDA_FUNCTION_NAME }}
          NUXT_LINE_LOGIN_CHANNEL_ID: ${{ vars.NUXT_LINE_LOGIN_CHANNEL_ID }}
        run: |
          set -euo pipefail
          jq -n \
            --arg fn "$FN_NAME" \
            --arg channel "$NUXT_LINE_LOGIN_CHANNEL_ID" \
            '{
              FunctionName: $fn,
              Environment: {
                Variables: {
                  NUXT_LINE_LOGIN_CHANNEL_ID: $channel,
                  NITRO_PRESET: "aws-lambda",
                  NODE_ENV: "production"
                }
              }
            }' > /tmp/lambda-config.json
          aws lambda update-function-configuration \
            --cli-input-json file:///tmp/lambda-config.json \
            --no-cli-pager \
            >/dev/null

      - name: Wait for configuration update to settle
        run: |
          aws lambda wait function-updated \
            --function-name "${{ vars.LAMBDA_FUNCTION_NAME }}"

      # ----------------------------------------------------------
      # alias 切替の **前**に現在の live target を確保(ロールバック用)
      # ----------------------------------------------------------
      # 後段でスモークテストが失敗した場合に巻き戻す宛先は「今 live が
      # 指している Version」=「直前まで本番に出ていた Version」。
      # publish-version 後に取得すると新 Version も候補に紛れ込むため、
      # publish より前にスナップショットする。
      # 初回デプロイ前にこのワークフローが走った(CDK が alias を bootstrap
      # していない)場合は早期に落として、壊れた状態を量産しないようにする。
      - name: Capture current 'live' version (rollback target)
        id: current
        env:
          FN_NAME: ${{ vars.LAMBDA_FUNCTION_NAME }}
        run: |
          set -euo pipefail
          # FunctionVersion と RevisionId を同時取得する。
          # RevisionId は後続の update-alias での楽観ロック(条件付き更新)に使う。
          # 取得から張り替えまでの間に AWS コンソール / 別経路で alias が更新された場合、
          # update-alias が PreconditionFailedException で失敗し、巻き戻し事故を防げる。
          LIVE_INFO=$(aws lambda get-alias \
            --function-name "$FN_NAME" \
            --name live \
            --query '[FunctionVersion,RevisionId]' --output text 2>/dev/null || echo "")
          CURRENT=$(echo "$LIVE_INFO" | awk '{print $1}')
          REVISION_ID=$(echo "$LIVE_INFO" | awk '{print $2}')
          if [ -z "$CURRENT" ] || [ "$CURRENT" = "None" ]; then
            echo "::error::Alias 'live' is not present on $FN_NAME. Run 'cdk deploy' first to bootstrap the alias."
            exit 1
          fi
          echo "version=$CURRENT" >> "$GITHUB_OUTPUT"
          echo "revision_id=$REVISION_ID" >> "$GITHUB_OUTPUT"
          echo "Current live version (rollback target): $CURRENT (revision: $REVISION_ID)"

      # ----------------------------------------------------------
      # 現在の Version スナップショットを発行
      # ----------------------------------------------------------
      # publish-version は $LATEST の code + config を immutable な Vn として
      # 凍結する。以降この Vn は code・env・memory・timeout が変わらないため、
      # ロールバックの「巻き戻し先」として確実に再現できる。
      - name: Publish new version
        id: publish
        run: |
          set -euo pipefail
          VERSION=$(aws lambda publish-version \
            --function-name "${{ vars.LAMBDA_FUNCTION_NAME }}" \
            --description "GHA run ${{ github.run_id }} (sha: ${{ github.sha }})" \
            --query 'Version' --output text)
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
          echo "Published version: $VERSION"

      # ----------------------------------------------------------
      # alias 'live' を新 Version に張り替え + 検証 + 失敗時自動ロールバック
      # ----------------------------------------------------------
      # 1 step にまとめて trap ERR の有効範囲を明示する:
      #   - update-alias は即時反映。これ以降のリクエストは新 Version へ流れる
      #   - update-alias 直後に rollback() を trap で仕掛ける
      #   - スモークテストが失敗(401 以外)したら自動で前 Version へ巻き戻す
      #   - 正常終了したら trap を解除
      #
      # `vars.CLOUDFRONT_DOMAIN_NAME` 未設定時はスモークテストをスキップする。
      # これは初回構築時など CloudFront 側がまだ未配備のケースを想定した暫定挙動。
      - name: Shift 'live' alias and verify (with auto rollback)
        env:
          FN_NAME: ${{ vars.LAMBDA_FUNCTION_NAME }}
          NEW_VERSION: ${{ steps.publish.outputs.version }}
          PREV_VERSION: ${{ steps.current.outputs.version }}
          PREV_REVISION_ID: ${{ steps.current.outputs.revision_id }}
          CF_DOMAIN: ${{ vars.CLOUDFRONT_DOMAIN_NAME }}
        run: |
          set -euo pipefail

          # 1) alias を新 Version へ張り替え(ここが本番トラフィックの唯一の切替ポイント)
          # --revision-id で楽観ロック。直前の Capture 取得後に AWS コンソールや別経路で
          # alias が更新されていた場合は PreconditionFailedException で失敗する。
          # rollback 側ではあえて --revision-id を付けない(巻き戻しは無条件に成功させたい)。
          aws lambda update-alias \
            --function-name "$FN_NAME" \
            --name live \
            --function-version "$NEW_VERSION" \
            --revision-id "$PREV_REVISION_ID" \
            --description "GHA run ${{ github.run_id }} on ${{ github.sha }}" \
            --no-cli-pager >/dev/null

          # 2) ここから先で失敗したら必ず alias を旧 Version へ自動巻き戻し
          rollback() {
            echo "::error::Verification failed. Auto-rolling 'live' back to version $PREV_VERSION"
            aws lambda update-alias \
              --function-name "$FN_NAME" \
              --name live \
              --function-version "$PREV_VERSION" \
              --description "Auto-rollback by GHA run ${{ github.run_id }} (sha: ${{ github.sha }})" \
              --no-cli-pager >/dev/null || \
              echo "::error::Rollback itself failed. Manual intervention required."
          }
          trap rollback ERR

          # 3) スモークテスト: CloudFront 経由で /api/users/me を叩いて 401 を期待
          #
          # curl のタイムアウトは shell 内で必ず制御する。指定しないと CloudFront
          # 不達や DNS ハング時に GitHub Actions の job timeout で SIGKILL され、
          # `trap rollback ERR` に到達できず壊れた Version を live に残す事故になる。
          # タイムアウト時 curl は非ゼロ終了 + http_code="000" を吐くので、
          # 後段の `[ "$STATUS" != "401" ]` で exit 1 → ERR trap が確実に発火する。
          if [ -n "$CF_DOMAIN" ]; then
            STATUS=$(curl -sS --connect-timeout 5 --max-time 15 -o /dev/null -w "%{http_code}" "https://$CF_DOMAIN/api/users/me")
            echo "Smoke test HTTP status: $STATUS"
            if [ "$STATUS" != "401" ]; then
              echo "::error::expected 401 but got $STATUS"
              exit 1
            fi
          else
            echo "::warning::CLOUDFRONT_DOMAIN_NAME is not set; skipping smoke test"
          fi

          # 4) 検証成功 → trap を解除
          trap - ERR
          echo "Verification passed. 'live' is now serving version $NEW_VERSION."

      - name: Summary
        env:
          DEPLOY_ENV: ${{ inputs.target_env }}
          NEW_VERSION: ${{ steps.publish.outputs.version }}
          PREV_VERSION: ${{ steps.current.outputs.version }}
          FN_NAME: ${{ vars.LAMBDA_FUNCTION_NAME }}
        run: |
          {
            printf '## ✅ Backend deploy completed (`%s`)\n' "$DEPLOY_ENV"
            printf '\n'
            printf '| Item | Value |\n'
            printf '|---|---|\n'
            printf '| Lambda | `%s` |\n' "$FN_NAME"
            printf '| Region | `%s` |\n' "${{ vars.AWS_REGION }}"
            printf '| New version | `%s` |\n' "$NEW_VERSION"
            printf '| Alias `live` | → `%s` |\n' "$NEW_VERSION"
            printf '| Previous version (rollback target) | `%s` |\n' "$PREV_VERSION"
            printf '\n### Manual rollback (if needed later)\n'
            printf '```bash\n'
            printf 'aws lambda update-alias \\\n'
            printf '  --function-name %s \\\n' "$FN_NAME"
            printf '  --name live \\\n'
            printf '  --function-version %s\n' "$PREV_VERSION"
            printf '```\n'
            printf '\n_Note: スモークテストが失敗した場合はワークフロー側で自動的に %s に巻き戻されます。手動ロールバックは過去 Version を任意の値に戻す用途。_\n' "$PREV_VERSION"
          } >> "$GITHUB_STEP_SUMMARY"
Lambdaエイリアス切り替えの補足
$LATEST          ← update-function-code でコードを反映
 ↓ publish-version
V42 (新 snapshot) ← この瞬間の code + env を凍結
 ↑
 ├─ alias 'live' ← API Gateway はここを invoke
 ↓
V41 (前 snapshot) ← ロールバック先として温存

publish-version APIは常に「現在の最大番号 + 1」のバージョンを作成するため、AWSコンソール上で手動で新規バージョンを発行していたとしても問題ありません。

② .github/workflows/deploy-frontend.yml

.github/workflows/deploy-frontend.yml
name: Deploy LIFF Frontend
run-name: Deploy Frontend 【${{ inputs.target_env }}】

# ============================================================
# フロントエンド(Nuxt SPA を S3 + CloudFront に配信)専用ワークフロー。
# バックエンド(Lambda + API Gateway)は別ワークフロー
# `.github/workflows/deploy-backend.yml` で更新する。
#
# 同一リポジトリ内で関心事を分離する理由:
#   - フロント更新(HTML/CSS/JS の差し替え)と
#     バックエンド更新(Nitro bundle 差し替え)は更新頻度・影響範囲・
#     ロールバック単位が大きく異なる。
#   - 1 つにまとめると、デザイン微調整のたびに Lambda の冷起動が走る等の
#     不必要な副作用が生じる。
#   - ワークフロー単位で並列実行制御・環境保護を独立に設定できる。
# ============================================================

on:
  workflow_dispatch:
    inputs:
      target_env:
        description: 'デプロイ先環境'
        required: true
        type: choice
        options:
          - dev
          - stg
          - prod

# 同一環境への並行デプロイを防止
# 実行中deployは止めず、後続deployはqueueする
concurrency:
  group: deploy-frontend-${{ github.event.inputs.target_env }}
  cancel-in-progress: false

permissions:
  contents: read
  id-token: write # OIDC AssumeRole に必須

jobs:
  deploy:
    name: Deploy Frontend to ${{ inputs.target_env }}
    runs-on: ubuntu-latest
    timeout-minutes: 15

    environment:
      name: ${{ inputs.target_env }}

    steps:
      - name: Validate deploy ref
        env:
          TARGET_ENV: ${{ inputs.target_env }}
          DEPLOY_REF: ${{ github.ref_name }}
        run: |
          if [ "$TARGET_ENV" = "prod" ] && [ "$DEPLOY_REF" != "main" ]; then
            echo "::error::prod deploys must use main (got: $DEPLOY_REF)"
            exit 1
          fi

      - name: Checkout (${{ github.ref_name }})
        uses: actions/checkout@v4
        with:
          # 履歴は不要なので浅く
          fetch-depth: 1

      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          # package.json の packageManager に従う
          run_install: false

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Type check
        run: pnpm typecheck

      - name: Lint
        run: pnpm lint

      - name: Build (nuxt build, prerender all routes)
        env:
          NUXT_PUBLIC_LIFF_ID: ${{ vars.LIFF_ID }}
        run: pnpm build

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}
          role-session-name: gha-frontend-${{ inputs.target_env }}-${{ github.run_id }}

      # S3 へ一括アップロード
      # 全ファイルを短期キャッシュで統一(ハッシュ付きアセットも含む)
      - name: Upload to S3
        run: |
          aws s3 sync ./.output/public/ s3://${{ vars.S3_BUCKET_NAME }}/ \
            --delete \
            --cache-control "no-cache, must-revalidate" \
            --metadata-directive REPLACE \
            --only-show-errors

      # CloudFront 無効化(静的アセットのみ。/api/* は CachingDisabled なので
      # invalidation 不要)。
      - name: Create CloudFront invalidation
        id: invalidate
        run: |
          INV_ID=$(aws cloudfront create-invalidation \
            --distribution-id ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} \
            --paths "/*" \
            --query 'Invalidation.Id' \
            --output text)

          echo "invalidation_id=$INV_ID" >> $GITHUB_OUTPUT
          echo "Invalidation: $INV_ID"

      - name: Wait for invalidation to complete
        run: |
          aws cloudfront wait invalidation-completed \
            --distribution-id ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} \
            --id ${{ steps.invalidate.outputs.invalidation_id }}

      - name: Summary
        env:
          DEPLOY_ENV: ${{ inputs.target_env }}
        run: |
          printf '## ✅ Frontend deploy completed (`%s`)\n' "$DEPLOY_ENV" >> "$GITHUB_STEP_SUMMARY"

③ cdk/bin/app.ts

cdk/bin/app.ts
#!/usr/bin/env node
import 'source-map-support/register'
import { App, Tags } from 'aws-cdk-lib'
import { loadEnvConfig } from '../config/env-config'
import type { EnvName } from '../config/types'
import { BaseStack } from '../lib/stacks/base-stack'
import { BackendStack } from '../lib/stacks/backend-stack'
import { FrontendStack } from '../lib/stacks/frontend-stack'
import { GithubOidcStack } from '../lib/stacks/github-oidc-stack'

const app = new App()

// `cdk bootstrap` のような env 非依存コマンドは cdk.json の `app` 経由で
// ここを実行するが、env context を必要としない。env 未指定の場合は
// per-env stack を一切構築せず空 synth で抜ける(bootstrap が壊れないように)。
// deploy/diff/synth など env が必要なコマンドは `--context env=...` を付ける運用。
const envName = app.node.tryGetContext('env') as EnvName | undefined
if (envName) {
  const config = loadEnvConfig(app)

  // --- アカウントガード -------------------------------------------------------
  // マルチアカウント前提: 各環境 (dev/stg/prod) は専用 AWS アカウントに居る。
  // 呼び出し側の認証情報が env config で宣言したアカウントと違うときは synth を
  // 拒否する。誤アカウントへのデプロイは最もよくある事故なので構造的に弾く。
  if (config.account === 'REPLACE_ME') {
    throw new Error(
      `config/${config.envName}.ts has 'account: REPLACE_ME'. ` +
        `Set the real AWS account ID for the ${config.envName} environment before deploying.`,
    )
  }

  const callerAccount = process.env.CDK_DEFAULT_ACCOUNT
  if (callerAccount && callerAccount !== config.account) {
    throw new Error(
      `Account mismatch: current AWS credentials resolve to ${callerAccount}, ` +
        `but config/${config.envName}.ts targets ${config.account}. ` +
        `Switch the --profile to the ${config.envName} account.`,
    )
  }

  const env = {
    account: config.account,
    region: config.region,
  }

  // --- NUXT_LINE_LOGIN_CHANNEL_ID -------------------------------------------------
  // `BackendStack` の Lambda 環境変数として埋め込む。値は GitHub Actions が
  // synth 時に env 経由で渡す(または CDK context `--context lineLoginChannelId=...`)。
  // Nuxt の runtime override 規約に揃えるため、Lambda 上では
  // `NUXT_LINE_LOGIN_CHANNEL_ID` という名前で注入される(`backend-api.ts` 参照)。
  // 空文字でも synth は成功するが、デプロイ後の /api/* は middleware が 500 を返す。
  // -------------------------------------------------------------------------
  const lineLoginChannelId =
    (app.node.tryGetContext('lineLoginChannelId') as string | undefined) ||
    process.env.NUXT_LINE_LOGIN_CHANNEL_ID ||
    ''

  // アカウントベースのリソース(per-env stack より先に各アカウントで 1 回だけ作成)。
  const base = new BaseStack(app, `${config.prefix}-${config.envName}-base`, {
    env,
    prefix: config.prefix,
    envName: config.envName,
    description: `${config.prefix} ${config.envName} account base (GitHub OIDC provider)`,
  })

  // BFF(Lambda + HTTP API)。FrontendStack より先に作って CloudFront に
  // origin として渡せるようにする。
  const backend = new BackendStack(app, `${config.prefix}-${config.envName}-backend`, {
    env,
    config,
    lineLoginChannelId,
    description: `${config.prefix} ${config.envName} BFF (Lambda + API Gateway HTTP API)`,
  })

  // 環境別の静的サイト(S3 + CloudFront)に BFF オリジンを連携。
  const frontend = new FrontendStack(app, `${config.prefix}-${config.envName}-frontend`, {
    env,
    config,
    apiOriginDomain: backend.apiEndpointDomain,
    description: `${config.prefix} ${config.envName} static site (S3 + CloudFront)`,
  })

  // 環境別の GitHub Actions デプロイ Role(base + frontend + backend に依存)。
  new GithubOidcStack(app, `${config.prefix}-${config.envName}-github-oidc`, {
    env,
    config,
    oidcProviderArn: base.githubOidcProviderArn,
    bucketArn: frontend.bucketArn,
    distributionArn: frontend.distributionArn,
    bffFunctionArn: backend.bffFunctionArn,
    description: `${config.prefix} ${config.envName} GitHub Actions deploy role`,
  })

  for (const [k, v] of Object.entries(config.tags)) {
    Tags.of(app).add(k, v)
  }
}

④ cdk/lambda-placeholder/index.mjs

CDKはLambdaリソースを定義する以上、何かしらcode:プロパティを渡す必要があります。
そのため、最小ハンドラだけを渡しておき、本物のコードはGitHub Actionsがaws lambda update-function-codeで後から上書きするという運用にしています。

cdk/lambda-placeholder/index.mjs
// ============================================================
// Lambda Placeholder Handler (BFF 初期化用)
// ------------------------------------------------------------
// CDK が Lambda 関数を「最初に作る瞬間」だけ必要となる最小ハンドラ。
// 実際の BFF コード(Nitro が `.output/server/` に吐いたもの)は、
// バックエンドデプロイの GitHub Actions ワークフローが
// `aws lambda update-function-code` で**この placeholder を上書き**する運用。
//
// なぜ placeholder を分けるか:
//   1. インフラ(IAM Role / API Gateway / Log Group / CloudFront Behavior)と
//      アプリコードは更新頻度が桁違い。CDK は前者だけを管理し、後者は
//      ライトウェイトな GitHub Actions ワークフローで頻繁にデプロイできる。
//   2. CDK 経由で毎回 `pnpm build` の成果物を zip 化・アップロードすると
//      `cdk deploy` の所要時間が伸び、ロールバック単位も粗くなる。
//   3. CloudFormation がコード変更で関数を再デプロイする際に発生する
//      短時間の 5xx を、計画的なバックエンドデプロイにだけ集中させる。
//
// 503 を返している理由:
//   このハンドラが実際に呼ばれた場合 = 「インフラは出来たがコードが
//   まだデプロイされていない」状態。クライアントには「一時的に利用不能」
//   であることを Retry-After 付きで明示し、リトライ可能性を伝える。
// ============================================================

/**
 * @type {(event: import('aws-lambda').APIGatewayProxyEventV2) =>
 *   Promise<import('aws-lambda').APIGatewayProxyResultV2>}
 */
export const handler = async () => {
  return {
    statusCode: 503,
    headers: {
      'content-type': 'application/json; charset=utf-8',
      'retry-after': '60',
    },
    body: JSON.stringify({
      message:
        'BFF Lambda placeholder. Application code has not been deployed yet. ' +
        'Run the deploy-backend GitHub Actions workflow to ship the Nitro bundle.',
    }),
  }
}

⑤ cdk/lib/constructs/backend-api.ts

BFF(Backend for Frontend)のAWSインフラ一式を1つのConstructにまとめたファイルです。

cdk/lib/constructs/backend-api.ts
import * as path from 'node:path'
import { Duration, Fn, RemovalPolicy, Stack } from 'aws-cdk-lib'
import { Construct } from 'constructs'
import {
  Architecture,
  CfnPermission,
  Code,
  Function as LambdaFunction,
  Runtime,
  Tracing,
} from 'aws-cdk-lib/aws-lambda'
import type { IFunction } from 'aws-cdk-lib/aws-lambda'
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'
import { CfnIntegration, CfnRoute, HttpApi } from 'aws-cdk-lib/aws-apigatewayv2'
import type { CfnApi, IHttpApi } from 'aws-cdk-lib/aws-apigatewayv2'
import {
  AwsCustomResource,
  AwsCustomResourcePolicy,
  PhysicalResourceId,
} from 'aws-cdk-lib/custom-resources'

export interface BackendApiProps {
  /** リソース名プレフィックス(例: '<project-name>-liff') */
  prefix: string
  /** 環境名(例: 'dev' | 'stg' | 'prod') */
  envName: string
  /**
   * LIFF ID Token の `aud` クレーム検証で使用する LINE Login Channel ID。
   * 機密度は低いが、クライアントには公開しないため Lambda 環境変数として注入する。
   * 値は GitHub Variables / CDK context 経由で synth 時に渡す。
   * 空文字の場合は middleware が 500 を返すので未設定でデプロイ自体は通る。
   */
  lineLoginChannelId: string
  /** Lambda コードの保持ポリシー(dev/stg は destroy、prod は retain 想定) */
  removalPolicy: RemovalPolicy
}

/** 本番トラフィックを受ける Lambda Alias 名。GitHub Actions が張り替える対象。 */
export const BFF_LIVE_ALIAS_NAME = 'live'

/**
 * BFF(Backend for Frontend)の AWS リソース一式を構築する Construct。
 *
 * 構成:
 *   - Lambda function (Node.js 22.x / arm64)
 *   - Lambda Version(CDK が placeholder のものだけ作成。実 Version は GHA が
 *     publish-version で発行する。CDK 上は currentVersion を RETAIN で保持)
 *   - Lambda Alias `live`(**所有権は GHA**。CDK は AwsCustomResource の
 *     onCreate でブートストラップするだけで、以降の `FunctionVersion` 変更には
 *     一切関与しない。これが「CDK が alias を巻き戻さない」設計の核)
 *   - CloudWatch Log Group (retention 30 日)
 *   - API Gateway HTTP API v2 (dualstack IPv6 対応)
 *   - L1 `CfnIntegration` / `CfnRoute` で qualifier 付き ARN(`...:live`)を
 *     IntegrationUri として直接指定
 *   - L1 `CfnPermission` で alias 宛 invoke 許可を API Gateway に付与
 *
 * Alias 所有権の整理(最重要):
 *   - 初回 `cdk deploy` 時、AwsCustomResource が `lambda:createAlias` を呼んで
 *     alias `live` を placeholder v1 を指す状態で作成する。
 *   - 以降 GHA が `update-alias --function-version Vn` で張り替える。
 *   - CDK の CFN テンプレートには **alias リソース自体が存在しない**ため、
 *     後段の `cdk deploy` は alias の `FunctionVersion` を触らない。
 *     → **ドリフトなし。インフラ変更で本番が断絶しない**。
 *   - 削除は CFN の親リソース(Lambda function)削除でカスケード削除される。
 *
 * 他の設計方針:
 *   - **インフラと配信物の分離**: 関数の `code` は本リポジトリの
 *     `cdk/lambda-placeholder/` のみを参照する。実コードは
 *     `deploy-backend` ワークフローが update-function-code で差し替える。
 *   - **arm64 / Graviton2**: x86_64 比でコスト ~20% 安・冷起動も短い。
 *   - **dualstack**: IPv6 経由の到達性も確保。L1 escape hatch で確実に指定。
 *   - **proxy+ ルート 1 本**: Nitro 側に全ルーティングを委ねる。
 *   - **X-Ray active tracing**: 一時障害の切り分けで効くため有効化。
 */
export class BackendApi extends Construct {
  public readonly fn: IFunction
  public readonly httpApi: IHttpApi
  /**
   * Alias の qualifier 付き ARN(`arn:...:function:<name>:live`)。
   * 文字列で扱う。CDK は alias リソースを所有しないため L2 IAlias は持たない。
   */
  public readonly aliasArn: string
  /** API Gateway のデフォルトドメイン名(CloudFront origin として利用する) */
  public readonly apiEndpointDomain: string
  /** API Gateway のフル URL(curl 等での確認用、CfnOutput 向け) */
  public readonly apiEndpointUrl: string

  constructor(scope: Construct, id: string, props: BackendApiProps) {
    super(scope, id)

    // ----------------------------------------------------------
    // Log Group を明示的に作成
    // ----------------------------------------------------------
    // Lambda に Log Group を任せると暗黙で `/aws/lambda/<fn>` が作成され、
    // retention が「無期限」のままになりがち。明示的に 30 日に固定する。
    // ----------------------------------------------------------
    const logGroup = new LogGroup(this, 'BffFunctionLogGroup', {
      logGroupName: `/aws/lambda/${props.prefix}-${props.envName}-bff`,
      retention: RetentionDays.ONE_MONTH,
      removalPolicy: props.removalPolicy,
    })

    // ----------------------------------------------------------
    // Lambda 関数(BFF 本体の入れ物)
    // ----------------------------------------------------------
    const fn = new LambdaFunction(this, 'BffFunction', {
      functionName: `${props.prefix}-${props.envName}-bff`,
      description: `${props.prefix} ${props.envName} BFF (Nitro on Lambda). Code is deployed by GitHub Actions.`,
      runtime: Runtime.NODEJS_22_X,
      architecture: Architecture.ARM_64,
      handler: 'index.handler',
      code: Code.fromAsset(path.join(__dirname, '..', '..', 'lambda-placeholder')),
      memorySize: 256,
      timeout: Duration.seconds(10),
      tracing: Tracing.ACTIVE,
      logGroup,
      environment: {
        // Nuxt の `runtimeConfig.lineLoginChannelId` を実行時に上書きするための env。
        // 規約 `NUXT_<KEY>` に従い、変数名は `NUXT_LINE_LOGIN_CHANNEL_ID` で固定。
        // 別名(例: NUXT_LINE_LOGIN_CHANNEL_ID)で渡しても Nuxt は拾わない。
        NUXT_LINE_LOGIN_CHANNEL_ID: props.lineLoginChannelId,
        NITRO_PRESET: 'aws-lambda',
        NODE_ENV: 'production',
      },
      currentVersionOptions: {
        removalPolicy: RemovalPolicy.RETAIN,
        description:
          'Initial placeholder version (created by CDK). GHA publishes real versions on deploy.',
      },
    })

    // 初回 alias 作成用に placeholder の Version を取得(CDK 管理)。
    // ここで CDK が version 1 を発行し、AwsCustomResource がそれを使って
    // alias を作る。以降 GHA が新 Version を publish しても、CDK は
    // alias の FunctionVersion を変更しない。
    const initialVersion = fn.currentVersion

    // ----------------------------------------------------------
    // 本番トラフィック用 Alias `live` の **初回作成のみ** を担う Custom Resource
    // ----------------------------------------------------------
    // - `onCreate` のみ定義し、`onUpdate` は意図的に定義しない。
    //   これが「CDK が alias を更新しない」契約の本体。
    // - `ignoreErrorCodesMatching: 'ResourceConflictException'` で、
    //   既に alias が存在する場合は no-op として通過する(手動作成や
    //   再デプロイ時の冪等性を担保)。
    // - 削除時は親の Lambda function 削除でカスケードされるため
    //   `onDelete` も指定しない(孤児リソースは発生しない)。
    // ----------------------------------------------------------
    const aliasBootstrap = new AwsCustomResource(this, 'BffLiveAliasBootstrap', {
      resourceType: 'Custom::BffLiveAliasBootstrap',
      onCreate: {
        service: 'Lambda',
        action: 'createAlias',
        parameters: {
          FunctionName: fn.functionName,
          Name: BFF_LIVE_ALIAS_NAME,
          FunctionVersion: initialVersion.version,
          Description:
            "Production traffic alias. Owned by GitHub Actions ('deploy-backend') after CDK bootstrap. " +
            'CDK does not manage FunctionVersion post-create — no drift on subsequent cdk deploys.',
        },
        physicalResourceId: PhysicalResourceId.of(
          `bff-alias-${props.prefix}-${props.envName}-${BFF_LIVE_ALIAS_NAME}`,
        ),
        // 既存 alias 検出時はエラーを無視(冪等)。
        ignoreErrorCodesMatching: 'ResourceConflictException',
      },
      // onUpdate / onDelete は意図的に未指定(CDK が alias を変更しない契約)。
      policy: AwsCustomResourcePolicy.fromSdkCalls({
        // function 本体と Version/Alias 子リソースに対して createAlias を許可
        resources: [fn.functionArn, `${fn.functionArn}:*`],
      }),
    })
    aliasBootstrap.node.addDependency(initialVersion)

    // qualifier 付き alias ARN を組み立てる。CDK は文字列補間でトークンを
    // 解決し、デプロイ時に `arn:...:function:<name>:live` になる。
    const aliasArn = `${fn.functionArn}:${BFF_LIVE_ALIAS_NAME}`

    // ----------------------------------------------------------
    // HTTP API(v2)+ ANY /{proxy+} ルート
    // ----------------------------------------------------------
    // L2 `HttpApi` でステージとドメインを作るが、ルート/統合は L1 で
    // 構築して **qualifier 付き ARN を文字列で直接指定**する。
    // これにより CDK が L2 alias を抱え込まずに済む。
    // ----------------------------------------------------------
    const httpApi = new HttpApi(this, 'BffHttpApi', {
      apiName: `${props.prefix}-${props.envName}-bff-api`,
      description: `${props.prefix} ${props.envName} BFF HTTP API (proxy to Lambda alias '${BFF_LIVE_ALIAS_NAME}')`,
      createDefaultStage: true,
    })

    // IpAddressType=dualstack を L1 escape hatch で強制注入。
    const cfnApi = httpApi.node.defaultChild as CfnApi
    cfnApi.addPropertyOverride('IpAddressType', 'dualstack')

    // ----------------------------------------------------------
    // Integration: AWS_PROXY で alias ARN を invoke
    // ----------------------------------------------------------
    const integration = new CfnIntegration(this, 'BffIntegration', {
      apiId: httpApi.apiId,
      integrationType: 'AWS_PROXY',
      // qualifier 付き ARN を文字列で渡す。これが「CDK は alias を所有しない」
      // 設計の鍵: integration が alias L2 リソースを参照しないため、
      // CFN の差分検出は alias の FunctionVersion を見ない。
      integrationUri: aliasArn,
      payloadFormatVersion: '2.0',
    })
    // alias が存在してから integration をデプロイ
    integration.node.addDependency(aliasBootstrap)

    // ----------------------------------------------------------
    // Route: ANY /{proxy+} → integration
    // ----------------------------------------------------------
    new CfnRoute(this, 'BffProxyRoute', {
      apiId: httpApi.apiId,
      routeKey: 'ANY /{proxy+}',
      target: `integrations/${integration.ref}`,
    })

    // ----------------------------------------------------------
    // Lambda Permission: API Gateway → alias の invoke を許可
    // ----------------------------------------------------------
    // L2 HttpLambdaIntegration は関数宛に自動で Permission を付けてくれるが、
    // 今回は L1 構成なので手動で付与。`FunctionName` を qualifier 付き ARN に
    // することで、alias 経由の呼び出しだけが許可される。
    const invokePermission = new CfnPermission(this, 'BffAliasInvokePermission', {
      action: 'lambda:InvokeFunction',
      functionName: aliasArn,
      principal: 'apigateway.amazonaws.com',
      sourceArn: Stack.of(this).formatArn({
        service: 'execute-api',
        resource: httpApi.apiId,
        resourceName: '*/*',
      }),
    })
    // `functionName` は qualifier 付き ARN を文字列で渡しているだけで CFN の
    // リソース参照ではないため、CloudFormation は alias への依存を推論できない。
    // 初回 deploy で alias 不在のまま AddPermission が走らないよう明示的に依存を張る。
    invokePermission.node.addDependency(aliasBootstrap)

    this.fn = fn
    this.httpApi = httpApi
    this.aliasArn = aliasArn
    this.apiEndpointUrl = httpApi.apiEndpoint
    // apiEndpoint は unresolved token のため JS の .replace() が効かない。
    // CFN の intrinsic Fn::Split + Fn::Select で `https://` を剥がしたドメインを得る。
    this.apiEndpointDomain = Fn.select(2, Fn.split('/', httpApi.apiEndpoint))
  }
}

L2だとCDKがaliasをCFNテンプレートで管理してしまい、cdk deployのたびに GHA(GitHub Action)が張り替えた[live]を元のVersionに巻き戻されてしまいます。それを避けるため、L1で文字列ARNを渡してCDKからLambdaエイリアスを見えなくしています。
CodeDeployを使用するともっとシンプルに実装できるようです

⑥ cdk/lib/constructs/static-site.ts

cdk/lib/constructs/static-site.ts
import { Duration, Stack } from 'aws-cdk-lib'
import type { RemovalPolicy } from 'aws-cdk-lib'
import { Construct } from 'constructs'
import { Bucket, BlockPublicAccess, BucketEncryption, ObjectOwnership } from 'aws-cdk-lib/aws-s3'
import type { IBucket } from 'aws-cdk-lib/aws-s3'
import {
  AllowedMethods,
  CachePolicy,
  CachedMethods,
  Distribution,
  HttpVersion,
  OriginProtocolPolicy,
  OriginRequestPolicy,
  OriginSslPolicy,
  PriceClass,
  S3OriginAccessControl,
  SecurityPolicyProtocol,
  Signing,
  ViewerProtocolPolicy,
} from 'aws-cdk-lib/aws-cloudfront'
import type { BehaviorOptions, IDistribution } from 'aws-cdk-lib/aws-cloudfront'
import { HttpOrigin, S3BucketOrigin } from 'aws-cdk-lib/aws-cloudfront-origins'

export interface StaticSiteProps {
  /** リソース名プレフィックス(例: '<project-name>-liff') */
  prefix: string
  /** 環境名(例: 'dev' | 'stg' | 'prod') */
  envName: string
  /** 永続リソースに適用する削除ポリシー */
  removalPolicy: RemovalPolicy
  /** stack 削除時にバケット中身を空にしてから削除するか(dev/stg のみ true) */
  autoDeleteObjects: boolean
  /**
   * BFF (API Gateway HTTP API) のドメイン名(例:
   * `xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com`)。
   * 渡された場合、CloudFront に `/api/*` の behavior を追加して
   * このドメインを origin として使う。未指定なら API behavior を追加しない
   * (= フロントエンド単独デプロイ可能)。
   */
  apiOriginDomain?: string
}

/**
 * S3(プライベート)+ CloudFront(OAC)の静的サイト構成。SPA ホスティングに最適。
 *
 * - Bucket: BlockPublicAccess、SSE-S3、BucketOwnerEnforced、TLS 強制
 * - Distribution: Managed CachingOptimized、redirect-to-https、HTTP/2、IPv6
 * - SPA エラーマッピング: 403/404 → /index.html(200 で応答)
 * - apiOriginDomain が指定された場合: `/api/*` を HTTP API(Lambda 統合)に
 *   ルーティングする behavior を追加(CachingDisabled + AllViewerExceptHostHeader)
 */
export class StaticSite extends Construct {
  public readonly bucket: IBucket
  public readonly distribution: IDistribution

  constructor(scope: Construct, id: string, props: StaticSiteProps) {
    super(scope, id)

    const account = Stack.of(this).account

    const bucket = new Bucket(this, 'Bucket', {
      bucketName: `${props.prefix}-${props.envName}-static-${account}`,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
      encryption: BucketEncryption.S3_MANAGED,
      objectOwnership: ObjectOwnership.BUCKET_OWNER_ENFORCED,
      enforceSSL: true,
      removalPolicy: props.removalPolicy,
      autoDeleteObjects: props.autoDeleteObjects,
      versioned: false,
      // `aws s3 sync` 失敗時に残る未完了 multipart upload を自動回収する。
      // 設定しないと孤児パートが残り続けてストレージ課金が止まらない。
      lifecycleRules: [
        {
          id: 'AbortIncompleteMultipartUploads',
          enabled: true,
          abortIncompleteMultipartUploadAfter: Duration.days(7),
        },
      ],
    })

    const oac = new S3OriginAccessControl(this, 'Oac', {
      originAccessControlName: `${props.prefix}-${props.envName}-oac`,
      signing: Signing.SIGV4_ALWAYS,
    })

    // ------------------------------------------------------------
    // /api/* 用 additional behavior(BFF が設定されていれば)
    // ------------------------------------------------------------
    // - CachePolicy: Managed `CachingDisabled`。API レスポンスは
    //   ユーザーごとに異なるためエッジにキャッシュしない。
    // - OriginRequestPolicy: Managed `AllViewerExceptHostHeader`。
    //   Authorization を含む全ヘッダ・Cookie・クエリを origin に転送する一方、
    //   Host だけは API Gateway 側に向ける(必須)。
    // - AllowedMethods: ALL(POST/PUT/PATCH/DELETE 等の追加にも備える)。
    // ------------------------------------------------------------
    const additionalBehaviors: Record<string, BehaviorOptions> = {}
    if (props.apiOriginDomain) {
      additionalBehaviors['/api/*'] = {
        origin: new HttpOrigin(props.apiOriginDomain, {
          protocolPolicy: OriginProtocolPolicy.HTTPS_ONLY,
          originSslProtocols: [OriginSslPolicy.TLS_V1_2],
          httpsPort: 443,
          // API Gateway 直は keep-alive が短いのでデフォルトのままで良い。
          // 必要に応じて読み込みタイムアウトを調整可。
          readTimeout: Duration.seconds(30),
        }),
        viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        allowedMethods: AllowedMethods.ALLOW_ALL,
        cachedMethods: CachedMethods.CACHE_GET_HEAD,
        cachePolicy: CachePolicy.CACHING_DISABLED,
        originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
        compress: true,
      }
    }

    const distribution = new Distribution(this, 'Distribution', {
      comment: `${props.prefix}-${props.envName} static site`,
      defaultRootObject: 'index.html',
      enabled: true,
      enableIpv6: true,
      httpVersion: HttpVersion.HTTP2,
      priceClass: PriceClass.PRICE_CLASS_ALL,
      minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_2_2021,
      defaultBehavior: {
        origin: S3BucketOrigin.withOriginAccessControl(bucket, {
          originAccessControl: oac,
        }),
        viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        allowedMethods: AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: CachedMethods.CACHE_GET_HEAD,
        cachePolicy: CachePolicy.CACHING_OPTIMIZED,
        compress: true,
      },
      additionalBehaviors,
      errorResponses: [
        {
          httpStatus: 403,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: Duration.seconds(0),
        },
        {
          httpStatus: 404,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
          ttl: Duration.seconds(0),
        },
      ],
    })

    this.bucket = bucket
    this.distribution = distribution
  }
}

⑦ cdk/lib/stacks/backend-stack.ts

BackendApi Constructを環境ごとに1個ずつデプロイ可能な独立スタックとして作成するだけのファイルです。

cdk/lib/stacks/backend-stack.ts
import { CfnOutput, RemovalPolicy, Stack } from 'aws-cdk-lib'
import type { StackProps } from 'aws-cdk-lib'
import type { Construct } from 'constructs'
import type { IFunction } from 'aws-cdk-lib/aws-lambda'
import type { EnvConfig } from '../../config/types'
import { BackendApi, BFF_LIVE_ALIAS_NAME } from '../constructs/backend-api'

export interface BackendStackProps extends StackProps {
  config: EnvConfig
  /**
   * LIFF ID Token の `aud` クレーム検証で使う LINE Login Channel ID。
   * `bin/app.ts` で `process.env.NUXT_LINE_LOGIN_CHANNEL_ID` から渡される。
   * Lambda には `NUXT_LINE_LOGIN_CHANNEL_ID` env として注入され、Nuxt の
   * runtime override 規約により `useRuntimeConfig().lineLoginChannelId` へ反映される。
   * 空文字でも synth は通る(Lambda env が空のままデプロイされ、middleware が 500 を返す)。
   */
  lineLoginChannelId: string
}

/**
 * BFF(Lambda + API Gateway HTTP API + Alias)の環境別 Stack。
 *
 * `FrontendStack` とは独立して deploy / destroy できるよう分離してある。
 * `FrontendStack` は本 Stack の `apiEndpointDomain` を props 経由で受け取り、
 * CloudFront の `/api/*` behavior に紐付ける。
 *
 * Alias 所有権:
 *   - 初回 `cdk deploy` 時に AwsCustomResource が createAlias し、
 *     placeholder v1 を指す `live` alias をブートストラップする
 *   - 以降の `update-alias` は GitHub Actions の `deploy-backend` ワークフローが
 *     publish-version → update-alias で実行する
 *   - CDK は alias の `FunctionVersion` プロパティを CFN テンプレートに
 *     持たないため、後続の `cdk deploy` は alias を**触らない**
 *     (= インフラ変更で本番が断絶しない)
 *   - ロールバックは `aws lambda update-alias --function-version <prev>` 1 行
 */
export class BackendStack extends Stack {
  /** Lambda 関数(GithubOidcStack が IAM resource として参照) */
  public readonly bffFunction: IFunction
  /** Lambda 関数名(GitHub Variables LAMBDA_FUNCTION_NAME に登録) */
  public readonly bffFunctionName: string
  /** Lambda 関数 ARN */
  public readonly bffFunctionArn: string
  /** live alias の qualifier 付き ARN(運用ツール / GHA が直接参照する) */
  public readonly bffAliasArn: string
  /** HTTP API の execute-api ドメイン(CloudFront origin domain として使う) */
  public readonly apiEndpointDomain: string
  /** HTTP API のフル URL(curl 確認用) */
  public readonly apiEndpointUrl: string

  constructor(scope: Construct, id: string, props: BackendStackProps) {
    super(scope, id, props)
    const { config, lineLoginChannelId } = props

    const api = new BackendApi(this, 'Bff', {
      prefix: config.prefix,
      envName: config.envName,
      lineLoginChannelId,
      removalPolicy:
        config.removalPolicy === 'retain' ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
    })

    this.bffFunction = api.fn
    this.bffFunctionName = api.fn.functionName
    this.bffFunctionArn = api.fn.functionArn
    this.bffAliasArn = api.aliasArn
    this.apiEndpointDomain = api.apiEndpointDomain
    this.apiEndpointUrl = api.apiEndpointUrl

    new CfnOutput(this, 'BffFunctionName', {
      value: this.bffFunctionName,
      exportName: `${id}-BffFunctionName`,
      description: 'Pass this to GitHub Actions vars.LAMBDA_FUNCTION_NAME',
    })
    new CfnOutput(this, 'BffFunctionArn', {
      value: this.bffFunctionArn,
      exportName: `${id}-BffFunctionArn`,
    })
    new CfnOutput(this, 'BffAliasName', {
      value: BFF_LIVE_ALIAS_NAME,
      description: 'Lambda alias name that receives production traffic',
    })
    new CfnOutput(this, 'BffAliasArn', {
      value: this.bffAliasArn,
      exportName: `${id}-BffAliasArn`,
      description: 'Lambda alias ARN invoked by API Gateway. Rollback target for ops scripts.',
    })
    new CfnOutput(this, 'ApiEndpointDomain', {
      value: this.apiEndpointDomain,
      description: 'HTTP API default domain (used as CloudFront origin)',
    })
    new CfnOutput(this, 'ApiEndpointUrl', {
      value: this.apiEndpointUrl,
      description: 'HTTP API full URL (for direct curl verification)',
    })
  }
}

⑧ cdk/lib/stacks/frontend-stack.ts

cdk/lib/stacks/frontend-stack.ts
import { CfnOutput, RemovalPolicy, Stack } from 'aws-cdk-lib'
import type { StackProps } from 'aws-cdk-lib'
import type { Construct } from 'constructs'
import type { EnvConfig } from '../../config/types'
import { StaticSite } from '../constructs/static-site'

export interface FrontendStackProps extends StackProps {
  config: EnvConfig
  /**
   * BFF(HTTP API)のドメイン名。指定された場合 CloudFront に `/api/*` の
   * additional behavior を追加し、Lambda 統合に転送する。
   * 未指定なら静的サイトのみの構成(BFF 切り離し運用)。
   */
  apiOriginDomain?: string
}

/**
 * 環境別の静的サイトインフラ: S3 + CloudFront(OAC) を構築する Stack。
 * BFF が連携される場合は CloudFront に `/api/*` behavior を追加して
 * 同一オリジンでフロント+BFF を提供する。
 */
export class FrontendStack extends Stack {
  public readonly bucketName: string
  public readonly bucketArn: string
  public readonly distributionId: string
  public readonly distributionArn: string
  public readonly distributionDomainName: string

  constructor(scope: Construct, id: string, props: FrontendStackProps) {
    super(scope, id, props)
    const { config, apiOriginDomain } = props

    const site = new StaticSite(this, 'Site', {
      prefix: config.prefix,
      envName: config.envName,
      removalPolicy:
        config.removalPolicy === 'retain' ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
      autoDeleteObjects: config.autoDeleteObjects,
      apiOriginDomain,
    })

    this.bucketName = site.bucket.bucketName
    this.bucketArn = site.bucket.bucketArn
    this.distributionId = site.distribution.distributionId
    this.distributionArn = `arn:${this.partition}:cloudfront::${this.account}:distribution/${site.distribution.distributionId}`
    this.distributionDomainName = site.distribution.distributionDomainName

    new CfnOutput(this, 'BucketName', {
      value: this.bucketName,
      exportName: `${id}-BucketName`,
    })
    new CfnOutput(this, 'DistributionId', {
      value: this.distributionId,
      exportName: `${id}-DistributionId`,
    })
    new CfnOutput(this, 'DistributionDomainName', {
      value: this.distributionDomainName,
      description: 'CloudFront default domain (e.g. dxxxx.cloudfront.net)',
    })
  }
}

⑨ cdk/lib/stacks/github-oidc-stack.ts

cdk/lib/stacks/github-oidc-stack.ts
import { CfnOutput, Duration, Stack } from 'aws-cdk-lib'
import type { StackProps } from 'aws-cdk-lib'
import type { Construct } from 'constructs'
import {
  Effect,
  FederatedPrincipal,
  OpenIdConnectProvider,
  PolicyStatement,
  Role,
} from 'aws-cdk-lib/aws-iam'
import type { EnvConfig } from '../../config/types'

export interface GithubOidcStackProps extends StackProps {
  config: EnvConfig
  /** アカウントベースの GitHub OIDC Provider ARN(BaseStack から渡される) */
  oidcProviderArn: string
  /** この Role が sync 対象とする S3 バケットの ARN */
  bucketArn: string
  /** この Role が invalidation を許可される CloudFront Distribution の ARN */
  distributionArn: string
  /**
   * この Role が update-function-code を許可される Lambda 関数の ARN。
   * 指定された場合のみ Lambda 系ポリシーを付与する(BFF を持たない構成への
   * 後方互換)。
   */
  bffFunctionArn?: string
}

/**
 * 環境別の GitHub Actions デプロイ Role を作成する Stack。
 *
 * Trust:
 *   federated = token.actions.githubusercontent.com の OIDC Provider
 *   aud       = sts.amazonaws.com
 *   sub       = config.github.subjects 各要素について repo:<owner>/<repo>:<subject>
 *
 * 権限:
 *   - 環境の S3 バケットへの sync(List/Get/Put/Delete object + multipart 系)
 *   - 環境の CloudFront Distribution の invalidation
 *   - (bffFunctionArn が指定されていれば)対象 Lambda 関数のコード更新と
 *     関連メタ操作
 */
export class GithubOidcStack extends Stack {
  public readonly deployRoleArn: string

  constructor(scope: Construct, id: string, props: GithubOidcStackProps) {
    super(scope, id, props)
    const { config, oidcProviderArn, bucketArn, distributionArn, bffFunctionArn } = props

    const provider = OpenIdConnectProvider.fromOpenIdConnectProviderArn(
      this,
      'GitHubOidcProvider',
      oidcProviderArn,
    )

    const subjectClaims = config.github.subjects.map(
      (sub) => `repo:${config.github.owner}/${config.github.repo}:${sub}`,
    )

    const role = new Role(this, 'DeployRole', {
      roleName: `${config.prefix}-${config.envName}-github-deploy`,
      description: `GitHub Actions deploy role for ${config.prefix} ${config.envName}`,
      maxSessionDuration: Duration.hours(1),
      // `sub` には StringLike ではなく StringEquals を使う。これにより
      // wildcard が解釈されない。config.github.subjects は完全一致の
      // subject claim 一覧(例: 'environment:prod')を想定しており、
      // 将来 '*' を含むエントリが紛れ込んでも StringLike のように暗黙的に
      // 権限が広がる事故を防げる。
      assumedBy: new FederatedPrincipal(
        provider.openIdConnectProviderArn,
        {
          StringEquals: {
            'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
            'token.actions.githubusercontent.com:sub': subjectClaims,
          },
        },
        'sts:AssumeRoleWithWebIdentity',
      ),
    })

    role.addToPolicy(
      new PolicyStatement({
        sid: 'S3BucketLevel',
        effect: Effect.ALLOW,
        actions: [
          's3:ListBucket',
          's3:GetBucketLocation',
          // `aws s3 sync` が実行中 multipart upload を列挙して
          // abort/resume を判断するために必要。
          's3:ListBucketMultipartUploads',
        ],
        resources: [bucketArn],
      }),
    )

    role.addToPolicy(
      new PolicyStatement({
        sid: 'S3ObjectLevel',
        effect: Effect.ALLOW,
        actions: [
          's3:GetObject',
          's3:PutObject',
          's3:DeleteObject',
          's3:GetObjectVersion',
          // `aws s3 sync` は約 8 MB を超えるファイルで multipart upload に
          // 切り替わる。これらが無いとアップロード失敗時に孤児パートが残り、
          // ストレージ課金が発生し続けるうえデプロイ Role 自身では掃除できない。
          's3:AbortMultipartUpload',
          's3:ListMultipartUploadParts',
        ],
        resources: [`${bucketArn}/*`],
      }),
    )

    role.addToPolicy(
      new PolicyStatement({
        sid: 'CloudFrontInvalidate',
        effect: Effect.ALLOW,
        actions: [
          'cloudfront:CreateInvalidation',
          'cloudfront:GetInvalidation',
          'cloudfront:GetDistribution',
        ],
        resources: [distributionArn],
      }),
    )

    if (bffFunctionArn) {
      // ------------------------------------------------------------
      // Lambda コード更新 + Version/Alias 切替権限
      // ------------------------------------------------------------
      // BFF を持つ環境のみ、対象 Lambda 関数とその子リソース(Version / Alias)に
      // 限定してデプロイ操作を許可する。
      //
      // 想定する GHA フロー:
      //   1. update-function-code            ($LATEST に新コード)
      //   2. update-function-configuration   ($LATEST に新 env)
      //   3. publish-version                  ($LATEST snapshot → Vn を発行)
      //   4. update-alias --function-version Vn ('live' を Vn に張り替え)
      //
      // resources に `${arn}:*` を加えるのは、Version (`arn:...:fn-name:7`) や
      // Alias (`arn:...:fn-name:live`) を IAM 上のリソースとして個別に許可する
      // ため。`update-alias` や `publish-version` は qualifier 付き ARN を
      // 内部で扱う実装になっている。
      // ------------------------------------------------------------
      role.addToPolicy(
        new PolicyStatement({
          sid: 'LambdaUpdate',
          effect: Effect.ALLOW,
          actions: [
            // $LATEST 上での操作
            'lambda:UpdateFunctionCode',
            'lambda:UpdateFunctionConfiguration',
            'lambda:GetFunction',
            'lambda:GetFunctionConfiguration',
            // Version / Alias の操作
            'lambda:PublishVersion',
            'lambda:ListVersionsByFunction',
            'lambda:GetAlias',
            'lambda:UpdateAlias',
          ],
          // 関数本体と、その配下の Version / Alias(`fn-name:*`)を両方カバー
          resources: [bffFunctionArn, `${bffFunctionArn}:*`],
        }),
      )
    }

    this.deployRoleArn = role.roleArn

    new CfnOutput(this, 'DeployRoleArn', {
      value: this.deployRoleArn,
      exportName: `${id}-DeployRoleArn`,
      description: 'Pass this to GitHub Actions vars.AWS_DEPLOY_ROLE_ARN',
    })
  }
}

実行方法

①依存関係のインストール

まずばcdkディレクトリに移動し、依存関係をインストールします。

zsh
cd cdk

npm install

②設定ファイルの編集

次に、config/dev/stg/prodのファイルを編集し、必要な値を入力します(AWSアカウントID等)

③SSOログイン

デプロイ対象のAWSアカウントでSSOログインします。

zsh
aws sso login --profile <your-profile-name>

以下のコマンドを実行し、出力結果のAWSアカウントIDが、config/dev/stg/prodのファイルに設定したIDと一致していることを確認します。

zsh
aws sts get-caller-identity --profile <your-profile-name>

④Bootstrap

スタックを作成するために必要なCDK Bootstrapを行います(各アカウント × 各リージョンで初回 1 回のみ)
npm runを使用することでcdkコマンドのバージョンを統一します。
(オプション引数を使用する場合に--が必要になります)

/cdk
npm run bootstrap -- --profile <your-profile-name> --context env=dev

⑤スタック(AWSリソース)のデプロイ

以下のコマンドを実行し、実際にスタックをデプロイします。

/cdk
npm run deploy:dev -- --profile <your-profile-name>

⑥GitHub Actions連携

ターミナル上に「Outputs:」として出力された値を、デプロイ対象のGitHubリポジトリのEnvironmentsに登録していきます。

SCR-20260520-paxk.png

※バックエンドが追加された分、登録する変数も増えています。

変数名 使用ワークフロー
AWS_DEPLOY_ROLE_ARN フロントエンド/バックエンド arn:aws:iam::<env-account>:role/<project-name>-liff-dev-github-deploy
AWS_REGION フロントエンド/バックエンド ap-northeast-1
LIFF_ID フロントエンド/バックエンド 2001234567-asdfghjk
S3_BUCKET_NAME フロントエンド <project-name>-liff-dev-static-<env-account>
CLOUDFRONT_DISTRIBUTION_ID フロントエンド E1A2B3C4D
LAMBDA_FUNCTION_NAME バックエンド <project-name>-liff-dev-bff
NUXT_LINE_LOGIN_CHANNEL_ID バックエンド 2001234567
CLOUDFRONT_DOMAIN_NAME バックエンド d12345asdfg.cloudfront.net

その後、GitHub Actionsのワークフローを実行してフロントエンドのコンテンツをアップロードすれば完了です!
このあたりは手動での設定が必要となるため、前回の記事をご参照ください(スクリプトで自動化するのもありです)

.output/server/は単なるserver/ディレクトリのコピーではなく、Nuxtアプリ全体をサーバー実行可能にした成果物です。SPAかSSRか、フロントエンドとバックエンドが完全に分離されているかどうか、などによっても内容は変わるため、フロントエンドのソースコードを修正したらフロントエンドのみを更新すればよい、というわけではありません。

ワークフローを1つにまとめると、フロントエンドのデザイン微調整のたびにLambdaのコールドスタートが走る等の不必要な副作用が生じてしまいます。
ワークフロー分離は、「Lambda Versionの無駄な発行を避ける」「ロールバック単位を分ける」といったメリットがあります。

ソースコード更新時(ワークフロー実行時)の判断基準:

  • server/ のみ → backend
  • app/, components/, pages/, composables/ のみ → frontend
  • nuxt.config.ts, runtimeConfig, plugin, middleware, トップレベル utils/ → 両方
  • 迷ったら両方(安全)

⑦スタックの削除

以下のコマンドを実行します。
確認プロンプトをスキップしたい場合は --force(または -f)を付けます。

/cdk
npm run destroy:dev -- --profile <your-profile-name>

dev/stg環境の場合、CDKToolkitを除く、「今回のcdkによって作成されたすべてのAWSリソース」が削除されます。

今回は以上になります!

0
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?