はじめに
今回は、前回の記事でAWSコンソール上で手動で設定した、Amazon CloudFrontに署名付きCookieを設定する構成を、AWS CDKを使ってコード化してみました(既存のCDKコードを編集しています)
少しでも参考になれば幸いです。
関連記事を公開していますので、ぜひ合わせてご参照ください。
前提
CloudFrontに登録するRSA鍵ペア(公開鍵と秘密鍵)が作成済みであること
今回、追加/変更したファイル
cdk/
├── bin/
│ └── app.ts ① (変更)
├── config/
│ ├── dev.ts ② (変更)
│ ├── stg.ts ③ (変更)
│ ├── prod.ts ④ (変更)
│ └── types.ts ⑤ (変更)
└── lib/
├── stacks/
│ └── frontend-stack.ts ⑥ (変更)
└── constructsstacks/
└── static-site.ts ⑦ (変更)
.github/
└── workflows/
└── deploy-backend.yml ⑧ (変更)
各ファイル内容
※プログラミング言語はTypeScriptを使用しています。
※コーディングにはClaude Codeを使用しています。
※ベストプラクティスというわけではないため、あくまで参考にしていただけると幸いです。
※各ファイルの詳細な説明は省略いたしますが、気になった方はAIに投げてみてください。
※機密情報を扱う際は、別途.envやSecretManagerなどと連携する必要がある場合があります。
① cdk/bin/app.ts (変更)
(変更内容)
署名付きCookie検証用のCloudFrontパブリックキー(公開鍵)を取り込む処理を追加しています。
-
config.gifSigning?.publicKeyを読み、デフォルトの
'REPLACE_ME'(鍵ペア未準備)の場合はundefinedにしてパスの保護を無効化。鍵が設定済みのときだけ有効値を渡します。 -
gifSigningPublicKeyをFrontendStack(S3 + CloudFront
の静的サイト構成)に新たに引き渡しています。 - 公開鍵は秘密ではないため、管理のしやすさを優先し、
config/のenvごとにコミットする方針(env渡し忘れによる保護漏れ事故防止)をコメントで明記しています。
// GIF 署名 Cookie 検証用の CloudFront 公開鍵 PEM。公開鍵は秘密ではないため env ごとに
// config(cdk/config/<env>.ts)へコミットしている(env 渡し忘れによる「保護したつもりが
// 公開のまま」事故を防ぐ)。既定の 'REPLACE_ME'(鍵ペア未準備)の間は GIF 保護を無効化する。
const configuredGifPublicKey = config.gifSigning?.publicKey
const gifSigningPublicKey =
configuredGifPublicKey && configuredGifPublicKey !== 'REPLACE_ME'
? configuredGifPublicKey
: undefined
// 環境別の静的サイト(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,
gifSigningPublicKey,
description: `${config.prefix} ${config.envName} static site (S3 + CloudFront)`,
})
②③④ cdk/config/dev(stg,prod).ts (変更)
(変更内容)
config/の各envにgifSigning.publicKeyを追加しました。
これらの公開鍵はCloudFrontパブリックキーに登録されます。
// GIF実体(/images/completion-gift-*)保護用 CloudFront 公開鍵。
// opensslで鍵ペアを作り、gif_public_key.pemの中身をここに貼る(公開鍵なのでコミット可)。
// 秘密鍵はbase64化してGitHub Secret: NUXT_GIF_SIGNING_PRIVATE_KEYとして保存する。
// 'REPLACE_ME'(デフォルト)の場合はGIF保護を無効化(公開配信のまま)。
gifSigning: {
publicKey: 'REPLACE_ME',
},
⑤ cdk/config/types.ts (変更)
(変更内容)
GifSigningConfigインターフェースを新規追加しました(フィールドは publicKey:
string のみ)
CloudFrontに登録するRSA公開鍵(PEM形式)を保持する型です。
/**
* GIF 実体(/images/completion-gift-*)への到達を絞る CloudFront 署名付き Cookie 構成。
*
* CloudFront の Trusted Key Group で「署名 Cookie を持つリクエストだけ GIF を取得できる」よう
* 配信レイヤーで認可する。ここに置くのは **公開鍵のみ**(CloudFront に登録する側)。公開鍵は
* 秘密ではないため env ごとにこの config へ直接コミットして運用ミス(env 渡し忘れ)を防ぐ。
*
* 対応する RSA 秘密鍵は **コミットしない**。base64 化して GitHub Secret
* `NUXT_GIF_SIGNING_PRIVATE_KEY` に置き、deploy-backend が Lambda env にマージする。
* 署名 Cookie の発行は BFF の redeem ハンドラ(server/utils/gifDownloadCookie.ts)。
*/
export interface GifSigningConfig {
/**
* CloudFront に登録する RSA 公開鍵(PEM, `-----BEGIN PUBLIC KEY-----` 形式)。
*
* 鍵ペア生成(env ごとに別の鍵を使う):
* openssl genrsa -out gif_private_key.pem 2048
* openssl rsa -pubout -in gif_private_key.pem -out gif_public_key.pem
* → gif_public_key.pem の中身をここに貼る。秘密鍵は base64 化して GitHub Secret へ。
*
* 既定の 'REPLACE_ME'(未設定)の間は GIF 保護 behavior を作らず、公開静的配信のままにする
* (鍵ペア未準備でも synth/deploy が壊れないフェイルソフト。bin/app.ts が判定する)。
*/
publicKey: string
}
export interface EnvConfig {
/**
* GIF 実体保護用 CloudFront 署名付き Cookie 構成(公開鍵のみ)。
* 省略 or `publicKey: 'REPLACE_ME'` の場合は GIF を公開静的配信のままにする。
*/
gifSigning?: GifSigningConfig
}
⑥ cdk/lib/stacks/frontend-stack.ts (変更)
(変更内容)
app.ts(公開鍵を解決) → 本ファイル(中継・ID出力) → static-site.ts(実配線)という橋渡し層の変更です。
- Propsに
gifSigningPublicKey?: stringを追加しました。これはbin/app.ts
から受け取るCloudFront公開鍵PEMです。未指定ならGIF保護なし(後方互換) - その値を
StaticSite constructへそのまま受け渡しています。 -
CfnOutputを追加しました(site.gifSigningPublicKeyId がある場合のみ)- 作成された
CloudFront PublicKey IDが出力されます。アプリ側のサーバーロジックで使用するため、GitHub VariableにNUXT_GIF_KEY_PAIR_IDとして保存し、github actionワークフローのdeploy-backend(後述)がLambda envにマージします。
- 作成された
export interface FrontendStackProps extends StackProps {
/**
* GIF 実体(/images/completion-gift-*)の署名 Cookie 検証に使う CloudFront 公開鍵 PEM。
* `bin/app.ts` が CDK context / 環境変数(GIF_SIGNING_PUBLIC_KEY)から解決して渡す。
* 未指定なら GIF 保護 behavior は作らず公開配信のまま(後方互換)。
*/
gifSigningPublicKey?: string
}
export class FrontendStack extends Stack {
constructor(scope: Construct, id: string, props: FrontendStackProps) {
super(scope, id, props)
const { gifSigningPublicKey } = props
const site = new StaticSite(this, 'Site', {
gifSigningPublicKey,
})
// GIF 署名 Cookie 用の CloudFront PublicKey ID。`gifSigningPublicKey` を渡した
// 場合のみ出力される。値を GitHub Variable `NUXT_GIF_KEY_PAIR_ID` に転記し、
// deploy-backend がそれを Lambda env にマージして redeem が Cookie に載せる。
if (site.gifSigningPublicKeyId) {
new CfnOutput(this, 'GifSigningPublicKeyId', {
value: site.gifSigningPublicKeyId,
description: 'Copy to GitHub Variable NUXT_GIF_KEY_PAIR_ID (CloudFront-Key-Pair-Id)',
})
}
}
}
⑦ cdk/lib/constructs/static-site.ts (変更)
(変更内容)
S3オリジンを共有化しました。S3BucketOrigin.withOriginAccessControl(...)をs3Origin変数に一度だけ生成し、default behaviorとGIF保護behaviorで共有させます(同一バケットのオリジン重複生成を回避)
- GIF保護behaviorを追加(公開鍵がある場合のみ):
- PublicKey + KeyGroup を作成
- パスパターン
/images/completion-gift-*のbehaviorに
trustedKeyGroupsを紐付け → 有効な署名Cookieが無いと403。GIFのURLが漏れてもCookie無しでは取得不可。 - 他の
/images/*はdefault behavior(公開)のまま。 - Cookie不正時のGIFリクエストは index.html(200)
によってアプリのトップページへリダイレクトさせる仕様にしています。
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,
KeyGroup,
OriginProtocolPolicy,
OriginRequestPolicy,
OriginSslPolicy,
PriceClass,
PublicKey,
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'
import type { ICertificate } from 'aws-cdk-lib/aws-certificatemanager'
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
/**
* CloudFront の Alternate Domain Name に登録する FQDN 群。
* `certificate` と必ずセットで指定すること(片方だけ渡された場合は
* 設定不整合として例外を投げる)。
* 未指定なら CloudFront デフォルトドメインのみで公開される。
*/
domainNames?: string[]
/**
* `domainNames` に紐づける ACM 証明書(us-east-1 で発行されたもの)。
* CloudFront はグローバルだが、関連付けられる証明書は us-east-1 限定のため、
* `bin/app.ts` で CertificateStack を us-east-1 に作って渡す。
*/
certificate?: ICertificate
/**
* GIF 実体(/images/completion-gift-*)への到達を CloudFront 署名付き Cookie で絞るための
* 公開鍵 PEM(`-----BEGIN PUBLIC KEY-----` 形式)。指定された場合のみ、CloudFront に
* PublicKey + KeyGroup を作り、保護パスの behavior に trustedKeyGroups として紐付ける。
* 未指定なら GIF は通常の公開静的配信のまま(ページ側チケットのソフトガードのみ)。
*
* 対応する RSA 秘密鍵は BFF Lambda の env(NUXT_GIF_SIGNING_PRIVATE_KEY)に置き、
* redeem ハンドラが署名 Cookie を発行する(キーペアの公開側だけをここに渡す)。
*/
gifSigningPublicKey?: string
}
/**
* S3(プライベート)+ CloudFront(OAC)の静的サイト構成。SPA ホスティングに最適。
*
* - Bucket: BlockPublicAccess、SSE-S3、BucketOwnerEnforced、TLS 強制
* - Distribution: Managed CachingOptimized、redirect-to-https、HTTP/2 + HTTP/3、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
/**
* GIF 署名 Cookie 用 CloudFront PublicKey の ID(`gifSigningPublicKey` 指定時のみ)。
* BFF が `CloudFront-Key-Pair-Id` Cookie に載せる値で、CfnOutput 経由で GitHub Variable
* `NUXT_GIF_KEY_PAIR_ID` に転記して Lambda env に渡す。
*/
public readonly gifSigningPublicKeyId?: string
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,
})
// S3(OAC) オリジンは default behavior と GIF 保護 behavior で共有する
// (同一バケットを指すオリジンを behavior ごとに重複生成しないため)。
const s3Origin = S3BucketOrigin.withOriginAccessControl(bucket, {
originAccessControl: oac,
})
// ------------------------------------------------------------
// /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,
}
}
// ------------------------------------------------------------
// GIF 実体(/images/completion-gift-*)保護用 behavior(署名 Cookie 必須)
// ------------------------------------------------------------
// 公開鍵が渡された場合のみ、CloudFront PublicKey + KeyGroup を作り、保護パスの
// behavior に trustedKeyGroups として紐付ける。これにより当該パスは
// 「有効な署名 Cookie(CloudFront-Policy / -Signature / -Key-Pair-Id)」が無いと
// 403 になり、GIF の URL が漏れても Cookie の無いリクエストでは取得できなくなる。
// 署名 Cookie の発行は BFF の redeem ハンドラ(対応する秘密鍵を Lambda env で保持)。
//
// 注: パスパターン `/images/completion-gift-*` は completion-gift- 配下だけに効き、
// 他の /images/* は default behavior(公開)のまま。CloudFront のカスタムエラー
// レスポンス(403→/index.html)は distribution 全体に効くため、Cookie 不正時の
// GIF リクエストは index.html(200) に化ける(GIF は渡らないので認可目的は満たす)。
let gifSigningPublicKeyId: string | undefined
if (props.gifSigningPublicKey) {
const publicKey = new PublicKey(this, 'GifSigningPublicKey', {
publicKeyName: `${props.prefix}-${props.envName}-gif-signing`,
encodedKey: props.gifSigningPublicKey,
comment: 'Public key for signed-cookie access to completion GIFs',
})
const keyGroup = new KeyGroup(this, 'GifSigningKeyGroup', {
keyGroupName: `${props.prefix}-${props.envName}-gif-signing`,
items: [publicKey],
comment: 'Trusted key group gating /images/completion-gift-* via signed cookies',
})
gifSigningPublicKeyId = publicKey.publicKeyId
additionalBehaviors['/images/completion-gift-*'] = {
origin: s3Origin,
viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: AllowedMethods.ALLOW_GET_HEAD,
cachedMethods: CachedMethods.CACHE_GET_HEAD,
cachePolicy: CachePolicy.CACHING_OPTIMIZED,
compress: true,
// 署名 Cookie / 署名 URL を必須にする。無効・不在なら CloudFront が 403 を返す。
trustedKeyGroups: [keyGroup],
}
}
this.gifSigningPublicKeyId = gifSigningPublicKeyId
// 独自ドメインを使う場合、domainNames と certificate は必ずセット指定。
// 片方欠けると CloudFront が ACM なしで Alternate Domain Name を持てない or
// 証明書だけ作って未使用というデッドリソースになるため、構造的に弾く。
const hasDomainNames = !!props.domainNames && props.domainNames.length > 0
const hasCertificate = !!props.certificate
if (hasDomainNames !== hasCertificate) {
throw new Error(
`[StaticSite] domainNames と certificate は必ずセットで指定してください。` +
`(domainNames=${hasDomainNames}, certificate=${hasCertificate})`,
)
}
const distribution = new Distribution(this, 'Distribution', {
comment: `${props.prefix}-${props.envName} static site`,
defaultRootObject: 'index.html',
enabled: true,
enableIpv6: true,
httpVersion: HttpVersion.HTTP2_AND_3,
priceClass: PriceClass.PRICE_CLASS_ALL,
minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_2_2021,
// 独自ドメイン有効時のみ Alternate Domain Name と ACM 証明書を関連付ける。
// 未指定なら CloudFront のデフォルトドメイン (dxxxx.cloudfront.net) で公開される。
domainNames: hasDomainNames ? props.domainNames : undefined,
certificate: hasCertificate ? props.certificate : undefined,
defaultBehavior: {
origin: s3Origin,
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
}
}
⑧ .github/workflows/deploy-backend.yml (変更)
(変更内容)
GIF署名Cookie用キーペア(NUXT_GIF_KEY_PAIR_ID(実体ではなく、CloudFrontパブリックキーのID) / NUXT_GIF_SIGNING_PRIVATE_KEY)の取り回しを追加しています。
- 環境変数の取り込み:
Key Pair ID(公開鍵を指す)GitHub Variable、秘密鍵(PEMをbase64化したもの)はGitHub Secretから注入します。 - デプロイ前バリデーション(XORチェック): 両方設定 or
両方未設定のみ許可します。片方だけだとredeem(アプリ側の関数)がCookie
を発行できずGIFが403になるため、設定漏れをdeploy前にfailさせます。(保
護behavior未配備の環境では両方未設定でOK=GIFは公開配信のまま) - Lambda envマージ処理: マージする際、空のキーはマージ対象か
ら除外します(空文字での上書きを防ぎます)
※(ファイル全体)を掲載しているため、該当箇所のみ参照ください
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 化)**
# を更新し、加えて **本 CI 所有の env キー**(channel id + gif ticket secret)を設定する。
# インフラ自体(関数の枠 / 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 を反映)/
# NUXT_GIF_TICKET_SECRET(同名の GitHub Secret を反映。/gif-download
# アクセスチケットの HMAC 署名鍵。秘密情報のため Variable ではなく Secret)
# 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 }}
# 秘密情報(Secret)。/gif-download アクセスチケットの HMAC 署名鍵。
# Secret 名は Lambda env と同名 NUXT_GIF_TICKET_SECRET に揃える。
# 値は出力しない(-z での存在チェックのみ。空のまま deploy すると後段の
# マージで既存 env を空値で上書きしてしまうため、ここで早期に弾く)。
NUXT_GIF_TICKET_SECRET: ${{ secrets.NUXT_GIF_TICKET_SECRET }}
# GIF 実体の署名 Cookie 用キーペア。Key Pair ID は秘密でない識別子なので Variable、
# 秘密鍵(PEM を base64 化した文字列)は Secret。CloudFront 保護 behavior を有効に
# した環境では両方必須(片方欠けると redeem が Cookie を出せず GIF が 403 になる)。
# 保護 behavior を未配備の環境では未設定でも可(その場合は GIF は公開配信のまま)。
NUXT_GIF_KEY_PAIR_ID: ${{ vars.NUXT_GIF_KEY_PAIR_ID }}
NUXT_GIF_SIGNING_PRIVATE_KEY: ${{ secrets.NUXT_GIF_SIGNING_PRIVATE_KEY }}
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
if [ -z "${NUXT_GIF_TICKET_SECRET:-}" ]; then
echo "::error::Required GitHub Secret 'NUXT_GIF_TICKET_SECRET' is not set on environment '$TARGET_ENV' (generate with: openssl rand -base64 32)."
fail=1
fi
# GIF 署名 Cookie のキーペアは「両方設定」か「両方未設定」のいずれかに限る。
# 片方だけだと redeem が Cookie を発行できず(または Key Pair ID 不一致で)GIF が
# 取得不能になるため、設定漏れを deploy 前に弾く(XOR をエラーにする)。
if { [ -n "${NUXT_GIF_KEY_PAIR_ID:-}" ] && [ -z "${NUXT_GIF_SIGNING_PRIVATE_KEY:-}" ]; } || \
{ [ -z "${NUXT_GIF_KEY_PAIR_ID:-}" ] && [ -n "${NUXT_GIF_SIGNING_PRIVATE_KEY:-}" ]; }; then
echo "::error::NUXT_GIF_KEY_PAIR_ID (Variable) and NUXT_GIF_SIGNING_PRIVATE_KEY (Secret) must be set together (or both left unset). One is missing on '$TARGET_ENV'."
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 server-only env (channel id + gif ticket secret + gif signing keypair) into Lambda env ($LATEST)
env:
FN_NAME: ${{ vars.LAMBDA_FUNCTION_NAME }}
NUXT_LINE_LOGIN_CHANNEL_ID: ${{ vars.NUXT_LINE_LOGIN_CHANNEL_ID }}
# 署名鍵は秘密情報なので Variable ではなく Secret から注入する(平文露出を避ける)。
# Secret 名は Lambda env と同名に揃える。
NUXT_GIF_TICKET_SECRET: ${{ secrets.NUXT_GIF_TICKET_SECRET }}
# GIF 実体の署名 Cookie 用。Key Pair ID は識別子なので Variable、秘密鍵は Secret。
NUXT_GIF_KEY_PAIR_ID: ${{ vars.NUXT_GIF_KEY_PAIR_ID }}
NUXT_GIF_SIGNING_PRIVATE_KEY: ${{ secrets.NUXT_GIF_SIGNING_PRIVATE_KEY }}
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')
# 本 CI 所有キーを **マージ**(他キー=CDK 所有の DB 接続情報等は保持。同名のみ上書き)。
# 空の secret で既存値を消さないよう、事前に validate 済み(未設定なら deploy 前に fail)。
# GIF 署名キーペアは「保護 behavior 未配備の環境では未設定」を許すため、値が空のキーは
# マージ対象から落とす(空文字で上書きせず、CDK 等が入れた既存値も汚さない)。
MERGED=$(echo "$CURRENT" | jq -c \
--arg c "$NUXT_LINE_LOGIN_CHANNEL_ID" \
--arg s "$NUXT_GIF_TICKET_SECRET" \
--arg kid "$NUXT_GIF_KEY_PAIR_ID" \
--arg pk "$NUXT_GIF_SIGNING_PRIVATE_KEY" \
'. + {NUXT_LINE_LOGIN_CHANNEL_ID: $c, NUXT_GIF_TICKET_SECRET: $s}
+ (if $kid != "" then {NUXT_GIF_KEY_PAIR_ID: $kid} else {} end)
+ (if $pk != "" then {NUXT_GIF_SIGNING_PRIVATE_KEY: $pk} else {} end)')
# 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"
適用方法
アプリ全体のデプロイ手順の概要に関してはこちらの記事をご参照ください。
「今回、新たに追加された手順」
① 設定ファイルの編集
config/のdev/stg/prodに新しくgifSigning: {}フィールドが追加されたので、CDKデプロイ前に公開鍵を設定します。
AWSコンソール上で手動で設定する場合と同様に、作成済みの公開鍵(例:gif_public_key.pem)の中身をそのまま全部貼ります。
② 差分をCDKデプロイ
cdkdiffコマンドを実行して差分を確認し、deployコマンドでデプロイします。
再作成(置換)されるリソースがないか注意します(論理IDが変わる/本番環境に影響でるため)
# 差分を確認
npm run diff:dev -- --profile <your-profile>
# デプロイ
npm run deploy:dev -- --profile <your-profile>
③ GitHub Actions連携
GitHub Actions経由でAWS Lambdaの環境変数に渡す値をGitHubのブラウザから設定していきます。
NUXT_GIF_KEY_PAIR_ID(公開鍵を指す)はGitHub Variable、NUXT_GIF_SIGNING_PRIVATE_KEY:秘密鍵(PEMをbase64化したもの)はGitHub Secretに設定します。
現状の実装では、AWSコンソール上のAWS Lambdaの環境変数の設定画面では、秘密鍵が平文で表示されてしまいます。
重要な機密情報へのアクセスを制御する場合などは、
・AWS KMSを使用して暗号化する
・SecretManagerに保存して読み取る
といった対応が必要です。
以下のコマンドを実行して、Base64化した秘密鍵を設定します。
# base64化して別ファイルに書き出す
openssl base64 -A -in gif_private_key.pem -out gif_private_key.b64
# ファイルの中身をコンソールに表示する
cat gif_private_key.b64
なぜ秘密鍵をBase64化するのか
Base64: 「バイナリデータを文字列として表現するためのエンコード方式」
秘密鍵(PEM)は複数行テキスト(改行入り)で構成されているため、(github secretなど)保存する環境によってはフォーマットが壊れてしまう可能性があるため、base64 -Aを使用して '1行の'文字列に変換しています。
| 変数名 | 使用ワークフロー | 例 |
|---|---|---|
NUXT_GIF_KEY_PAIR_ID |
バックエンド | K1A2B3C4D5E6F7 |
NUXT_GIF_SIGNING_PRIVATE_KEY※シークレットの方に保存 |
バックエンド |
LS0tLS1CRUdJTiBSU0EgUFJJVkF...※(実際は数千文字になります。LS0tLS1CRUdJTi... で始まるのは -----BEGIN をbase64 化した先頭部分です) |
④ GitHub Actionsワークフローの実行
バックエンドのワークフロー(deploy-backend.yml)を実行し、バックエンド(Lambda)を更新します。
おわりに
RSA鍵ペア(公開鍵と秘密鍵)のセットアップ手順や、実際にアプリのバックエンド実装をまとめた関連記事もぜひ合わせご参照ください。
今回は以上になります!
最後までお読みいただきありがとうございました。