上線前的 checklist 幾乎都死於同一種方式——每一條後面是一個勾,而打勾是一個主張,不是一個檢查。這個系列寫了 28 天的 code 與文件,Day 29 把它們盤成 45 條,然後回答一個比「勾了幾條」誠實得多的問題:哪些條目輪得到機器驗,憑什麼。讀完這篇,你能替自己的清單畫出同一條線,並且認出清單上最危險的那種格子——測試是綠的,要求卻沒有被滿足。
成品在 lab 的 docs/production-readiness-checklist.md。這篇講它是怎麼長出來的,以及它長到一半時抓到了什麼。
動手前先否決掉三個常見的形狀,理由都跟「這頁到底攜帶什麼資訊」有關。
不打分數。 沒有百分比、沒有成熟度等級、不數勾了幾格。因為 45 條的權重不相等,而且更根本的是,後面掛著指令的條目跟後面掛著問題的條目,是兩種不同性質的主張。把它們平均成一個數字,等於親手銷毀這頁唯一有價值的資訊。
不設 [verifiable] 標籤。 一開始的草稿真的有這個標籤,後來整個拿掉,換成一條規則:可機器驗的條目,後面必須直接跟著一條可執行的指令。但指令只是候選證據——貼錯標籤不會有任何東西變紅,指令至少要跑;而一條指令要算得上檢查,還得過兩關:它的通過條件說的就是要求本身,而且你找得到一個會讓它 exit 非零的不想要狀態。這篇後面就有自己的反例——三道每一條都真的跑過的指令,齊齊放行了一個從未 ready 的版本。
不從 Well-Architected pillars 抄。 每一條都從這個 repo 已經寫下來的東西抽出來,逐條記出處。從通用框架抄下來的條目會問出「確保已設定適當的保留期限」這種句子——它長得像要求,其實是一句沒有主詞、沒有日期、驗證不了的空話。
掛得出指令的條目,指令要滿足三件事:通過條件與要求同義、違反時 exit 非零、而且這一輪真的跑過(2026-08-25,對著這棵樹)。
第一件最容易漏——ls tests/ 印得出目錄但永遠 exit 0,grep … | wc -l 數出多少都算跑完,這種指令是印表機不是斷言;清單裡所有「證明缺席」的指令因此一律寫成 ! 開頭的 assertion,不想要的東西一出現就變紅,而且每一條都注入過一次違反態驗證它真的會紅。
沒跑過的指令不准冒充可機器驗——要嘛降級成問題句,要嘛明寫「要一個部署中的 app 才驗得到」。就這三種落點,沒有含糊的中間態。
掛不出指令的條目不是比較差,是另一種東西。差別在問題的形狀。壞的問題句長這樣——「確保已設定保留期限」。好的長這樣——「你的保留期限是誰核定的,核定日期是什麼時候?」後者問得出日期與姓名,於是它答不出來的時候,你知道的是一件具體的事:沒有人做過這個決定。
盤點前我有一個假設:可機器驗的條目會集中在寫過 code 的主題——reliability、observability 掛得出指令,data governance 與 incident response 掛不出來。
分布方向沒錯:成品清單 45 條裡,observability 七條有六條掛得出指令,incident response 七條只有兩條。但 data governance 揭穿了「主題不同」這個解釋——八條裡有四條掛得出指令,其中兩條還是「證明缺席」的 assertion。真正的分界是那條要求問的是「程式的一個性質」,還是「某個人做過的決定」,而這條線穿過每一個主題。
! 開頭的 grep 證明 code 裡沒有任何 retention 設定——但它答的只是缺席那一半;「有沒有人在某一天核定過」永遠掛不出指令,不管再寫多少 code。它也在 data governance 裡,同一格裡同時站著分界線的兩側。
最值得警惕的是 incident response 那兩條綠色的。「有可查的稽核軌跡」與「能把一個請求從入口追到上游」都掛得出指令、測試都過——但指令驗到的是機制存在,而要求問的是用過沒有。稽核軌跡存在,不代表有人在事故裡查過它。這兩格是全表最容易自我欺騙的地方:測試綠了,要求沒有被滿足。
忍喵:「可查」跟「查過」中間差的是一次真的事故。你清單上還有幾個綠勾是這種——機制在,沒人用過?
45 條裡有不少「沒有」,而「沒有」往往也驗得出來——一條把缺席展示出來的指令,勝過一句宣稱缺席的句子。但這裡踩過兩個坑。
第一個:缺席證明需要精確的 pattern。「這個 lab 沒有 inbound rate limiting」這句話,用 grep -i limiter 掃 src/ 會回 11 個命中——8 個是常數 _DELIMITER、2 個是被處理的上游 RateLimitError、1 個是 docstring 裡普通的「delimiter」。沒有一個是 inbound 控制。一個寬鬆的 pattern 證明出來的是相反的結論。
第二個:讀回空值的指令不是讀回零的指令。az … -o tsv 在失敗時回空字串,而一個沒設防的比較式會把空字串變成一個「發現」。這一天差點真的發生——digest 對帳的讀回是空的,比較式判成「tag 被覆寫的情況真的發生了」,真因卻是另一個租戶的 refresh token 過期,跟 registry 毫無關係。比較要對著一個具體的值,空值要 fail closed。
| 節 | 條目 | ✅ 掛得出指令 | ⚑ 要部署中的 app 才驗得到 | ✎ 問題句 | 不適用 |
|---|---|---|---|---|---|
| Reliability | 8 | 6 | 0 | 2 | 0 |
| Observability | 7 | 6 | 0 | 1 | 0 |
| Cost | 8 | 2 | 1 | 5 | 0 |
| Data governance | 8 | 4 | 0 | 4 | 0 |
| Incident response | 7 | 2 | 0 | 4 | 1 |
| Rollback | 7 | 6 | 0 | 1 | 0 |
| 合計 | 45 | 26 | 1 | 17 | 1 |
(Security 不列條目——docs/security-checklist.md 在 Day 21 就是一份逐控制項指到實作檔的清單,這頁只放指標,不重述。)
「不適用」全表只有一條:on-call 輪值。判準收得很緊——只有「這條要求的是一個組織或一個真實使用者群」才准寫不適用;「做得到只是沒做」一律寫「沒有」。per-user quota 就是後者:Day 19 之後每個受保護請求都有已驗證身份,技術上做得到,仍然沒做——那就寫「沒有」,附上不做的理由。「不適用」一旦放寬,它就會變成整份清單的垃圾抽屜。
而全表最誠實的一格在 observability:這個部署有 trace、有 audit log、有一整個儀表板的資料,但沒有任何東西會在半夜叫醒任何人。唯一的 alert 是訂閱層的 budget alert——一個延遲的成本通知。
Rollback 那節有一條「你知道新版本起不來時,你的 health check 會怎樣嗎」。這一條的答案,是 Day 29 當天用一次部署換來的——而且是最壞的那個答案。
缺口是這個 lab 自己在公開文件裡寫下的。Day 25 的 docs/ci-cd.md 留了一個開放問題:single revision mode 下,新 revision 起不來時,/health 會不會由舊 revision 回答,讓探測「因為錯誤的理由拿到正確的 body」?當時賭的是不會這麼巧。
Day 29 把兩種失敗模式各注入一次:一個解析不到的 digest(拉不到 image),與一個拉得到但啟動就崩的 image(拿掉必要的 prompt 檔,讓 loader 在 composition time 直接 raise——觸發的正是 reliability 那節才勾過的 fail-fast 設計)。
兩次的結果相同:部署腳本的三道檢查全過,exit 0,印出「Verified」。 template image 讀回過了,因為它回報的是「你要求了什麼」;runningState 讀到 Activating,不是已知失敗態,放行;/health 回了逐位元組正確的 body——因為舊 revision 還在服務。
這不是推論。Container Apps 的 console log 帶 RevisionName_s,外部探測兩次都落在舊的 revision 上,而新的那個要嘛零筆 log(拉不到 image),要嘛正在 crash loop:
01:45:03.66Z aca-…--0000002 "GET /health HTTP/1.1" 200 OK ← 舊 revision 回答探測
01:45:04.71Z aca-…--0000003 PromptTemplateError: … default_chat.md ← 新 revision 正在崩
最刺的是——控制面一直分得出來。latestRevisionName 是壞的那個,latestReadyRevisionName 停在舊的那個,兩個欄位腳本都沒讀。而直覺上該當判準的 trafficWeight 不是判準:壞 revision 上兩次都讀到 100。

這件事收進 checklist,就成了那條問題——你的部署閘門分得出「新版本在服務」與「有東西在服務」嗎? 修完之後的四道檢查裡,三道回答的是後者,只有一道回答前者。
修法是在 update-container-app.sh 加 step 3b:輪詢 latestReadyRevisionName,直到它等於這次更新產生的 revision,等不到就失敗。
是有界輪詢,不是單次讀回——健康的部署上,這個欄位本來就會比 latestRevisionName 晚一步才跟上,單次讀回會把每一個正常部署都判成失敗。
然後對真 Azure 重驗三次:不存在的 digest → exit 1;startup 崩 → exit 1;健康的部署 → exit 0。第三次最重要。只證明新閘門會擋、不證明它不誤擋,等於沒驗完——一個把正常部署也擋下來的閘門,幾天內就會被人加上 || true。
同一場還修了一個小但要命的東西:回退指令原本只在失敗路徑印出,而上面兩次失敗都 exit 0,所以操作者在最需要回退指令的時刻一張都拿不到。現在成功路徑也印。但它有一個誠實的限制要一起講:那條指令回退到的是 pre-mutation snapshot——「上一個狀態」不等於「上一個好的狀態」,正常使用時兩者重合,事故連續發生時不會。
順帶把回退本身也執行並計了時:兩次控制面收斂各為 20 秒與 21 秒(同一時鐘、指令送出到新 revision 出現且無已知失敗態)。兩次觀測不構成趨勢,也不是 SLO,量到的是控制面、不是資料面何時可用——但「回退真的執行過」那條,從此後面有紀錄可指。
清單盤到 cost 那節時,掉出一個這個 repo 部署過兩次都沒發現的事實。
「你列得出部署打開哪些 meter,也量得出每個 meter 的用量」——meter 清單有,但 ACA 帳單上最大的兩項是 vCPU 秒數與記憶體 GiB 秒數,而 app YAML 裡沒有 resources: 區塊。replica 數釘死了,CPU 與記憶體卻交給平台預設值。換句話說,這個 repo 從沒記下自己在為多少 CPU 付錢,要部署一次、讀回一行才知道——讀回是 0.5 vCPU、1 GiB(2026-08-24)。
忍喵:replica 釘死了,CPU 交給預設值——帳單上最大的一項是多少,部署之前連這個 repo 自己都不知道。去讀回你的resources:。
拿 Azure Retail Prices API 對日本東部(japaneast、USD、Consumption,查核 2026-08-24)核價:這個配置約 0.054 USD/小時。
計費單位與 Container Apps 計費文件寫的一致——vCPU 秒與 GiB 秒(查核 2026-08-25)。
ACR Basic 是 0.1666 USD/天,meter 單位是 1/Day,定價頁寫的也是 per day(查核 2026-08-25)——本篇估算把這個 session 以一整天計入;至於不足一天實際怎麼計,兩個來源都沒說,這裡不假設。
Log Analytics 的擷取落在 5 GB 免費層(Azure Monitor 定價頁,查核 2026-08-25)——額度是每帳單帳戶每月前 5 GB,不是每 workspace,而「當月額度還沒被帳戶裡別的東西用掉」是本篇沒有查的前提。
有一件事要照實講:Log Analytics 的免費層在 API 裡看得見(一列 0 元的 tier),Container Apps 的看不見——所以本篇不引用任何 ACA 每月免費額度的數字,估算一律當作沒有免費額度,只會高估不會低估。
另一條 cost 缺口更隱蔽。這個專案把 LLM timeout 從 SDK 預設的 600 秒調緊到 30 秒,retry 上限留在預設的 2——而 timeout 是 per attempt。用一個不回話的 loopback server 實測(openai==2.45.0):一次邏輯呼叫,伺服器端到達三次,x-stainless-retry-count 從 0 數到 2,而 SDK 產生的 Idempotency-Key 從來沒被送出去過——在伺服器眼裡那是三個互不相干的新請求。
Day 9 的 ledger、usage log 行、audit event 記的都是「回得來的那一次」。所以 Day 9 已揭露的缺口(失敗的 turn 可能有沒記到的錢)之外,還有一條它蓋不到的:成功的 turn 也可能有沒記到的錢——使用者拿到答案、帳記了一筆,背後最多兩次沒人記帳的額外到達;那兩次有沒有被服務端處理、有沒有計費,這一輪沒有確認。
至於 Azure 對被放棄的 attempt 收不收費,官方 FAQ 的判準是「服務端有沒有做處理」(該頁 ms.date 2026-08-04,查核 2026-08-25),並把 408 逾時列為會收費的例子、429 列為不會——但整頁沒有提 client 放棄的情境,本系列查不到就不裁決,兩個方向都不寫死。
latestReadyRevisionName 的落後時間沒有量——只知道有界輪詢在三次重驗裡都夠用。這份清單的做法是從自己的 repo 往外抽,刻意跟通用框架反向。要一份不綁定任何 repo 的完整框架,Azure Well-Architected Framework(查核 2026-08)的五個 pillar 蓋得比 45 條寬——但也正因為寬,它的每一條都需要你自己補上這篇在做的事:掛上指令,或把它改寫成問得出日期與姓名的問題。
用到的 Azure 服務:
chat-mini,常駐、純 token 計費——readiness gate 與 smoke 打真模型)45 條盤完,這個 lab 作為「一個 app」的形狀就到這裡了。Day 30 是系列最後一篇:回顧這 29 天的取捨,然後把視角拉遠——從單一 app 走向 platform 的時候,model gateway、agent workflow、governance 這些字各自接在今天哪一條的後面。
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。