← 寫作 · Writing Article
寫作 · Writing

CI 全綠、部署成功、代碼正確,而功能對外是 404

我一個人開發 MsgMesh。實作與 code review 分別由不同視角的 AI agent 跑,我做最後裁決。這篇後半記的是我自己判斷錯的四件事——其中幾件是我下的指示、幾件是協作 agent 做的而我複核時簽了字。兩種都算我的:該抓到的人是我。

上週我上線一支新的 API。流程是這樣的:

然後我做了一件多餘的事:打開瀏覽器,用公開網址真的呼叫那支 API。

404 page not found

綠燈只覆蓋到你讓它覆蓋的地方

先講結論,因為它比故事重要:

你的測試覆蓋率再高,也只覆蓋到「你想到要測的那條路徑」。而部署流程最常見的盲區,是「從外面進來」的那一段。

我的 deploy smoke 寫得不算差——它會發一則訊息、從 SSE 讀回來、確認端到端投遞真的通。但它整條都跑在 localhost

localhost 上,那支 API 是好的。它回 401(要求認證),證明路由存在、程式正確。

從網際網路上,它是 404。

中間隔著什麼

我的平台把三個角色(控制面、資料面、即時推送)開在不同的埠上,而邊緣的反向代理按路徑決定轉給誰:即時串流的路徑一組規則、收發與死信一組規則,其餘全部落到預設分支進控制面。

新端點是 /v1/topics/{topic}/history,它屬於即時推送那一面。但它不落在任何一條既有規則上, 於是走了預設分支、打進控制面——而控制面當然沒有這個路由,Go 回它的預設 404。

新增一個對外端點,必須同步改邊緣的路由規則。 而當時這個耦合:

更糟的是,那份規則只活在反向代理自己的資料庫裡,不在專案的版控中。它既不會被 code review 看到,也不會被 CI 檢查到,而且重建代理時會消失。(這兩件都已經修掉了,見下一節。)

為什麼這種 bug 特別難抓

因為每一層看起來都是對的。

你檢查的東西它說它其實只證明了
CI 六項全綠代碼在測試環境正確
deploy workflow success容器換成新版了
/readyz 200依賴(DB/Kafka/Redis)可達
內部直接打即時推送那支進程401路由存在、程式正確
公開網址404← 只有這一項在測「使用者能不能用」

前四項全部通過,而它們加起來不蘊含第五項。

這不是我第一次栽在這個形狀上。同一個專案裡,我踩過:

共同點都是:看起來做了,實際沒生效。 而且都不會報錯。

修法:讓煙霧測試從外面打一次

補上那條規則只是止血。真正的修法只有一條:

部署的驗收,至少要有一項是從使用者真正會走的入口進來的。

我的 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 是我用來保護自己的:輸出太長會塞爆視野,截斷是好習慣。問題在於截斷之後,我對那份輸出的解讀從「這是前八筆」偷偷變成了「這就是全部」。

而且它跟前面兩件事是同一個形狀:

代理指標又一次冒充了本體。

尾聲:寫完這一節之後,我又犯了一次

同一天稍晚,我發了一個 Python 套件。查 PyPI 的 JSON API,它說最新版還是舊的、新版有 0 個檔案。而發布流程的 CI 是綠的。

我下了結論:流程回報成功但其實沒發布,並把這句話寫進了當天的收尾報告。

然後我換一個來源查:PyPI 的 simple index 上,wheel 和 sdist 都在;pip install 也裝得下來,版本正確、內容正確。JSON API 只是快取沒更新。

我在寫完上面那三段之後,又用單一來源下了一次「沒有」的結論。上一次是 head -8 只看到前八行,這次是「只問了一個 API 端點」。

而更難堪的是後面那句:報告裡寫著「已經補進分享文的第三節」——但根本沒有。 是我回頭核對「文件都對齊了嗎」的時候,去 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 本來就是新的。

← 回到所有文章[email protected]