這個系列的 lab 走完 30 天時,有 2008 個單元測試、behave 的 8 個 feature、CI 四個 job、三個 export drift gate——而這篇挑出的六個錯誤——每一個都有日期、有紀錄——全部發生在這些東西全綠的時候。這篇把那些「檢查通過、事情是錯的」攤開來分類:六種形狀,每種一個有日期的案例、一個修法,以及最後一個問題——真正抓到它的是什麼。讀完你可以拿這六種當 checklist,掃一遍自己 pipeline 裡的綠燈。
先劃兩條線。Day 29 問的是「一條 checklist 怎麼掛上會失敗的指令」;本篇問的是掛了指令之後的事:指令綠了,你能信多少。Day 30 驗收的是產物(這個架構搬不搬得動);本篇驗收的是方法。至於每個案例自己的完整故事,各天的文章已經講過或即將講。本篇只做各篇做不到的事:把它們放在一起看。

Day 25 的 live session,az role assignment list 當場拒絕 --all 與 --scope 併用:
ERROR: group or scope are not required when --all is used
create 腳本在建了六個物件之後中止,delete 在刪掉兩個 federated credential 之後中止。而在此之前,整套單元測試是綠的——因為 fake 的 az 對這兩個旗標照單全收。測試守護的那條指令,真的 CLI 根本不會執行。
更值得記的是成因:那個 --all 是 Day 24 學來的規則(「role assignment 讀回一律帶 --all」),但它的適用範圍是「當你沒指定 scope 時」。** 一條從真 bug 學來的規則,被套用時脫離了它的邊界,就是下一個 bug**,而且會通過所有為前一個 bug 建立的防護。
修法有兩半,缺一不可:呼叫端修旗標,同時 fake 改成用真 CLI 的訊息拒絕同一種組合(測試在這裡)。驗收方式是變異:把壞旗標放回去,7 個測試從全綠變 3 個失敗。老實說,這個 lab 還有兩個同類的 fake 至今不驗旗標——已列管未修,因為今天的呼叫端都寫對了。fake 的保真度是一條永遠在欠的債。
忍喵:你的 fake 上一次跟真 CLI 對過表是什麼時候?沒有答案的話,你的全綠跟這篇的全綠是同一種。
完整故事在 Day 25(它是「接上線」那一節的錨點)。
Day 29 對部署腳本注入兩種失敗(拉不到的 image、拉得到但啟動就崩),兩次的結果相同:三道檢查全過、exit 0、印出「Verified」。template 讀回過了,因為它回報的是「你要求了什麼」;runningState 讀到 Activating,不在已知失敗態清單,放行;/health 回逐位元組正確的 body——舊 revision 還在服務。而 latestReadyRevisionName 從頭到尾都停在舊 revision 上,分得清清楚楚,只是腳本沒讀它。
這條的完整展開(含 server 端身分證據與配圖)留給 Day 29 那篇;修好之後的腳本把整段來歷寫在 header 裡。這裡只取它的形狀:讀回要讀平台的判定,不是自己要求的回聲——你剛寫進去的值,讀回來當然一樣,那不是驗證,是回音測試。
證明用的工具也是程式,也會有 bug,而它的 bug 產出的是假證據。
Day 29 量 SDK retry 行為的 probe,用單執行緒 HTTPServer 且 handler 阻塞了 accept():後兩次連線塞在 backlog 裡從未被收下,於是 probe 印 arrivals: 1,看起來像「重試沒送出去」。拆穿它的是同一份輸出裡的另一個數字:elapsed: 4.7s 在兩個版本一模一樣——那個時長早就在說重試發生過了。
Day 27 的取證撞到另一種:同一句 KQL requests | count,az 的 -o tsv 印出 1,-o json 裡的真值是 12。一個看起來完全合理的計數,尤其當你在驗證「應該是 0 筆或很少筆」的時候。Day 27 那篇會展開這條,連同它換來的取證紀律:要當證據的數字,用 json 讀,不用 tsv 讀。
還有一次是量測工具「慢了」:Day 24 的 log 擷取每 8 秒抓一次快照,一行晚到 6 秒的 log 沒被收進來,smoke 因此記 12/13。事後從 Azure 確認那行存在,但工具不記 correlation id、綁不回自己的請求,所以如實記 12/13 不宣稱 13/13。這是這類案例裡唯一的正面例子:工具的極限被寫進結論的限定詞,而不是被結論掩蓋。
Day 21 修過一對經典:for _ in $(seq 1 "$KNOB") 在 knob 不是數字時無聲跑零圈,直接廢掉它保護的機制——而且第一版修法(驗數字)還會讓 "00" 以數值零溜過,因為 GNU 的 seq 對空範圍不吭聲。
同一輪也發現 az … -o tsv 讀回在 query 出錯時會 fail closed,結果為空時卻不會——三處讀回全數補上 require_nonempty(腳本在這裡)。
這類問題最難堪的一次在 Day 29:digest 對帳把一次空讀判成「DIFFERENT——tag 警告的情況真的發生了」,差一步就把假發現寫進紀錄。拆穿它的不是比較式,是回頭讀那條查詢自己的錯誤輸出——stderr 寫著 AADSTS70043,另一個租戶的 token 過期,跟 registry 一點關係都沒有——然後換一條路徑(data plane 的 docker pull 讀 RepoDigests)把 digest 重取一次。
它踩的正是 Day 19 與 Day 21 都記過的同一個 tsv 陷阱——而且踩在為這個陷阱寫的檢查裡。
處置是兩句話:空值一律 fail closed;以及在用一個 predicate 宣告「沒有殘留」之前,先證明它在違反狀態下抓得到東西。一個從來沒紅過的檢查,你不知道它會不會紅。
Day 24 部署當天挖到三個 bug,全部長在文件與直覺的反面:az acr build --file 相對的是呼叫者的 cwd 而不是 build context(--help 寫的是相反的,CLI 自己的原始碼註解才講真話)。
第二個:containerapp 的 YAML 省略欄位不等於不送。extension 把 YAML 過一遍 SDK model 再整包序列化,沒寫的欄位全變明確的 null,API 對其中一個 non-nullable boolean 回 400,錯誤訊息還不指向任何欄位。第三個:YAML 註解裡的一對反引號,在未加引號的 heredoc 裡是 command substitution,炸掉之後下游完全看不出來。
這三個,review 抓不到,Day 23 才強化過的 CI boot smoke 也攔不到——全部要真的執行才顯現。
它們如今都有 regression test 且驗過修前為紅;後兩個的測試刻意選了不同形狀,一個驗產生出來的 YAML,另一個直接驗 script 原始碼,因為 fake 的 az 永遠不會打開那個檔(測試在這裡)。
前兩個的完整故事在 Day 24:標題「沒寫不等於預設」就是第二個 bug。第三個沒寫進那篇文章,它的公開落點就是上面那支 regression test 與它的註解。
最後一種最陰:工具根本沒跑,而失敗的樣子跟成功一模一樣。
Day 27 的 live session 一天內集齊了兩款。第一款:把長輸出接上 | tail 之後,整條管線的 exit code 屬於 tail。docker build 與 teardown 腳本各中了一次,其中一次是 15 分鐘零輸出、最後拿到一個乾淨的 0。第二款:zsh 的 noclobber 讓 > 對已存在的檔案直接失敗,重導向在命令執行前就爆掉。那天 mktemp 先建了檔,curl 的 config 因此是空的,401 看起來完全像 token 問題。最小重現(zsh,macOS,2026-08-26):
# noclobber 已開、輸出檔已存在
python3 -c 'print("CHECK RAN")' > out.txt
# zsh: file exists: out.txt
# exit code = 1,CHECK RAN 從未印出
$? 不會記錄這個值是誰產生的——它是重導向、shell 與被呼叫程式共用的一條回報通道。這次兩條路都回 1,於是「命令失敗了」和「命令根本沒執行」讀起來一模一樣。順帶招認:寫這篇文章的當天,我又踩了一次一模一樣的坑。重寫這份稿子時 > 撞上已存在的檔案,heredoc 一個位元組都沒寫進去,而下游的檢查對著舊版照樣全綠。處置:捕捉輸出並讀它(out=$(cmd 2>&1); rc=$?),或讓輸出裡帶著「這次真的執行過」的證據;管線要 pipefail。
忍喵:寫防呆的人當天再踩一次,這種坑才叫坑。筆記重點不是noclobber,是「exit code 不告訴你是誰失敗」。
六種形狀對過來,修法收斂成一張短清單,每一條都在這個 lab 裡真的執行過:
assert 0 != 0——紅的原因必須正是那個 bug,不是隨便紅。Running,而實測回來的 RunningAtMaxScale 連安裝在同一台機器上的 SDK enum 裡都沒有;第二輪又撞到 enum 外的 Activating。兩輪兩個值,這是結構問題不是運氣——你列不完你不知道的值,但已知的失敗態是有限的。require_nonempty;json 對 tsv。然後是那個收尾的問題:這六種,最後是被什麼抓到的?攤開看:live session 的真執行(一、五、六)、刻意注入失敗(二)、server 端的身分紀錄(二)、同一份輸出裡的第二個數字與 -o json 的對照讀(三)、回頭讀工具自己的錯誤輸出再換一條路徑重取(四)。
沒有一個是把既有檢查調嚴抓到的。 把檢查加嚴的時候,你 grep 的字串、你以為在跑的命令、你相信的欄位——這些假設會原封不動地跟過去;真執行與獨立重算換掉的正是假設本身。所以下次想加嚴之前,先問一個更便宜的問題:這次換掉的是哪一條假設?答案若是「沒有,只是門檻調高」,你加的可能只是一顆更亮的綠燈。
忍喵:圈這句就好:這次換掉的是哪一條假設。門檻調高不換假設,只是把同一個盲點檢查得更用力。
--all 與 --scope 互斥、YAML null 序列化、tsv 的輸出形狀、enum 缺值),有些是我的(fake 寫太鬆、允許清單自信、迴圈沒驗 knob)——但讓每一個活下來的,都是我的驗證。這篇講的是驗證怎麼失效。下一篇回到讓它成立的那一半:這個 lab 的 behave 測試——8 個 feature、37 個 scenario——是整個系列一路綠著走完的那條線,而主線 30 天刻意沒有展開過測試教學。Day 32 來補這件事:BDD 在 GenAI backend 上到底買到了什麼。
本篇無新增雲端資源、零成本:所有案例都來自各天已記錄的 live session 與公開 repo 的程式碼,本篇沒有發出任何一次新的雲端呼叫。
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。