
系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code / Terraform / GitHub Actions
今日進度:push 到 main → Go 映像自動上 ECR 與 Lambda、Vue 自動上 S3 + CloudFront;OIDC 拿權限,零金鑰
Day 15 的敵人才剛學會走路,今天卻要先離開前端一天。IaC 分三階段推進:Day 9 寫完「會留下資料的東西」但只跑 plan,今天補上「隨時可重建的東西」並第一次真的 apply,Day 24 再疊「會叫的東西」。
今天的目標是把「部署」變成一個副作用:合併 PR 之後,我不需要記得任何指令,也不需要在本機留 AWS 金鑰。
交付物是容器映像:第一階段用 golang:1.26 編譯,第二階段從 public.ecr.aws/lambda/provided:al2023 帶走要跑的:
COPY --from=build /out/bootstrap /var/runtime/bootstrap
COPY data/ /var/task/data/
CMD [ "bootstrap" ]
執行檔仍然必須正好叫 bootstrap,自帶 runtime 的硬規定沒變;變的是 package_type = "Image" 之後 runtime、handler、filename 全不存在,進入點只寫在 CMD。build context 是 repo 根目錄:data/ 在 Go module 之外,指令結尾那個 . 是重點。
雷:buildx 的 attestation 會讓 Lambda 拒收映像。 build 綠、push 綠,apply 才紅:image manifest, config or layer media type ... is not supported。buildx 預設附掛 provenance/SBOM attestation,產出 OCI manifest list,Lambda 不吃;log 裡那行 exporting attestation manifest 就是徵兆。scripts/lambda-image.sh 因此把旗標釘死:docker build --platform linux/arm64 --provenance=false --sbom=false -f backend/Dockerfile .
--platform 也不能省:Apple Silicon 上不釘也是 arm64,x86 的 CI runner 上不釘就建出 amd64,要到建立函式時才報錯。換來的是 make image 後 docker run 加 Lambda RIE 就回 {"statusCode":200,...}——跟線上同一份 bytes。

compute / cdn / cicd 三個 module我把 Day 9 的 foundation 貼給 Claude Code 當範本,請它產出這三個。函式的殼很單純:arm64、512 MB、逾時 15 秒(夾在前端 8 秒與 API Gateway 20 秒之間)。要我拍板的是兩行:
# modules/compute/main.tf(節錄)
logging_config { log_format = "Text" }
lifecycle { ignore_changes = [image_uri] }
日誌格式預設是 JSON,但一設 JSON,Lambda 會把我每行輸出再包一層信封,Day 24 那個比對 level 的 metric filter 就會比到 Lambda 的欄位——告警永遠不會響,也不會有錯誤訊息。
ignore_changes 是 Terraform 與 CI 不打架的理由:Terraform 管殼,CI 管碼。但 zip 時代「先 apply 壞掉的 placeholder、再 deploy 換碼」那個順序整個反過來了:必須先有拉得到的映像,apply 才會成功——Lambda 建立函式的當下就去 ECR 拉 manifest 驗證,沒有就是 InvalidParameterValueException: Source image ... does not exist。開局因此是先 apply -target=module.foundation 生出 repo、推第一版映像,才 apply 其餘。
ECR 三件套(repository、只留 5 個映像的 lifecycle policy、repository policy)放在 foundation:映像沒了,函式連冷啟動都起不來。第三件最容易漏——Lambda 是用服務主體 lambda.amazonaws.com 拉映像,不是執行角色,得補一條 repository policy,用 aws:sourceArn 鎖到那支函式。
API 邊緣是 REST API v1:不是偏好,是 ap-east-2 根本沒有 apigatewayv2。零件多一圈:{proxy+} 之外要補一組 root 的 ANY,整合一律 AWS_PROXY,access log 還多要一個 aws_api_gateway_account,少了它 apply 死在 stage。aws_api_gateway_deployment 最該小心:v1 沒有 auto_deploy,triggers 只雜湊 .id 的話 apply 全綠、說沒漂移,stage 卻永遠服務舊快照。
雷:SPA 重新整理 403。 按 F5 時 CloudFront 去私有 S3 找 battle/ashfield,私有 bucket 找不到回的是 403 不是 404,我用兩段 custom_error_response 接回 /index.html。但那是 distribution 層設定,/api/* 也吃得到——Day 28 才知道有多痛。

我拒絕在 GitHub Secrets 放長期金鑰。做法是建一個 OIDC provider 信任 GitHub 的 token 端點,再建 deploy role,trust policy 的 sub 只允許 repo:<owner>/<repo>:ref:refs/heads/main。Secrets 裡只剩 role ARN,它不是祕密——外洩了也沒人 assume 得動。
這個 role 只有四類權限:限定到單一函式的 lambda:UpdateFunctionCode、鎖在那一個 repository 上的 ECR 推送(五個 layer 動作加 PutImage;只有 GetAuthorizationToken 沒有資源層級授權、只能開 "*")、網站桶的物件讀寫與列表、CloudFront 的建立失效。我請 Claude Code 列「workflow 會呼叫的 AWS API」,它連 iam:PassRole 都列了——那是註冊容器工作定義才要的。AI 列清單比我快十倍,刪清單得我自己來。
ci.yml 是守門員,後端、前端、基礎建設三個平行 job 都不需要 AWS 憑證——刻意守住的界線,fork 來的 PR 也能安全跑完。基礎建設那個 job 只做到 validate:Day 9 那個 -backend=false 陷阱還在,沒憑證走不到 plan;手工步驟今天收成 make plan-offline,但假憑證驗不了線上漂移,CI 沒接。
# .github/workflows/deploy-api.yml(節錄)
steps:
- uses: docker/setup-qemu-action@v3 # runner x86,映像要 arm64
- uses: docker/setup-buildx-action@v3
- uses: aws-actions/amazon-ecr-login@v2 # 前一步 configure-aws-credentials
- run: make push-image # IMAGE_TAG=${{ github.sha }}
- run: aws lambda update-function-code --publish --function-name $FN
--image-uri "$REGISTRY/$FN@$DIGEST"
Go 不用裝,編譯在 Dockerfile 第一階段發生,QEMU 與 buildx 才是必要的(再加 OIDC 前提 id-token: write)。觸發條件的 paths 含 data/**:資料被 COPY 進映像,改關卡數值也要重新部署。更新用 digest 不用 tag——tag 會被下次 push 覆寫,digest 不會;最後 wait function-updated-v2,綠勾勾才代表新版就位。
最後的煙霧測試「healthz → 開存檔拿 token → 讀劇情」不可省略:Lambda adapter、payload 1.0 的事件形狀、CloudFront 的 /api/*,本機都驗不到。
deploy-web.yml 分兩段上傳:帶雜湊的靜態檔一年快取,首頁不快取。infra.yml 在 PR 貼 plan 留言、合併後才 apply;我盯的不是 diff,是最後那行——刪除不是 0 就停下來。

.claude/settings.json 從 Day 6 起就 deny terraform apply,出口只有兩個:我在終端機按,或走 infra.yml 的人工核准;這次兩個都用上——先在終端機 -target=module.foundation 建出 ECR、推第一版映像,剩下的交給人工核准。刪除欄是 0,兩段合計 46 個資源,Day 9 的地基今天才第一次存在。
| 階段 | 耗時 | 備註 |
|---|---|---|
| apply 46 個資源 | 9 分 14 秒 | 分兩段,CloudFront 約 8 分鐘 |
| 排除命名與權限雷 | 31 分鐘 | 見下 |
| 排除 IAM 的 ARN 雷 | 12 分鐘 | 連 log 都寫不出來 |
映像的代價也量到了:冷啟動從 zip 版的 0.21 秒變成 1.6 秒,熱請求 0.15–0.25 秒、兩者沒差;加上 ECR 儲存,月費 US$1.33 變成約 US$1.35。
雷一(31 分鐘):bootstrap 命名,然後是一條對不到任何東西的權限。 第一次部署全部請求都 500。前 10 分鐘我把執行檔叫成 api,日誌只有一行 Runtime.InvalidEntrypoint;改完名字 500 還在,而且更難看:每個請求回 {"message": "Internal server error"}、連一條 log stream 都沒有,可是 aws lambda invoke 直接打函式回 200。沒有 log 不是日誌壞了,是函式沒被叫到。
兇手是為了讓 plan 離線跑而設的 skip_requesting_account_id = true:provider 算不出帳號 id,execution_arn 回來是 arn:aws:execute-api:ap-east-2::gt5lxmot3k/*/*,帳號那格是空的。permission 建立成功、apply 全綠、Lambda 顯示 Active,卻匹配不到任何呼叫者。修法是別碰 execution_arn,自己用 var.aws_account_id 組。為了「離線也能驗證」做的妥協,自己也需要被驗證。
雷二(12 分鐘):IAM 政策裡的 log group ARN 少了 :* 後綴。 logs:PutLogEvents 的資源是 log stream 不是 group,尾巴必須補 :*。少了那兩個字元,函式跑得正常,但一行 log 都寫不出來——而我正好在用 log 找雷一。
今天之後,git push 就是出兵。真正花時間的四件事都不是 Terraform 語法問題:buildx 偷偷附掛的 attestation、bootstrap 這個檔名、一條少了帳號 id 的 ARN、log group ARN 尾巴那兩個字元。Claude Code 給的第一版是「正確的通用做法」,雷是它碰到具體區域、runtime、registry 才長出來的——共同點是本機驗不到。
如果你只打算從這篇抄一樣東西:抄 OIDC。它讓「金鑰外洩」從一種要管理的風險,變成一個不存在的類別。
backend/Dockerfile(兩階段,44 MB)、scripts/lambda-image.sh、make image/push-image
modules/foundation 補 ECR 三件套;modules/{compute,cdn,cicd} 與 envs/prod 接線.github/workflows/{ci,deploy-api,deploy-web,infra}.yml
healthz 回 {"status":"ok"},CloudFront 看得到 Day 15 的空戰場Day 17:塔的升級樹:用 Pinia 管理遊戲狀態與資源經濟系統——回到前端,讓塔開火、讓金幣流動。