
系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code / CloudWatch Logs Insights / Cost Explorer
今日進度:十二個「本機永遠遇不到」的雷整理成表、一張從 US$105.6 掉到 US$1.35 的月費帳、本週數據
第四週結束。這週吃掉時間的是部署當下才會出現的東西:本機 make dev 全綠、CI 全綠、terraform apply 成功,然後正式環境回 401。
這篇週記把十二個雷排成一張表:症狀、原因、修法,以及 Claude 幫上什麼、沒幫上什麼。最後三個是第一次 apply 就炸出來的,嚴格說不屬於這六天。第二節是錢:Day 9 承諾的月費表,改成無伺服器之後整個翻了過來。
| # | 症狀 | 原因 | 修法 | Claude 的角色 |
|---|---|---|---|---|
| 1 | 上線後所有 /saves/{id}/* 回 401,本機正常 |
CloudFront /api/* 預設不把 Authorization 轉給 origin |
設 origin_request_policy_id = AllViewerExceptHostHeader;不能用 AllViewer——API GW 靠 Host 選 API,轉過去直接 403 |
它第二題就問有沒有轉 header;但 Day 22 生成 behavior 時沒提醒 |
| 2 | deploy-api 綠燈,煙霧測試卻打到舊版行為 |
update-function-code 回來只代表新映像收下了,LastUpdateStatus 仍是 InProgress |
中間插一行 aws lambda wait function-updated-v2,實測約 6 秒 |
它第一版 workflow 沒這行;我問「為什麼偶爾抓到舊行為」才指對 |
| 3 | 部署後玩家看到舊畫面,Console 一排 chunk 404 | s3 sync 沒設 Cache-Control,被快取的舊 index.html 指向已被 --delete 刪掉的 chunk |
兩段 sync:assets/* 給 max-age=31536000, immutable、index.html 給 no-cache;只失效 /index.html |
「只失效 index」是它補的,省下每次 /* 的錢 |
| 4 | 更新存檔噴 ValidationException: reserved keyword |
第一版用 UpdateItem,SET name = :n, status = :s——兩個字都在保留字表裡 |
先用 ExpressionAttributeNames 別名;後來改成整包 PutItem + 樂觀鎖,這雷自己消失 |
它第一版就寫 UpdateItem——關聯式資料庫的肌肉記憶 |
| 5 | infra.yml 的 apply 連續失敗六次,每次少一個 IAM 權限 |
部署 role 從最小權限開始加,每多一種資源就缺一個 action:lambda:CreateFunction、cloudfront:CreateDistributionWithTags 等六個 |
改成 PowerUserAccess 等效的 inline policy + deny iam:*User*、iam:*AccessKey* |
它每次都猜得出缺哪個 action,但六次來回本身就是訊號:IaC role 不該從零列 |
| 6 | GetItem/PutItem 全正常,只有一句 GSI 查詢回 AccessDeniedException |
執行角色只列了表 ARN,沒列 ${table_arn}/index/GSI1 |
兩個 ARN 都列 | Day 16 的雷換個載體再踩一次(那次是 log group ARN 少了 :*)。記憶體版與 CI 都碰不到 |
| 7 | 前端 res.json() throw Unexpected token '<',只在錯誤路徑上 |
Day 16 為 SPA 深連結加的 custom_error_response 是 distribution 層設定,把 API 的 403、404 一起改寫成 200 + HTML |
拿掉它,改成只掛在 default behavior 的 CloudFront Function,/api/* 不掛 |
我問「為什麼 404 會變成 HTML」才問對 |
| 8 | Day 24 第一次演練沒收到信 | SNS email 訂閱要按確認信,沒按就是 PendingConfirmation |
按確認;terraform output 加一行提醒 |
沒問它,20 分鐘後自己在主控台看到 |
| 9 | Logs Insights 查「昨天的錯誤」查到今天的 | Insights 的時間範圍是 UTC | 查詢用 datefloor,runbook 註明 UTC |
它提醒過一次我還是忘了。和 Day 21 的坑 #6 同一組:時間在系統邊界永遠要標時區 |
| 10 | plan 過了兩次,apply 第一句就死:HTTP APIs are not available in this AWS region |
ap-east-2 的服務目錄裡沒有 apigatewayv2 |
API 邊緣整個換成 REST API v1:{proxy+} 與 root 各一組 ANY、具名 stage prod |
貼上錯誤就拿到 ssm get-parameters-by-path 這條查法;但沒人在我動工前查 |
| 11 | 同一次 apply 的第二個錯:EnableAcceptEncodingGzip is invalid for policy with caching disabled |
自建 cache policy 的 TTL 全 0 就是「快取關閉」,這種 policy 不准開 gzip | 改用託管 CachingDisabled,壓縮上移到 REST API 的 minimum_compression_size = 1024:實測 1,885 → 663 bytes |
它一眼認出是 AWS 的參數限制,不是 HCL 寫錯 |
| 12 | 映像 build 綠、push 綠,apply 才紅:The image manifest, config or layer media type ... is not supported |
buildx 預設附掛 provenance/SBOM attestation,推上去的是 OCI manifest list,Lambda 只吃單一平台的 Docker manifest | docker build --provenance=false --sbom=false;build log 那行 exporting attestation manifest 就是徵兆 |
它問「build log 有沒有 attestation」——問題問對,答案就在我自己的終端機裡 |

十二個雷有一個共同點:沒有一個是程式邏輯錯。前九個是「兩個系統的預設值合起來不對」——CloudFront 預設不轉 header、s3 sync 預設不寫 Cache-Control、SNS 訂閱預設要人按確認;後三個更狠:這個區域根本沒有那個服務、那組參數 AWS 根本不收、那種 manifest Lambda 根本不拉。Claude 對每個預設值都知道,但它只在被問到時才說,而我只在踩到時才問。
假設刻意保守:API 8,000 次/月、Lambda 160 GB-秒、DynamoDB 3,000 寫/5,000 讀、CloudFront 3 萬次請求 + 1.5 GB 傳出,而且把永久免費額度當成用完。
| 項目 | 月費 | 備註 |
|---|---|---|
| CloudWatch alarms(6 個) | 0.60 | US$0.10/個;占 44% |
CloudWatch 自訂指標(ErrorCount) |
0.30 | Day 24 的 metric filter |
| CloudFront | 0.25 | 還在免費額度內,這列是保守值 |
| CloudWatch Logs(攝入 + 儲存) | 0.07 | |
| S3(網站 + tfstate) | 0.04 | |
| DynamoDB(on-demand + PITR) | 0.02 | |
| ECR(映像 44 MB × 5) | 0.02 | US$0.10/GB/月 |
| API Gateway REST API | 0.03 | US$3.50/百萬;HTTP API 只要 1.00,但台北沒有 |
| Lambda(arm64、512 MB) | 0.01 | |
| SNS(email) | 0.01 | 每月前 1,000 封免費 |
| SSM Parameter Store/Budgets/CF Functions | 0.00 | 免費範圍內 |
| VPC / NAT / ALB / RDS / Fargate | — | 不存在 |
| 合計 | 1.35 | WAF 開著另計 +8.03 |

三個觀察:
第一,最貴的東西是「告警」,不是「運算」。 監控(六個 alarm + 自訂指標)0.90,占 67%;真正在跑遊戲的 Lambda + API GW + DynamoDB 只有 0.06。省錢的下一步不是優化程式,是問「六個告警需要六個嗎」。
第二,零玩家 US$0.92,一百個玩家 US$1.35。 舊架構我寫過「一百個玩家跟零個玩家差不了三美元」,理由是固定費占 88%;新架構固定費占 0%,同一句話變成「差不了半美元」,理由卻是變動費太低——同一個現象,兩種相反的原因。
第三,WAF 是這裡唯一貴的東西。 US$8.03,是其餘全部的六倍。Day 27 開起來第一小時就擋了 37 次對 /wp-login.php、/.env 的掃描——但我用一週的錢買了它,今天把 enable_waf 設回 false。這一開一關抵消:加一個 aws_cloudfront_function、關掉一個 Web ACL,plan 仍是 51 個資源。
但書:ap-east-2 是新區域,單價未必和別區一樣,上表是用公開單價推的量級,不是帳單抄本。
更該寫下來的是代價:冷啟動仍在,而且換成容器映像之後更明顯(實測 1.6 秒,熱請求 0.15–0.25 秒);每 IP 的精準限流做不到,行程內 token bucket 在 Lambda 上無效,只剩全域 throttling;單表設計把「換一種查詢方式」的成本從一句 SQL 變成一次資料遷移。這三件都不是錢能解決的。
以下是這六天(Day 22–27)的數字,不含今天。
| 項目 | 數值 | 備註 |
|---|---|---|
| 專注工時 | 1,010 分鐘(6 天) | 排雷佔 260 分鐘 |
| Claude Code session | 13 | 含 Day 26 的 2 個 worktree、/ultracode(我自訂的 slash command,不是官方功能)的 3 個 subagent |
| 部署次數 | api 11、web 9、infra apply 6 | 6 次 apply 有 4 次是雷 5 |
| 測試總數 | Go 62、vitest 20、Playwright 9 | 本週新增 Go 8、vitest 8、Playwright 9 |
/ultracode |
1 次,24 分鐘 | Day 26 的池子 bug |
| 告警 | 2 次演練 + 1 次真實(Day 26 壓測觸發 lambda-duration-p99) |
|
| 排雷平均耗時 | 29 分鐘/個 | 本週九個,最長是雷 5(75 分鐘) |
跟前三週對照:830、790、1,120、1,010。session 數跟第三週差不多,形狀卻不同:這週有一半是「貼錯誤訊息、拿到方向、關掉」的短對話。
預設值是雷的原料。 十二個雷有八個是預設值——buildx 那個也是:沒人叫它加 attestation,是它自己預設加的。現在我在 apply 前多一步:把 plan 貼給 Claude 問「哪個預設值會讓我在部署當下嚇一跳」——雷 3、7 就是這樣擋下的。
同一個雷會踩兩次,除非它變成檢查項。 雷 6 就是 Day 16 那個「ARN 少一截」的翻版。「只追加、不重寫」的 PITFALLS.md 因此多一行「新增資源 → 執行角色的 Resource 連子資源都列了嗎」。
本機全綠只證明本機,plan 全綠也只證明 plan。 contract test、storylint、tfsec 都在 CI 裡,但雷 1、3、7 只在真實的 CloudFront、S3、Lambda 上發生;雷 10–12 更極端——plan 不會問 AWS「這個區域有沒有這個服務」「這種 manifest 你收不收」,那條安全帶根本沒拉上。所以 deploy-api 最後一步是煙霧測試:等 function-updated-v2,再用真實 token 打 /healthz。
這週六個主題都完成,但週記只寫雷,因為雷是「Claude 知道、我沒問」的集合——只有最後兩個例外,那要真的 apply 下去才知道。AI 對預設值的知識很完整,卻不會主動拿你的架構推演它們的後果——除非你問對問題。
至於那張從 105.6 掉到 1.35 的帳,我最想說的不是「省了一百美元」,而是沒有任何一個資源在我睡覺時按小時計費。錢的形狀變了,煩惱也跟著變:以前擔心帳單,現在擔心冷啟動。
明天上線 demo,後天終章。
PITFALLS.md
modules/cdn 用 aws_cloudfront_function.spa_rewrite 取代 custom_error_response;enable_waf 設回 false(plan 仍是 51)deploy-api.yml 補 wait function-updated-v2 與部署後煙霧測試docs/weekly/
Day 29:塔防遊戲正式上線:demo 展示、架構全貌圖與開發數據總結。