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?

はじめに

今回は、前回の記事でAWSコンソール上で手動で設定した、(RDS x KMS x Secrets Manager)構成を、AWS CDKを使ってコード化してみました(既存のCDKコードを編集しています)
実装順に関連記事を公開していますので、ぜひ過去の記事も合わせてご参照ください。

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

cdk/
├── bin/
│   └── app.ts ①                    (変更)
├── config/
│   ├── dev.ts ②                    (変更)
│   ├── stg.ts ③                    (変更)
│   ├── prod.ts ④                   (変更)
│   └── types.ts ⑤                  (変更)
└── lib/
  ├── constructs/
  │   └── backend-api.ts ⑥          (変更)
  └── stacks/
      ├── backend-stack.ts ⑦        (変更)
      ├── github-oidc-stack.ts ⑧    (変更)
      └── database-stack.ts ⑨       (追加)

.github/
└── workflows/
    ├── deploy-backend.yml ⑩        (変更)
    └── db-operations.yml ⑪         (追加)

各ファイル内容

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

① cdk/bin/app.ts (変更)

(変更内容)
新たにDatabaseStackを新規追加(ファイルは後述)し、BackendStackより先に作成→BackendStackへDB情報を受け渡しています(BFF LambdaにData API接続用のenvと最小権限rds-data + Secret読み取りを配線)
GithubOidcStackにもDB情報を受け渡すことで、GitHub ActionワークフローによるDB操作を可能にしています。

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 { DatabaseStack } from '../lib/stacks/database-stack'
import { BackendStack } from '../lib/stacks/backend-stack'
import { CertificateStack } from '../lib/stacks/certificate-stack'
import { DnsStack } from '../lib/stacks/dns-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` は CDK では扱わない。GitHub Environment の Variable
  // として管理し、`deploy-backend` ワークフローが Lambda env にマージして設定する
  // (キー所有者分離: CDK は DB/NITRO/NODE_ENV、CI は 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)`,
  })

  // データベース(Aurora Serverless v2 + Data API)。BackendStack より先に作り、
  // cluster / dbName を渡して BFF Lambda に env + Data API 権限を配線する。
  const database = new DatabaseStack(app, `${config.prefix}-${config.envName}-database`, {
    env,
    config,
    description: `${config.prefix} ${config.envName} Aurora PostgreSQL Serverless v2 + Data API`,
  })

  // BFF(Lambda + HTTP API)。FrontendStack より先に作って CloudFront に
  // origin として渡せるようにする。DatabaseStack の cluster を渡し、Lambda に
  // Data API 接続 env と最小権限(rds-data + Secret 読み取り)を付与する。
  const backend = new BackendStack(app, `${config.prefix}-${config.envName}-backend`, {
    env,
    config,
    database: {
      clusterArn: database.clusterArn,
      secretArn: database.secretArn,
      encryptionKeyArn: database.encryptionKeyArn,
      databaseName: database.databaseName,
    },
    description: `${config.prefix} ${config.envName} BFF (Lambda + API Gateway HTTP API)`,
  })

  // --- 独自ドメイン構成判定 ---------------------------------------------------
  // domain.enabled が true かつ aliases に値があるときだけ、Route 53 + ACM フローを
  // 有効化する。どちらか欠ければ CloudFront デフォルトドメインのみで公開し、
  // Cert/Dns Stack は構築しない(コスト・運用ともに最小構成のまま)。
  // -------------------------------------------------------------------------
  const domainEnabled = config.domain?.enabled === true && (config.domain?.aliases?.length ?? 0) > 0

  // ACM 証明書 (us-east-1)。CloudFront は us-east-1 の証明書しか受け付けないため、
  // 他リソース (ap-northeast-1) とリージョンを分けて立てる。Stack 間の参照は
  // `crossRegionReferences: true` により SSM Parameter 経由で自動配線される。
  let certStack: CertificateStack | undefined
  if (domainEnabled) {
    certStack = new CertificateStack(app, `${config.prefix}-${config.envName}-certificate`, {
      env: { account: config.account, region: 'us-east-1' },
      crossRegionReferences: true,
      prefix: config.prefix,
      envName: config.envName,
      domain: config.domain!,
      description: `${config.prefix} ${config.envName} ACM certificate for CloudFront (us-east-1)`,
    })
  }

  // 環境別の静的サイト(S3 + CloudFront)に BFF オリジンを連携。
  // 独自ドメイン有効時は ACM 証明書をクロスリージョンで受け取り、CloudFront に
  // Alternate Domain Name と一緒に紐付ける。
  const frontend = new FrontendStack(app, `${config.prefix}-${config.envName}-frontend`, {
    env,
    crossRegionReferences: domainEnabled,
    config,
    apiOriginDomain: backend.apiEndpointDomain,
    certificate: certStack?.certificate,
    description: `${config.prefix} ${config.envName} static site (S3 + CloudFront)`,
  })

  // Route 53 に CloudFront を指す A/AAAA Alias レコードを作成する Stack。
  // FrontendStack の後に立てる(distribution を参照するため)。Hosted Zone は
  // CDK 配下に作らず、Route 53 コンソールで事前作成された Zone を fromLookup する。
  if (domainEnabled) {
    new DnsStack(app, `${config.prefix}-${config.envName}-dns`, {
      env,
      prefix: config.prefix,
      envName: config.envName,
      domain: config.domain!,
      distribution: frontend.distribution,
      description: `${config.prefix} ${config.envName} Route 53 alias records`,
    })
  }

  // 環境別の GitHub Actions Role(base + frontend + backend + database に依存)。
  // deploy Role に加え、database を渡すことで DB 操作専用 Role(DbOpsRole)も作る。
  new GithubOidcStack(app, `${config.prefix}-${config.envName}-github-oidc`, {
    env,
    config,
    oidcProviderArn: base.githubOidcProviderArn,
    bucketArn: frontend.bucketArn,
    distributionArn: frontend.distributionArn,
    bffFunctionArn: backend.bffFunctionArn,
    database: {
      clusterArn: database.clusterArn,
      secretArn: database.secretArn,
      encryptionKeyArn: database.encryptionKeyArn,
    },
    description: `${config.prefix} ${config.envName} GitHub Actions roles (deploy + DB ops)`,
  })

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

②③④ cdk/config/dev(stg,prod).ts (変更)

(変更内容)
Aurora DB用の設定db:{}を追加しています。

cdk/config/dev(stg,prod).ts
import type { EnvConfig } from './types'

export const devConfig: EnvConfig = {
  envName: 'dev',
  account: '<aws-account-id>',
  region: 'ap-northeast-1',
  prefix: '<project-name>-liff',
  github: {
    owner: 'organization-name',
    repo: 'repo-name',
    subjects: ['environment:dev'],
  },
  tags: {
    Project: '<project-name>-liff',
    Environment: 'dev',
    ManagedBy: 'cdk',
  },
  removalPolicy: 'destroy',
  autoDeleteObjects: true,
  // dev: 常時待機 min 0.5 ACU(auto-pause なし)。PITR 最大保持。
  db: {
    databaseName: '<db-name>',
    minCapacityAcu: 0.5,
    maxCapacityAcu: 2, // prodは8
    multiAz: false, // prodはtrue
    backupRetentionDays: 35,
  },
  // 独自ドメインを使う場合は enabled を true にし、zoneName / hostedZoneId / aliases を埋める。
  // Hosted Zone は Route 53 コンソールで事前に作成しておくこと(README の手順参照)。
  domain: {
    enabled: false,
    zoneName: 'example.com',
    hostedZoneId: 'REPLACE_ME',
    aliases: ['example.com'],
  },
}

⑤ cdk/config/types.ts (変更)

(変更内容)
DB設定の型定義DatabaseConfigを新規追加し、EnvConfigにdbフィールドを追加しています。

cdk/config/types.ts
export type EnvName = 'dev' | 'stg' | 'prod'

export interface GitHubConfig {
  /** リポジトリを保有する GitHub Organization または User */
  owner: string
  /** リポジトリ名 */
  repo: string
  /**
   * デプロイ Role の AssumeRole を許可する GitHub OIDC subject claim 一覧。
   * 例:
   *   - 'environment:dev'        : GitHub Environment 'dev' 発行のトークン
   *   - 'ref:refs/heads/main'    : main ブランチへの push で発行されたトークン
   *   - 'pull_request'           : pull_request ワークフロー発行のトークン
   * 複数指定した場合は OR 結合される。
   */
  subjects: string[]
}

/**
 * 独自ドメイン(Route 53 + ACM + CloudFront)構成。
 *
 * `enabled` が false、または `aliases` が空であれば、ドメイン関連の Stack
 * (Certificate / Dns)は構築されず、CloudFront のデフォルトドメイン
 * (`dxxxx.cloudfront.net`)のみが公開エンドポイントになる。
 *
 * 設計判断:
 * - Hosted Zone は **事前にコンソールで手動作成 → fromLookup で参照** する前提。
 *   `cdk destroy` で誤って削除し、外部レジストラ側の NS 切替をやり直す事故を防ぐため。
 * - ACM 証明書は CloudFront 要件により **us-east-1 固定** で発行する
 *   (`bin/app.ts` が CertificateStack を `us-east-1` リージョンで生成)。
 * - aliases は CloudFront の Alternate Domain Name、SAN は ACM 証明書の
 *   Subject Alternative Name。両者は通常一致するため `certificateSans` 省略時は
 *   `aliases` をそのまま使う。
 */
export interface DomainConfig {
  /**
   * 独自ドメインフローを有効にするフラグ。
   * false の場合、`aliases` 等の他フィールドの値に関わらず一切構築されない。
   */
  enabled: boolean
  /**
   * Hosted Zone のドメイン名(例: 'example.com')。
   * `fromLookup` の引数として渡される。`HostedZone.fromLookup` は
   * `cdk.context.json` に解決結果をキャッシュするので、synth マシンで
   * 一度 AWS 認証情報があれば以降オフラインでも synth 可能。
   */
  zoneName: string
  /**
   * Hosted Zone ID(省略可)。指定すれば `fromHostedZoneAttributes` で
   * Lookup をバイパスでき、CI 環境で AWS 認証情報なしで synth できる。
   * 推奨: Route 53 で Hosted Zone 作成後、コンソールに表示される ID を控えてここに入れる。
   */
  hostedZoneId?: string
  /**
   * CloudFront の Alternate Domain Name(CNAMEs)に登録する FQDN 群。
   * Route 53 にはこの一覧それぞれに対して A (Alias) レコードが作成される。
   * 例: ['example.com'] / ['dev.example.com'] など。
   * 空配列なら独自ドメインフローはスキップされる(`enabled` と同様の効果)。
   */
  aliases: string[]
  /**
   * ACM 証明書に **追加で** 含める SAN。`aliases` は常に証明書 SAN に含まれる
   * (CloudFront の Alternate Domain Name は必ず証明書 SAN に含まれる必要があるため)。
   * 例: aliases=['example.com'] と certificateSans=['*.example.com'] を組み合わせて
   * 「apex + ワイルドカード」を 1 枚の証明書にまとめる、など。
   * 省略時は `aliases` のみが証明書に登録される。
   */
  certificateSans?: string[]
}

/**
 * データベース(Aurora PostgreSQL Serverless v2 + RDS Data API)構成。
 *
 * 接続方式は RDS Data API(HTTPS / IAM 認可)で、Lambda は非VPC のまま維持する。
 * 認証情報は Secrets Manager(KMS CMK 暗号化)に置き、Data API には Secret ARN を渡す。
 * ローカル開発では別途 Docker の PostgreSQL を使い、この構成は本番/stg のみに効く。
 */
export interface DatabaseConfig {
  /** 既定データベース名(ローカルと揃える) */
  databaseName: string
  /**
   * Serverless v2 の最小 ACU。`0` を指定すると auto-pause(キャンペーン期間外に
   * 自動停止)。dev/stg は 0、prod は 0.5 を推奨。0 は対応エンジンバージョン前提。
   */
  minCapacityAcu: number
  /** Serverless v2 の最大 ACU。実測ピークに合わせる。 */
  maxCapacityAcu: number
  /**
   * auto-pause までのアイドル秒数(300〜86400)。`minCapacityAcu === 0` のときのみ有効。
   * 省略時は auto-pause を有効化しない(min 0 でも停止させたくない場合)。
   */
  autoPauseSeconds?: number
  /** リーダーインスタンスを追加して Multi-AZ 化する(prod 推奨)。 */
  multiAz: boolean
  /** 自動バックアップ / PITR の保持日数(1〜35)。 */
  backupRetentionDays: number
}

export interface EnvConfig {
  envName: EnvName
  /**
   * この環境の AWS アカウント ID。必須。
   *
   * 本プロジェクトは「dev/stg/prod を別アカウントに分離する」マルチアカウント構成を
   * 前提としており、誤って別環境のリソースに触れないようアカウント単位で隔離する。
   * `bin/app.ts` は synth 時にこの値と `CDK_DEFAULT_ACCOUNT` を突き合わせ、
   * 不一致なら synth 自体を拒否する。
   *
   * 実アカウントが未割当の段階では文字列 'REPLACE_ME' をプレースホルダとして
   * 入れておく。その場合 synth 時にガードがエラーで停止する。
   */
  account: string
  region: string
  /** リソース名プレフィックス(例: '<project-name>-liff') */
  prefix: string
  github: GitHubConfig
  /** App 配下のすべてのリソースに付与するタグ */
  tags: Record<string, string>
  /** S3 など永続リソースに適用する削除ポリシー */
  removalPolicy: 'destroy' | 'retain'
  /** データベース(Aurora Serverless v2 + Data API)構成 */
  db: DatabaseConfig
  /** stack 削除時にバケット中身を自動削除するか(dev/stg のみ true 推奨) */
  autoDeleteObjects: boolean
  /**
   * 独自ドメイン構成(Route 53 + ACM + CloudFront)。
   * 省略 or `enabled: false` の場合は CloudFront デフォルトドメインのみで公開される。
   */
  domain?: DomainConfig
}

⑥ cdk/lib/constructs/backend-api.ts (変更)

(変更内容)
BFF LambdaへDB(Data API)連携用のenv注入と最小権限IAMポリシーの追加を反映しました。

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 { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam'
import {
  AwsCustomResource,
  AwsCustomResourcePolicy,
  PhysicalResourceId,
} from 'aws-cdk-lib/custom-resources'

export interface BackendApiProps {
  /** リソース名プレフィックス(例: '<project-name>-liff') */
  prefix: string
  /** 環境名(例: 'dev' | 'stg' | 'prod') */
  envName: string
  /**
   * 【env の所有者分離】LINE Login Channel ID(`NUXT_LINE_LOGIN_CHANNEL_ID`)は
   * 本 Construct(CDK)では **注入しない**。値は GitHub Environment の Variable として
   * 管理し、`deploy-backend` ワークフローが既存 env に**マージ**して設定する
   * (UI で完結させたい運用方針)。CDK が所有する env キーは DB 接続情報 / NITRO_PRESET /
   * NODE_ENV のみで、channel id とはキーが重ならない(1 キー = 1 writer を維持)。
   */
  /** Lambda コードの保持ポリシー(dev/stg は destroy、prod は retain 想定) */
  removalPolicy: RemovalPolicy
  /**
   * データベース(Aurora + Data API)連携。指定された場合のみ、Lambda に
   * Data API 接続用の env を注入し、Data API + Secret 読み取りの最小権限を付与する。
   * 未指定なら DB 連携なし(DB 導入前の構成との後方互換)。
   */
  database?: {
    /** Aurora クラスタ ARN(Data API resourceArn / rds-data の resource / env DB_CLUSTER_ARN) */
    clusterArn: string
    /** DB 認証情報 Secret ARN(Data API secretArn / env DB_SECRET_ARN) */
    secretArn: string
    /** Secret 暗号化 CMK の ARN(fn に kms:Decrypt を付与する対象) */
    encryptionKeyArn: string
    /** 既定データベース名(Data API の database 引数 / env DB_NAME) */
    databaseName: string
  }
}

/** 本番トラフィックを受ける 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 本体の入れ物)
    // ----------------------------------------------------------
    // データベース連携用の env。RDS Data API ドライバはこれらを process.env から読む
    // (server/db/client.ts)。DB 未連携の構成では空オブジェクトのまま。
    //
    // 【設定の単一 writer 契約】これらの env は CDK が唯一の書き手。コードデプロイ
    // (deploy-backend.yml の update-function-code)は env を一切書き換えない運用にして
    // あるため、後段のコードデプロイで DB 設定が消えることはない(env 全置換の禁止)。
    const dbEnv: Record<string, string> = props.database
      ? {
          // ローカル(pg)ではなく Data API ドライバを使う
          DB_DRIVER: 'data-api',
          DB_CLUSTER_ARN: props.database.clusterArn,
          DB_SECRET_ARN: props.database.secretArn,
          DB_NAME: props.database.databaseName,
        }
      : {}

    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: {
        // CDK が所有する env はここだけ(NITRO_PRESET / NODE_ENV / ...dbEnv)。
        // `NUXT_LINE_LOGIN_CHANNEL_ID` は **意図的に含めない**。これは
        // `deploy-backend` ワークフローが GitHub Variable から既存 env にマージして
        // 設定する(キー所有者分離。CI は channel id だけ、CDK は DB/NITRO/NODE_ENV だけ)。
        NITRO_PRESET: 'aws-lambda',
        NODE_ENV: 'production',
        ...dbEnv,
      },
      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)

    // ----------------------------------------------------------
    // DB 連携時の IAM 最小権限付与(identity ベース)
    // ----------------------------------------------------------
    // cross-stack の依存サイクルを避けるため、grant ヘルパ(cluster.grantDataApiAccess /
    // secret.grantRead)は使わず、ARN 文字列に対する identity ポリシーを fn ロールへ直接張る。
    //   1. rds-data: Data API の実行(クラスタ ARN に限定)。トランザクション系も含む。
    //   2. secretsmanager: 認証情報 Secret の読み取り(Secret ARN に限定)。
    //   3. kms:Decrypt: Secret は CMK 暗号化のため復号権限が必要(CMK ARN に限定)。
    // 管理用ロールは別途分離する方針(このロールは Data API + Secret 読み取りのみ)。
    // ----------------------------------------------------------
    if (props.database) {
      fn.addToRolePolicy(
        new PolicyStatement({
          sid: 'RdsDataApiExecute',
          effect: Effect.ALLOW,
          actions: [
            'rds-data:ExecuteStatement',
            'rds-data:BatchExecuteStatement',
            'rds-data:BeginTransaction',
            'rds-data:CommitTransaction',
            'rds-data:RollbackTransaction',
          ],
          resources: [props.database.clusterArn],
        }),
      )
      fn.addToRolePolicy(
        new PolicyStatement({
          sid: 'ReadDbSecret',
          effect: Effect.ALLOW,
          actions: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'],
          resources: [props.database.secretArn],
        }),
      )
      fn.addToRolePolicy(
        new PolicyStatement({
          sid: 'DecryptDbSecretKey',
          effect: Effect.ALLOW,
          actions: ['kms:Decrypt'],
          resources: [props.database.encryptionKeyArn],
          // 同一 CMK がストレージと Secret の両方を暗号化しているため、無条件の
          // kms:Decrypt にせず Secrets Manager 経由かつ対象 Secret の復号だけに限定する。
          conditions: {
            StringEquals: {
              'kms:ViaService': `secretsmanager.${Stack.of(this).region}.amazonaws.com`,
              'kms:EncryptionContext:SecretARN': props.database.secretArn,
            },
          },
        }),
      )
    }

    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))
  }
}

⑦ cdk/lib/stacks/backend-stack.ts (変更)

(変更内容)
BackendStackのProps(BackendApiに渡すための)にdatabaseを追加しlineLoginChannelIdは削除しています(lineLoginChannelIdはGitHub Variableで管理/挿入することにしました)

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
  /**
   * データベース(Aurora + Data API)連携情報。`bin/app.ts` が DatabaseStack から
   * 渡す。指定時は BFF Lambda に Data API 用 env + 最小権限が付与される。
   */
  database?: {
    clusterArn: string
    secretArn: string
    encryptionKeyArn: string
    databaseName: string
  }
  // 注: `NUXT_LINE_LOGIN_CHANNEL_ID` は本 Stack(CDK)では扱わない。GitHub Variable として
  // 管理し、`deploy-backend` ワークフローが Lambda env にマージして設定する(キー所有者分離)。
}

/**
 * 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, database } = props

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

    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/github-oidc-stack.ts (変更)

(変更内容)
DB操作専用のOIDC Role(DbOpsRole)をデプロイRoleと分離して追加しています。

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
  /**
   * データベース(Aurora + Data API)連携情報。`bin/app.ts` が DatabaseStack から渡す。
   * 指定された場合、デプロイ Role とは**別の** DB 操作専用 Role(DbOpsRole)を作成し、
   * Data API でのマイグレーション適用に必要な最小権限のみを付与する。
   * デプロイ Role に相乗りさせないのは権限分離のため(デプロイ侵害で任意 SQL を
   * 実行されない / DB 操作侵害で Lambda を差し替えられない)。
   */
  database?: {
    clusterArn: string
    secretArn: string
    encryptionKeyArn: 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
  /** DB 操作専用 Role の ARN(database 指定時のみ。GitHub Variables AWS_DB_OPS_ROLE_ARN に登録) */
  public readonly dbOpsRoleArn?: string

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

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

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

    // デプロイ Role / DB 操作 Role が共有する信頼ポリシー。
    // `sub` には StringLike ではなく StringEquals を使う。これにより
    // wildcard が解釈されない。config.github.subjects は完全一致の
    // subject claim 一覧(例: 'environment:prod')を想定しており、
    // 将来 '*' を含むエントリが紛れ込んでも StringLike のように暗黙的に
    // 権限が広がる事故を防げる。
    const federatedTrust = new FederatedPrincipal(
      provider.openIdConnectProviderArn,
      {
        StringEquals: {
          'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com',
          'token.actions.githubusercontent.com:sub': subjectClaims,
        },
      },
      'sts:AssumeRoleWithWebIdentity',
    )

    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),
      assumedBy: federatedTrust,
    })

    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',
    })

    // ------------------------------------------------------------
    // DB 操作専用 Role(Data API でのマイグレーション適用 / seed)
    // ------------------------------------------------------------
    // デプロイ Role とは分離した最小権限 Role。`db-operations` ワークフローが
    // OIDC で Assume し、RDS Data API 経由で migrate.ts を実行する。
    // 権限は BFF Lambda 実行ロール(backend-api.ts)と同一の 3 点:
    //   1. rds-data: Data API の実行(クラスタ ARN に限定。トランザクション系含む)
    //   2. secretsmanager: 認証情報 Secret の読み取り(Secret ARN に限定)
    //   3. kms:Decrypt: Secret は CMK 暗号化のため復号が必要(CMK ARN に限定)
    // 呼び出し元は Secret 値を読まず Data API がサーバ側で復号するが、ポリシー上は
    // GetSecretValue + Decrypt が必要(identity ベース。CMK キーポリシーは変更しない)。
    // ------------------------------------------------------------
    if (database) {
      const dbOpsRole = new Role(this, 'DbOpsRole', {
        roleName: `${config.prefix}-${config.envName}-github-db-ops`,
        description: `GitHub Actions DB operations (Data API migrate/seed) role for ${config.prefix} ${config.envName}`,
        maxSessionDuration: Duration.hours(1),
        assumedBy: federatedTrust,
      })

      dbOpsRole.addToPolicy(
        new PolicyStatement({
          sid: 'RdsDataApiExecute',
          effect: Effect.ALLOW,
          actions: [
            'rds-data:ExecuteStatement',
            'rds-data:BatchExecuteStatement',
            'rds-data:BeginTransaction',
            'rds-data:CommitTransaction',
            'rds-data:RollbackTransaction',
          ],
          resources: [database.clusterArn],
        }),
      )
      dbOpsRole.addToPolicy(
        new PolicyStatement({
          sid: 'ReadDbSecret',
          effect: Effect.ALLOW,
          actions: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'],
          resources: [database.secretArn],
        }),
      )
      dbOpsRole.addToPolicy(
        new PolicyStatement({
          sid: 'DecryptDbSecretKey',
          effect: Effect.ALLOW,
          actions: ['kms:Decrypt'],
          resources: [database.encryptionKeyArn],
          // 無条件の kms:Decrypt にせず、Secrets Manager 経由かつ対象 Secret の復号だけに限定する。
          conditions: {
            StringEquals: {
              'kms:ViaService': `secretsmanager.${this.region}.amazonaws.com`,
              'kms:EncryptionContext:SecretARN': database.secretArn,
            },
          },
        }),
      )

      this.dbOpsRoleArn = dbOpsRole.roleArn

      new CfnOutput(this, 'DbOpsRoleArn', {
        value: dbOpsRole.roleArn,
        exportName: `${id}-DbOpsRoleArn`,
        description: 'Pass this to GitHub Actions vars.AWS_DB_OPS_ROLE_ARN',
      })
    }
  }
}

⑨ cdk/lib/stacks/database-stack.ts (追加)

(内容)
Aurora PostgreSQL Serverless v2 + RDS Data APIのデータベースStackを定義しています。
ARN文字列(cluster / secret / encryptionKey)とdbNameを公開するだけで、Lambdaへのenv注入・IAM権限付与はBackendStack側に任せる設計です。

cdk/lib/stacks/database-stack.ts
import { CfnOutput, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'
import type { StackProps } from 'aws-cdk-lib'
import type { Construct } from 'constructs'
import { IpAddresses, SubnetType, Vpc } from 'aws-cdk-lib/aws-ec2'
import { Key } from 'aws-cdk-lib/aws-kms'
import {
  AuroraPostgresEngineVersion,
  ClusterInstance,
  Credentials,
  DatabaseClusterEngine,
  DatabaseCluster,
} from 'aws-cdk-lib/aws-rds'
import type { IClusterInstance } from 'aws-cdk-lib/aws-rds'
import type { EnvConfig } from '../../config/types'

export interface DatabaseStackProps extends StackProps {
  config: EnvConfig
}

/**
 * データベース(Aurora PostgreSQL Serverless v2 + RDS Data API)の環境別 Stack。
 *
 * 設計(docs/rds-design.md)に準拠:
 *   - 接続方式は RDS Data API(HTTPS / IAM 認可)。Lambda は **非VPC のまま**維持する。
 *     Data API は AWS API 経由でクラスタに到達するため、Lambda を VPC に入れる必要がない。
 *   - 認証情報は Secrets Manager(KMS CMK 暗号化)に置き、Lambda には Secret ARN を渡す。
 *   - 保存時暗号化は KMS Customer Managed Key(PII = line_user_id を保持するため鍵を自前統制)。
 *   - dev/stg は min 0 ACU(auto-pause)でキャンペーン期間外のコストを最小化。
 *     prod は min 0.5 ACU・Multi-AZ・PITR 最大保持。
 *
 * Lambda への権限付与と env 注入は BackendStack 側で行う。本 Stack は ARN 文字列
 * (cluster / secret / encryptionKey)と dbName を公開するだけに留める。
 *
 * 【cross-stack の依存サイクル回避】
 * cluster.grantDataApiAccess(fn) / secret.grantRead(fn) のような「オブジェクト参照
 * による grant」は、CMK のキーポリシーへ Lambda ロールを書き込むため
 * DatabaseStack → BackendStack の逆向き参照を生み、BackendStack → DatabaseStack と
 * 合わさって循環になる。これを避けるため、本 Stack は ARN 文字列だけを公開し、
 * BackendStack 側で **identity ベースの IAM ポリシー**(fn ロールに直接付与)を張る。
 * KMS のデフォルトキーポリシーはアカウント root を信頼するため、identity ポリシーの
 * kms:Decrypt だけで Secret 復号が可能(キーポリシー変更が不要=逆向き参照が出ない)。
 */
export class DatabaseStack extends Stack {
  /** Aurora クラスタ ARN(Lambda env DB_CLUSTER_ARN / Data API resourceArn / rds-data の resource) */
  public readonly clusterArn: string
  /** DB 認証情報 Secret ARN(Lambda env DB_SECRET_ARN / Data API secretArn) */
  public readonly secretArn: string
  /** 保存時暗号化 / Secret 暗号化に使う CMK の ARN(fn に kms:Decrypt を付与する対象) */
  public readonly encryptionKeyArn: string
  /** 既定データベース名(Lambda env DB_NAME に渡す) */
  public readonly databaseName: string

  constructor(scope: Construct, id: string, props: DatabaseStackProps) {
    super(scope, id, props)
    const { config } = props
    const { db } = config
    const removalPolicy =
      config.removalPolicy === 'retain' ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY

    // ----------------------------------------------------------
    // VPC(Aurora の配置先。Data API 経由なので NAT/IGW は不要)
    // ----------------------------------------------------------
    // クラスタは隔離サブネット(PRIVATE_ISOLATED)に置く。インターネット
    // 経路を持たないため NAT Gateway 課金が発生しない。Data API はクラスタへ
    // AWS 内部経路で到達するため、これで十分。
    // ----------------------------------------------------------
    const vpc = new Vpc(this, 'DbVpc', {
      ipAddresses: IpAddresses.cidr('10.0.0.0/16'),
      maxAzs: 2, // Aurora は 2 AZ 以上のサブネットグループが必要
      natGateways: 0,
      subnetConfiguration: [
        {
          name: 'db-isolated',
          subnetType: SubnetType.PRIVATE_ISOLATED,
          cidrMask: 24,
        },
      ],
    })

    // ----------------------------------------------------------
    // KMS CMK(保存時暗号化 + Secret 暗号化を 1 鍵で統制)
    // ----------------------------------------------------------
    // line_user_id(PII)を保持するため、保存時暗号化は CMK で自前統制する。
    // 同じ鍵で認証情報 Secret も暗号化し、鍵の自動ローテーションを有効化する。
    // ----------------------------------------------------------
    const encryptionKey = new Key(this, 'DbKey', {
      description: `${config.prefix} ${config.envName} Aurora/Secret encryption CMK`,
      enableKeyRotation: true,
      removalPolicy,
    })

    // ----------------------------------------------------------
    // Aurora PostgreSQL Serverless v2 クラスタ
    // ----------------------------------------------------------
    // ローカル(postgres:17)とメジャーを揃えて PostgreSQL 17 系を採用。
    // auto-pause は min 0 ACU かつ autoPauseSeconds 指定時のみ有効化する。
    // ----------------------------------------------------------
    const readers: IClusterInstance[] = db.multiAz
      ? [
          // prod: 別 AZ のリーダーを 1 台追加して可用性を確保(writer に追従してスケール)
          ClusterInstance.serverlessV2('Reader', { scaleWithWriter: true }),
        ]
      : []

    const cluster = new DatabaseCluster(this, 'DbCluster', {
      engine: DatabaseClusterEngine.auroraPostgres({
        version: AuroraPostgresEngineVersion.VER_17_9,
      }),
      vpc,
      vpcSubnets: { subnetType: SubnetType.PRIVATE_ISOLATED },
      writer: ClusterInstance.serverlessV2('Writer', {
        // Data API はライターインスタンスでのみ実行されるため、まずは writer を確実に用意
        enablePerformanceInsights: config.envName === 'prod',
      }),
      readers,
      serverlessV2MinCapacity: db.minCapacityAcu,
      serverlessV2MaxCapacity: db.maxCapacityAcu,
      // min 0 ACU かつ autoPauseSeconds 指定時のみ auto-pause を設定する
      serverlessV2AutoPauseDuration:
        db.minCapacityAcu === 0 && db.autoPauseSeconds
          ? Duration.seconds(db.autoPauseSeconds)
          : undefined,
      // RDS Data API(HTTPS / IAM)を有効化。非VPC Lambda から叩けるようにする
      enableDataApi: true,
      // 保存時暗号化を CMK で統制(PII 要件)
      storageEncryptionKey: encryptionKey,
      // 認証情報は Secrets Manager に自動生成(CMK 暗号化)。Data API には Secret ARN を渡す
      credentials: Credentials.fromGeneratedSecret('app_admin', { encryptionKey }),
      defaultDatabaseName: db.databaseName,
      backup: { retention: Duration.days(db.backupRetentionDays) },
      // prod は誤削除防止 + 物理削除時のスナップショット化
      deletionProtection: config.envName === 'prod',
      removalPolicy,
    })

    this.clusterArn = cluster.clusterArn
    // fromGeneratedSecret により secret は必ず存在する
    this.secretArn = cluster.secret!.secretArn
    this.encryptionKeyArn = encryptionKey.keyArn
    this.databaseName = db.databaseName

    // ----------------------------------------------------------
    // Outputs(運用 / 他 Stack / マイグレーション適用で参照)
    // ----------------------------------------------------------
    new CfnOutput(this, 'DbClusterArn', {
      value: this.clusterArn,
      exportName: `${id}-DbClusterArn`,
      description: 'Aurora cluster ARN (Lambda env DB_CLUSTER_ARN / Data API resourceArn)',
    })
    new CfnOutput(this, 'DbSecretArn', {
      value: this.secretArn,
      exportName: `${id}-DbSecretArn`,
      description: 'DB credentials Secret ARN (Lambda env DB_SECRET_ARN / Data API secretArn)',
    })
    new CfnOutput(this, 'DbName', {
      value: this.databaseName,
      description: 'Default database name (Lambda env DB_NAME)',
    })
  }
}

⑩ .github/workflows/deploy-backend.yml (変更)

(変更内容)
Lambdaの環境変数の更新を「全置換」から「マージ(read-modify-write)」に変更しました。(データベースの導入により、CDKで注入する環境変数(DB情報関連)が出てきたため、CIがそれを壊さないようにする対応です。

.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 化)**
# を更新し、加えて **`NUXT_LINE_LOGIN_CHANNEL_ID` の env だけ**を設定する。
# インフラ自体(関数の枠 / API Gateway / IAM Role / CloudFront behavior)の
# 作成・変更は `cdk deploy` で行う。
#
# 【env のキー所有者分離(1 キー = 1 writer)】
# Lambda の環境変数は writer をキー単位で分離している:
#   - CDK が所有 : DB_CLUSTER_ARN / DB_SECRET_ARN / DB_NAME / DB_DRIVER /
#                  NITRO_PRESET / NODE_ENV(`cdk deploy` で設定)
#   - 本 CI が所有: NUXT_LINE_LOGIN_CHANNEL_ID(GitHub Variable を反映)
# update-function-configuration の Environment.Variables は **全置換**のため、
# CI は「現在の env を get → channel id を **マージ** → put」する(read-modify-write)。
# これにより CDK が入れた DB 接続情報を保持したまま channel id だけ上書きでき、
# 全置換で DB 設定が消える事故が起きない。
#
# 注: `cdk deploy` は $LATEST の env を CDK 所有キーで上書きする(channel id は持たない)。
# そのため cdk deploy 後は本ワークフローを再実行して channel id を再マージ + 再 publish する
# こと(live alias は旧 publish 版を指し続けるため、再 publish までは無停止)。
# ============================================================

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 を回す前にここで弾く。
      #
      # NUXT_LINE_LOGIN_CHANNEL_ID の形式: LINE Developers が発行する Channel ID は
      # 10 桁前後の数字。8〜12 桁の数字以外を弾くことで、LIFF ID
      # (`2001234567-abcdefgh` 形式) を誤投入する事故を捕捉する。
      # 変数名は Nuxt の runtime override 規約 (`NUXT_<KEY>` → runtimeConfig.lineLoginChannelId)
      # に揃えてある。LIFF_ID はフロントのビルド時公開値(NUXT_PUBLIC_LIFF_ID)として焼き込む。
      - 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 (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 を $LATEST env に **マージ**(read-modify-write)
      # ----------------------------------------------------------
      # update-function-configuration の Environment.Variables は全置換のため、
      # 「現在の env を get → channel id を足す → put」で **CDK が入れた DB 接続情報
      # (DB_CLUSTER_ARN 等)を保持**したまま channel id だけ上書きする。
      # publish-version は code + env の snapshot なので、必ず publish の **前** に確定させる。
      #
      # jq で JSON を組み立てて --cli-input-json に渡す(値に `,`/`=` が来ても shell 展開
      # 事故ゼロ)。get で env が空(Environment 未設定)の場合は `{}` を起点にマージする。
      # ----------------------------------------------------------
      - name: Merge NUXT_LINE_LOGIN_CHANNEL_ID into Lambda env ($LATEST)
        env:
          FN_NAME: ${{ vars.LAMBDA_FUNCTION_NAME }}
          NUXT_LINE_LOGIN_CHANNEL_ID: ${{ vars.NUXT_LINE_LOGIN_CHANNEL_ID }}
        run: |
          set -euo pipefail
          # 現在の $LATEST の env(CDK が入れた DB 接続情報・NITRO_PRESET 等)と
          # RevisionId を同時取得する。RevisionId は put 時の楽観ロックに使う。
          CONFIG=$(aws lambda get-function-configuration \
            --function-name "$FN_NAME" \
            --query '{vars: Environment.Variables, rev: RevisionId}' --output json)
          # Environment 自体が無い場合 vars は null になるので空オブジェクトに正規化
          CURRENT=$(echo "$CONFIG" | jq -c '.vars // {}')
          REVISION_ID=$(echo "$CONFIG" | jq -r '.rev')
          # channel id を **マージ**(他キーは保持。同名キーのみ上書き)
          MERGED=$(echo "$CURRENT" | jq -c --arg c "$NUXT_LINE_LOGIN_CHANNEL_ID" \
            '. + {NUXT_LINE_LOGIN_CHANNEL_ID: $c}')
          # RevisionId を載せて条件付き更新する。get 後に cdk deploy 等が env を
          # 書き換えていた場合は PreconditionFailedException で fail し、古い env で
          # CDK 所有キー(DB 接続情報等)を巻き戻す事故を防ぐ(再実行で最新を取り直す)。
          jq -n --arg fn "$FN_NAME" --argjson vars "$MERGED" --arg rev "$REVISION_ID" \
            '{FunctionName: $fn, Environment: {Variables: $vars}, RevisionId: $rev}' > /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"

⑪ .github/workflows/db-operations.yml (追加)

(内容)
RDS Data API経由でDB操作(migrate / seed / reset)を行う手動実行専用ワークフローです。
バックエンド/フロントのデプロイとは独立したワークフローであり、GithubOidcStackで作ったDbOpsRole をOIDCでAssumeし、Data API経由でmigrate.ts / seed.ts / reset.tsを実行します。
GitHub Actions → IAM AssumeRole(OIDC) → RDS Data API → Aurora
追加インフラ(NAT / VPC エンドポイント / 踏み台サーバー)ゼロ=常設費ゼロで運用可能です。

.github/workflows/db-operations.yml
name: Database Operations (Data API)
run-name: DB ${{ inputs.operation }} 【${{ inputs.target_env }}】

# ============================================================
# データベース操作(migrate 適用 / マスタ seed / reset 初期化)専用ワークフロー。
# バックエンド/フロントのデプロイとは独立して手動実行する。
#
# 接続方式は RDS Data API(HTTPS / IAM 認可)。Aurora は隔離サブネット
# (PRIVATE_ISOLATED / NAT なし)に居るため、VPC 外の GitHub Actions からは
# Data API のパブリックエンドポイント経由でしか到達できない。
#   ローカル / GitHub Actions → IAM AssumeRole(OIDC) → RDS Data API → Aurora
# この経路は追加インフラ(NAT / VPC エンドポイント / 踏み台)を一切持たないため
# 常設費ゼロ。かつ呼び出し元は Secret ARN を渡すだけで DB パスワードを読まない
# (復号はサーバ側)ため、認証情報の平文露出も無い。
#
# ──────────────────────────────────────────────────────────
# 【前提(このワークフローが動くために別途必要なもの)】
#   1. `server/db/migrate.ts` が DB_DRIVER=data-api に対応していること
#      (適用時に各 SQL ファイルを $$ 対応で文分割し、Data API の
#       BeginTransaction → ExecuteStatement → Commit で適用する executor)。
#   2. マイグレーション専用 IAM ロール(vars.AWS_DB_OPS_ROLE_ARN)に
#      rds-data:* / secretsmanager:GetSecretValue,DescribeSecret / kms:Decrypt
#      が付与されていること(Lambda 実行ロールと同一権限を流用)。
#      OIDC subject は environment:<env> に限定する。
#   3. 環境ごとに GitHub Variables を設定:
#        AWS_REGION / AWS_DB_OPS_ROLE_ARN
#        DB_CLUSTER_ARN / DB_SECRET_ARN / DB_NAME
#      (DB_* は DatabaseStack の CfnOutput を転記)。
#   注: prod の Required reviewers(承認ゲート)は採用しない方針。prod の統制は
#       「main ブランチ限定(Validate ref)+ 全操作の typed confirmation」で担保する。
#
# 【リセット(reset)について】
#   テスト後・リリース直前の「DB 初期化」用途に限り、破壊的リセットを提供する。
#   reset = DROP SCHEMA public CASCADE → CREATE SCHEMA public → migrate → seed の
#   フル初期化(= ローカルの db:reset と同じ)を Data API 経由で実行する。
#   ガード(多重に守る):
#     - confirm 入力に「対象環境名」を手入力させ、target_env と一致しなければ中断。
#     - reset ステップのみ ALLOW_DB_RESET=1 を立てる(reset.ts の data-api ガード)。
#     - prod は main 限定(Validate ref で誤ブランチからの本番操作を拒否)。
#   バックアップは PITR(backup 保持 35 日)に依存し、ジョブ内ではスナップショットを
#   取得しない。巻き戻しが要る場合は PITR / スナップショット復元で対応する。
# ============================================================

on:
  workflow_dispatch:
    inputs:
      target_env:
        description: '操作対象の環境'
        required: true
        type: choice
        options:
          - dev
          - stg
          - prod
      operation:
        description: '実行する操作'
        required: true
        type: choice
        default: migrate
        options:
          - migrate # 未適用マイグレーションを Data API 経由で適用
          - seed # マスタ seed を投入/更新(可変列は DO UPDATE)
          - reset # 【破壊的】DROP SCHEMA → migrate → seed のフル初期化。confirm 必須
      confirm:
        description: '必須: 対象環境名を入力(例 prod)。target_env と一致しないと中断。対象のブランチと env が正しいことを確認してください'
        required: true
        type: string

# 同一環境への DB 操作は必ず直列化する。
# Data API では pg_advisory_lock が効かない(セッション固定が保証されない)ため、
# マイグレーションの多重実行防止はこの concurrency グループで担保する。
# 適用途中の操作をキャンセルすると中途半端な状態が残るため cancel-in-progress は false。
concurrency:
  group: db-ops-${{ github.event.inputs.target_env }}
  cancel-in-progress: false

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

jobs:
  db-operation:
    name: ${{ inputs.operation }} on ${{ inputs.target_env }}
    runs-on: ubuntu-latest
    timeout-minutes: 15

    # Environment スコープの Variables(DB_* / AWS_DB_OPS_ROLE_ARN 等)をここで解決する。
    # Required reviewers(承認ゲート)は採用しない方針のため、ここでの待機は発生しない。
    # prod の統制は Validate ref(main 限定)と全操作の typed confirmation で担保する。
    # 将来 Environment 側に保護ルールを足せば、この environment: 経由で自動的に発火する。
    environment:
      name: ${{ inputs.target_env }}

    steps:
      # ----------------------------------------------------------
      # prod への操作は main からのみ許可(誤ブランチからの本番適用を防ぐ)
      # ----------------------------------------------------------
      - name: Validate ref
        env:
          TARGET_ENV: ${{ inputs.target_env }}
          # github.ref_name はブランチ/タグの短名で種別を区別しないため、
          # 'main' という名前のタグでガードを迂回できる。完全 ref で判定する。
          DEPLOY_REF: ${{ github.ref }}
        run: |
          if [ "$TARGET_ENV" = "prod" ] && [ "$DEPLOY_REF" != "refs/heads/main" ]; then
            echo "::error::prod の DB 操作は main ブランチから実行してください (got: $DEPLOY_REF)"
            exit 1
          fi

      # ----------------------------------------------------------
      # typed confirmation 検証(全操作で必須)
      # ----------------------------------------------------------
      # 誤環境・誤ブランチでの実行を防ぐため、operation を問わず confirm 入力に
      # 対象環境名そのものを手入力させ、target_env と完全一致しない限り中断する。
      # reset(DROP SCHEMA を伴う破壊的操作)のときは追加で警告を出す。
      # ----------------------------------------------------------
      - name: Validate confirmation
        env:
          CONFIRM: ${{ inputs.confirm }}
          TARGET_ENV: ${{ inputs.target_env }}
          OPERATION: ${{ inputs.operation }}
        run: |
          if [ "$CONFIRM" != "$TARGET_ENV" ]; then
            echo "::error::confirm に対象環境名 '$TARGET_ENV' を入力してください(入力値: '$CONFIRM')"
            exit 1
          fi
          if [ "$OPERATION" = "reset" ]; then
            echo "::warning::'$TARGET_ENV' のデータベースを初期化します(DROP SCHEMA public → migrate → seed)"
          fi

      # ----------------------------------------------------------
      # 必須 GitHub Variables の存在チェック(操作実行前に弾く)
      # ----------------------------------------------------------
      - name: Validate required GitHub Variables
        env:
          AWS_REGION: ${{ vars.AWS_REGION }}
          AWS_DB_OPS_ROLE_ARN: ${{ vars.AWS_DB_OPS_ROLE_ARN }}
          DB_CLUSTER_ARN: ${{ vars.DB_CLUSTER_ARN }}
          DB_SECRET_ARN: ${{ vars.DB_SECRET_ARN }}
          DB_NAME: ${{ vars.DB_NAME }}
          TARGET_ENV: ${{ inputs.target_env }}
        run: |
          set -eo pipefail
          fail=0
          for v in AWS_REGION AWS_DB_OPS_ROLE_ARN DB_CLUSTER_ARN DB_SECRET_ARN DB_NAME; do
            if [ -z "${!v:-}" ]; then
              echo "::error::Required GitHub Variable '$v' is not set on environment '$TARGET_ENV'."
              fail=1
            fi
          done
          [ "$fail" -eq 0 ] || exit 1

      - name: Checkout (${{ github.ref_name }})
        # サプライチェーン対策でフル長 commit SHA に固定する(mutable tag の遡及差し替え防止)。
        # 更新は Dependabot 等に任せ、末尾コメントの版数を目視確認の手掛かりにする。
        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
        with:
          fetch-depth: 1

      - name: Setup pnpm
        uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
        with:
          run_install: false

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

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

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
        with:
          role-to-assume: ${{ vars.AWS_DB_OPS_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}
          role-session-name: gha-db-${{ inputs.operation }}-${{ inputs.target_env }}-${{ github.run_id }}

      # ----------------------------------------------------------
      # 操作の実行
      # ----------------------------------------------------------
      # DB_DRIVER=data-api を渡すことで migrate.ts / seed.ts が Data API ドライバを
      # 使う。接続情報は ARN のみ(パスワードは渡さない / 読まない)。
      # ----------------------------------------------------------
      - name: Run DB operation (${{ inputs.operation }})
        id: run
        env:
          DB_DRIVER: data-api
          DB_CLUSTER_ARN: ${{ vars.DB_CLUSTER_ARN }}
          DB_SECRET_ARN: ${{ vars.DB_SECRET_ARN }}
          DB_NAME: ${{ vars.DB_NAME }}
          OPERATION: ${{ inputs.operation }}
        run: |
          set -euo pipefail
          case "$OPERATION" in
            migrate)
              echo "Applying pending migrations via Data API..."
              pnpm db:migrate
              ;;
            seed)
              echo "Seeding master data via Data API..."
              pnpm db:seed
              ;;
            reset)
              # 破壊的初期化。ALLOW_DB_RESET=1 はこの branch でのみ立てる
              # (reset.ts の data-api ガードを通すための明示オプトイン)。
              echo "Resetting database (DROP SCHEMA -> migrate -> seed) via Data API..."
              ALLOW_DB_RESET=1 pnpm db:reset
              ;;
            *)
              echo "::error::Unknown operation: $OPERATION"
              exit 1
              ;;
          esac

      - name: Summary
        if: always()
        env:
          TARGET_ENV: ${{ inputs.target_env }}
          OPERATION: ${{ inputs.operation }}
          OUTCOME: ${{ steps.run.outcome }}
        run: |
          {
            if [ "$OUTCOME" = "success" ]; then
              printf '## ✅ DB operation completed\n\n'
            else
              printf '## ❌ DB operation failed\n\n'
            fi
            printf '| Item | Value |\n'
            printf '|---|---|\n'
            printf '| Environment | `%s` |\n' "$TARGET_ENV"
            printf '| Operation | `%s` |\n' "$OPERATION"
            printf '| Region | `%s` |\n' "${{ vars.AWS_REGION }}"
            printf '| Driver | `data-api` (Aurora Data API / IAM) |\n'
            printf '| Ref | `%s` |\n' "${{ github.ref_name }}"
          } >> "$GITHUB_STEP_SUMMARY"

実行方法(独自ドメイン使用の場合を含む)

スタック(AWSリソース)のデプロイ〜削除までの手順は過去(今回の編集前のCDK)の記事をご参照ください。
(4点、前回と違う点を後述します)

① 設定ファイルの編集

config/dev/stg/prodに新しくdb: {}フィールドが追加されたので、デプロイ前に設定値を確認します。

② GitHub Actions連携

※データベースが追加された分、登録する変数も増えています。
※CDKでLambdaに注入済みであるDB情報をGitHubの環境変数にも入れているのは、Lambda用ではなくDB操作ワークフロー用だからです。(別プロセスのため)

変数名 使用ワークフロー
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
AWS_DB_OPS_ROLE_ARN データベース arn:aws:iam::<env-account>:role/<project-name>-liff-stg-github-db-ops
DB_CLUSTER_ARN データベース arn:aws:rds:ap-northeast-1:<account-id>:cluster:db-for-app
DB_SECRET_ARN データベース arn:aws:secretsmanager:ap-northeast-1:<account-id>:secret:rds!cluster-asdfghjkl-123456789-qwertyuiop
DB_NAME データベース app-db

③ GitHub Actionsワークフローの実行

データベースのワークフローの実行手順が増えました。

  1. フロントエンドのワークフロー(deploy-frontend.yml)を実行します
  2. バックエンドのワークフロー(deploy-backend.yml)を実行します
  3. データベースのワークフロー(db-operations.yml)をmigrare → seedの順に実行します

SCR-20260608-oxow.png

④ destroy後にも削除されずに残るAWSリソース

※中にはremovalPolicy: 'destroy'を設定していたとしてもdestroyコマンド実行後に残るAWSリソースがあります。

・CR(CloudFormation Custom Resource)プロバイダLambdaの自動生成ロググループ
CDKでAwsCustomResourceや一部の高レベルConstructを使うと、裏側でCustom Resource Provider Lambdaが自動作成されます。
このLambda用にCloudWatch Logsのロググループも自動生成されますが、テンプレートでは管理していないため残ります。
※再デプロイへの影響→ロググループは再利用されるため問題ありません

・KMS CMK
KMSは即時削除されずPendingDeletion状態で待機期間(既定30日)残留する仕様です(期間中は復元可能)

・Secrets ManagerのDB認証情報Secret
Credentials.fromGeneratedSecret生成のSecretはforce削除指定が無いため、destroy時 DeleteSecretが復旧ウィンドウ(7〜30日)付きで論理削除=期間中は残留します(復元可能)

・prod環境でremovalPolicy: retainに設定したもの

・GitHub Actionsが作るCFN管理外リソース
エイリアス切り替え用に作成した複数のLambda VersionはremovalPolicy: retainの場合残ります。(通常は親関数の削除でカスケード消去される)

・アカウントレベルの共有リソース
GitHub OIDC Providerなど、複数envで共有利用しているスタックの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?