我一個人開發 MsgMesh。實作與 code review 分別由不同視角的 AI agent 跑,我做最後裁決。這篇後半記的是我自己判斷錯的四件事——其中幾件是我下的指示、幾件是協作 agent 做的而我複核時簽了字。兩種都算我的:該抓到的人是我。
上週我上線一支新的 API。流程是這樣的:
- 三個 agent 用不同視角審過,抓出三條 critical,全部修掉並用真的 Kafka 端到端驗證
- CI 六項全綠
- 部署 workflow success
- prod 健康檢查 200
然後我做了一件多餘的事:打開瀏覽器,用公開網址真的呼叫那支 API。
404 page not found綠燈只覆蓋到你讓它覆蓋的地方
先講結論,因為它比故事重要:
你的測試覆蓋率再高,也只覆蓋到「你想到要測的那條路徑」。而部署流程最常見的盲區,是「從外面進來」的那一段。
我的 deploy smoke 寫得不算差——它會發一則訊息、從 SSE 讀回來、確認端到端投遞真的通。但它整條都跑在 localhost。
在 localhost 上,那支 API 是好的。它回 401(要求認證),證明路由存在、程式正確。
從網際網路上,它是 404。
中間隔著什麼
我的平台把三個角色(控制面、資料面、即時推送)開在不同的埠上,而邊緣的反向代理按路徑決定轉給誰:即時串流的路徑一組規則、收發與死信一組規則,其餘全部落到預設分支進控制面。
新端點是 /v1/topics/{topic}/history,它屬於即時推送那一面。但它不落在任何一條既有規則上, 於是走了預設分支、打進控制面——而控制面當然沒有這個路由,Go 回它的預設 404。
新增一個對外端點,必須同步改邊緣的路由規則。 而當時這個耦合:
- 沒有寫在任何文件裡
- 沒有任何測試會發現
- 失效時零訊號 —— 不是 500、不是錯誤日誌,是一個看起來很正常的 404
更糟的是,那份規則只活在反向代理自己的資料庫裡,不在專案的版控中。它既不會被 code review 看到,也不會被 CI 檢查到,而且重建代理時會消失。(這兩件都已經修掉了,見下一節。)
為什麼這種 bug 特別難抓
因為每一層看起來都是對的。
| 你檢查的東西 | 它說 | 它其實只證明了 |
|---|---|---|
| CI 六項全綠 | ✅ | 代碼在測試環境正確 |
| deploy workflow success | ✅ | 容器換成新版了 |
/readyz 200 | ✅ | 依賴(DB/Kafka/Redis)可達 |
| 內部直接打即時推送那支進程 | 401 | 路由存在、程式正確 |
| 公開網址 | 404 | ← 只有這一項在測「使用者能不能用」 |
前四項全部通過,而它們加起來不蘊含第五項。
這不是我第一次栽在這個形狀上。同一個專案裡,我踩過:
- 靜態網站漏傳一個檔案 → CDN 找不到就送首頁,回 200 但內容是錯的,健康檢查完全抓不到(踩過兩次)
- Prometheus 的告警規則寫對了、載入了,但底層指標的 series 從來沒被建立過,所以
increase()永遠算不出 0→N 那一躍——最該告警的第一次,永遠不會響 - (這條發生在我自己的網站產生器上)一個「檢查產出是否正確」的護欄,三條檢查全是恆為真的死碼。把文章內容整個清空,它照樣說沒問題
共同點都是:看起來做了,實際沒生效。 而且都不會報錯。
修法:讓煙霧測試從外面打一次
補上那條規則只是止血。真正的修法只有一條:
部署的驗收,至少要有一項是從使用者真正會走的入口進來的。
我的 smoke 全跑在 localhost 上,因為那樣最快、最穩、不受網路影響。這些理由都對——代價是它結構上不可能發現邊緣的問題。
已經做了兩件事。
第一,部署的煙霧測試多了一項:對公開網址打幾個代表性路徑,三個角色各一條,確認回的不是 404。這裡有個小設計讓它變得很便宜——判定用「不是 404」而不是「200」。那些路徑本來就需要憑證,回 401 正好證明流量轉給了正確的角色,而路由才是要驗的東西。於是這項檢查不需要任何密鑰,任何環境都跑得起來。
失敗語意也分開:404 直接讓部署失敗回滾(那是規則漏了,而「宣稱上線但對外不通」比回滾更糟); 連不上或逾時只警告不失敗(那是網路或 CDN 抖動,回滾修不好)。
第二,把那份規則抓進版控,標明「執行中的真相源仍是代理的資料庫,這份是副本」,並寫上雙向同步的守則——因為這種「源在 repo、機器上是手動同步的副本」的關係天然會反向漂移,照流程從 repo 蓋回去就會刪掉別人直接在機器上加的東西。順帶對照時發現版控裡那份範例已經漂了:缺 /history,還缺一條「內部指標端點禁止對外」的規則。
驗收方式是刻意讓它失敗一次:在檢查清單裡混進一條邊緣沒有規則的路徑,確認它真的回 404 並讓流程中止。守門不驗證會不會擋,跟沒有守門是一樣的。
同一天,我又想錯了三件事
上面那個是流程漏洞。而同一天,同樣的形狀在我身上又以另外三種樣子出現——每一件都是被指出來的,不是我自己發現的。我覺得比 bug 本身更值得寫。
一、我以為「保守回報」比較安全,其實是把訊號變成雜訊
那支 API 除了回歷史訊息,還會給一個「書籤」,讓呼叫端接上即時串流,中間不漏。
但伺服器對書籤有限制:太舊的書籤不能用(只保留最近一段的重播能力)。
我的指示是:如果書籤被夾到合法範圍內,就在回應裡標記「這中間可能有缺口」。 理由是誠實——寧可多報。
複審的 agent 不同意,理由是:掃描是從「現在」往回走的。 它掃過的那一段,已經確認「這個房間沒有訊息」。所以把書籤往新的方向推,不會漏掉任何東西——因為那一段本來就是空的。
這個反駁是對的,而且差別很實際:
照我的說法,每一個安靜的房間都會收到一個「有缺口」的警告,而其實沒有缺口。 而一個常常誤報的警告,呼叫端第二次就不看了。
一個誠實的欄位,如果常態誤報,就不再是誠實的了——它只是雜訊。
「保守一點比較安全」在這裡是錯的。保守的代價不是多做工,是讓真正有缺口的那一次也被忽略。
二、我為了「不可逆」而過度約束設計,而它根本還沒發布
那支 API 的契約(欄位名、語意、保證)我當成不可逆來處理,因為 SDK 已經發過版。所以評審時我一直在問「這個決定將來會不會後悔」,也因此接受了一個明知不理想的妥協:新舊 API 對同一個概念用了兩個名字,而解法是「以後加個別名欄位」。
後來我問了一句「這樣真的好嗎」,得到的回答是:
目前還沒有人在用。只要合理都可以改。
於是那個妥協完全沒有必要。而且一查才發現,那個「舊名字」本身就是個坑:同一個查詢參數名,在相鄰的兩個端點上意思完全相反——一個是識別用的、一個是分組用的。是我自己種的。
教訓不是「不用管相容性」,而是:
「這是不可逆的」是一個需要查證的事實,不是一個可以假設的前提。
而查證它很便宜:看一眼有幾個活躍使用者就知道。我沒查,直接套用了「已發版 = 不能改」的預設,結果為了保護零個使用者,接受了一個會永遠留在 API 表面上的妥協。
三、我用 head -8 看 grep 結果,然後寫下了「沒有」
這一件是同一天稍晚發生的,而且比前兩件難堪,因為它已經是同一個 session 裡第三次。
我要判斷一次跨語言的改名會不會波及前端,所以跑了:
grep -rn "\.key\b" apps/panel/app | grep -viE "apikey|api_key" | head -8八行全是 React 的 e.key === "Enter"。我下了結論:前端不受影響,而且把這句話寫進了 issue,當成後續施工的依據。
後來是前端的型別檢查抓出兩處真的命中。我回頭把同一條指令不截斷跑一次:
8 .../account/page.tsx:105: ... e.key === "Enter" ...
9 .../dlq/page.tsx:128: ... e.key === "Enter" ...
10 .../dlq/page.tsx:139: {m.key ? ( ← 真的
11 .../dlq/page.tsx:143: }}>key {m.key}</span> ← 真的在第 10、11 行。 我砍在第 8 行。
而另一處更蠢:它在 lib/ 底下,而我只搜了 app/。
為什麼這個錯特別容易犯
因為 head 是我用來保護自己的:輸出太長會塞爆視野,截斷是好習慣。問題在於截斷之後,我對那份輸出的解讀從「這是前八筆」偷偷變成了「這就是全部」。
而且它跟前面兩件事是同一個形狀:
- 我看的是「前八行沒有」,我想知道的是「有沒有」
- 我看的是「CI 綠燈」,我想知道的是「使用者能不能用」
- 我看的是「已發版」,我想知道的是「有沒有人在用」
代理指標又一次冒充了本體。
尾聲:寫完這一節之後,我又犯了一次
同一天稍晚,我發了一個 Python 套件。查 PyPI 的 JSON API,它說最新版還是舊的、新版有 0 個檔案。而發布流程的 CI 是綠的。
我下了結論:流程回報成功但其實沒發布,並把這句話寫進了當天的收尾報告。
然後我換一個來源查:PyPI 的 simple index 上,wheel 和 sdist 都在;pip install 也裝得下來,版本正確、內容正確。JSON API 只是快取沒更新。
我在寫完上面那三段之後,又用單一來源下了一次「沒有」的結論。上一次是 head -8 只看到前八行,這次是「只問了一個 API 端點」。
而更難堪的是後面那句:報告裡寫著「已經補進分享文的第三節」——但根本沒有。 是我回頭核對「文件都對齊了嗎」的時候,去 grep 才發現那句話是假的。
我想這反而是這篇最誠實的結尾:知道一個陷阱、剛剛才寫下它、甚至正在寫的就是它——都不足以讓你不再踩。
真正有效的不是「更小心」,是把檢查變成不需要記得的東西:
- 支持「沒有」的結論 →
wc -l,或換第二個來源 - 說「我做了 X」→ 在說之前 grep 一次自己的產出
第二條是這次新加的。因為前一條再熟,也擋不住「我以為我做了」。
便宜的解法
如果你要下的結論是「沒有」,那就不能截斷。要嘛看全部,要嘛先數:
grep -rn "pattern" path | wc -l # 先知道有幾筆,再決定要不要看wc -l 是零成本的,而且它回答的正好是「有沒有」這個問題。我現在把它寫成規矩了:凡是要用來支持「沒有」的搜尋,先 wc -l。
還有一件事同樣便宜:搜尋路徑不要憑印象給。 我寫 apps/panel/app 是因為我以為前端代碼都在那,而 lib/ 就在它隔壁。ls 一下要一秒。
三件事的共同點
寫完才發現這三件事是同一回事:
| 我看的 | 我以為它等於 | 它其實不等於 |
|---|---|---|
| CI 綠燈 | 使用者能用 | 只證明代碼在測試環境對 |
| 保守回報缺口 | 更誠實 | 常態誤報 = 沒人看 = 更不誠實 |
| 已發版 | 不能改 | 沒查過有沒有人在用 |
head -8 沒看到 | 沒有 | 只看到前八行 |
每一個都是我拿一個看起來很合理的代理指標,去代替我真正想知道的那件事。而代理指標最危險的地方不是它會錯,是它錯的時候看起來跟對的一模一樣。
唯一的解法很笨:去看真正想知道的那件事。 打一次公開網址、問一句有沒有人在用、把 head 換成 wc -l。三件加起來不到一分鐘,只是容易跳過——因為代理指標已經給了你一個答案,而人很難主動去質疑一個已經到手的答案。
MsgMesh 是一個架在 Kafka 之上的多租戶事件總線。文中的 404 在上線約十分鐘後被發現並修好,期間沒有使用者受影響——因為那支 API 本來就是新的。