一個從未運行的功能,因為一個服務缺少了金鑰
某個功能自發布當日起,每個請求都失敗了。四個部署目標中有一個缺少憑證,而本地環境隱藏了這個問題。
一個功能上線了,而從那天起,所有對該功能的請求都以 503 錯誤碼失敗。四天內無人察覺。原因並非該功能的錯誤。四個共用同一個容器映像檔的部署目標中,有一個缺少 API 金鑰,而本地開發環境總是提供該金鑰,因此再多的本地測試也無法發現這個問題。本文將說明該失敗的樣貌、為何僅憑回應時間就能找出問題所在的層級,以及本可避免此問題的檢查清單。
一次 1.4 毫秒的失敗告訴你什麼
故障端點的存取日誌看起來像這樣:
14:20:07 503 /api/v1/extract/preview 0.001846s
14:20:06 503 /api/v1/extract/preview 0.001463s
14:20:06 503 /api/v1/extract/preview 0.001414s
一天內有十九個請求,全都是 503 錯誤,全都耗時不到兩毫秒。這個數字就是整個診斷的關鍵。
這個端點會解析兩個上傳的檔案,將它們通過語言模型運行兩次,成功時需要 30 到 60 秒。一個在 1.4 毫秒內就發生的失敗,並沒有嘗試執行任何這些操作。它沒有讀取請求主體。它在門口就被拒絕了。
在你讀取任何一行程式碼之前,回應時間就已將失敗分層歸類:
| 失敗延遲時間 | 代表的意義 |
|---|---|
| 幾毫秒內 | 在執行任何工作前,因前置條件檢查而被拒絕 |
| 與正常延遲時間相似 | 在執行大部分實際工作後,於過程中失敗 |
| 達到平台逾時時間 | 卡在一個從未回應的依賴項上 |
| 極不穩定 | 資源爭用或不健康的實例池 |
一個耗時與成功請求相當的 503 錯誤,代表某個依賴項在執行中途失效了。一個立即返回的 503 錯誤,則代表是防護子句(guard clause)所致。這些是不同的錯誤,有不同的修復方法,而時間戳欄位在你打開編輯器之前就告訴了你是哪一種。
在這個案例中,這個防護子句是一個位於處理常式頂部的能力檢查:
if s.embedder == nil || s.primaryLLM == nil || s.secondaryLLM == nil {
writeError(w, http.StatusServiceUnavailable, "models are not available")
return
}
自從該功能推出那天起,那三者其中之一在每個實例、每個請求上皆為 nil。
為何本機開發會隱藏遺失的憑證
nil 的那個是第二個語言模型用戶端,它只有在 API 金鑰存在時才會建立:
if cfg.OpenAIKey != "" {
s.secondaryLLM = newClient(cfg.OpenAIKey)
} else {
log.Warn("secondary model disabled: API key not set")
}
那個警告在每次實例啟動時都會印出,持續了四天。沒有人在看一個看似運作正常的服務的啟動日誌,因為它確實運作正常。應用程式中的所有其他頁面都能正常運作。
該服務是從同一個容器映像檔建置的四個部署目標之一:一個公開的 API、一個預備環境副本、一個夜間批次作業,以及一個內部審查工具。其中三個有金鑰,而內部工具則沒有。
| 部署目標 | 是否有密鑰 |
|---|---|
| 公開 API | 是 |
| 預備環境 | 是 |
| 夜間批次作業 | 是 |
| 內部審查工具 | 否 |
其中一個目標之所以不同,其原因值得一提,因為它具有普遍性。這個內部工具一直以來只需要資料庫連線。它是一個帶有編輯按鈕的表格檢視器。它的部署設定列出了一個資料庫 URL 和一個快取密碼,而這份清單幾個月來都是正確的。然後,該工具上線了一個會呼叫語言模型的功能,而設定清單背後的假設也悄悄地不再成立。程式碼變了,但部署規格沒有。
本地開發讓這個問題變得不可見。在開發人員的機器上,金鑰來自 .env 檔案或共用的設定載入器,因此客戶端總是能被建構,而防護子句也從未觸發。每次在本地執行該功能都成功。針對本地伺服器的整合測試也成功。這個失敗只存在於程式碼現在所需與某個部署實際所提供的之間的差距,而在筆記型電腦上運行的任何東西都無法看到這個差距。
這就是讓憑證漂移比一般錯誤更棘手的特性。大多數的錯誤至少會在你看的一個環境中失敗。而這個問題卻在除了生產環境以外的所有地方都成功,而生產環境的失敗是無聲的,除非有人打開那個特定的畫面。
遺失了自身訊息的錯誤
伺服器並未故弄玄虛。它回覆了一個明確的句子:
{"detail": "models are not available"}
瀏覽器顯示 Request failed (503)。
將失敗回應轉換為訊息的用戶端輔助程式讀取了三個鍵,而伺服器實際使用的那個並不在其中:
async errText(r) {
const d = await r.json();
return d.error || d.message || `Request failed (${r.status})`;
}
API 標準化採用了 detail。前端的輔助函式是根據不同的慣例編寫的,而且從未被重新檢視。來自每個路由的每個錯誤內文都被擷取、解析,然後丟棄,只留下狀態碼。修復方法只是一個識別碼:
return d.detail || d.error || d.message || `Request failed (${r.status})`;
這種不匹配在 happy path 中是看不見的,在對狀態碼進行斷言的測試中也同樣看不見。它只會在有人試圖根據螢幕截圖進行除錯時才會出現,而這恰恰是你最需要它的時候。錯誤路徑上的合約不匹配不會有任何成本,直到它讓你花費數天時間為止。
兩個較小的變更讓剩下的診斷工作可以自行完成。防衛子句現在會指名缺少哪個能力,而不是將其中三個能力全用一個句子來概括:
func (s *Server) missingCapabilities() []string {
var miss []string
if s.embedder == nil { miss = append(miss, "embeddings") }
if s.primaryLLM == nil { miss = append(miss, "primary model") }
if s.secondaryLLM == nil { miss = append(miss, "secondary model (API key)") }
return miss
}
而此拒絕訊息會與相同的清單一同記錄下來,因此答案會出現在回應主體與日誌行中,而不僅僅是出現在四天前就已捲動而過的啟動警告裡。
新增外部依賴項的檢查清單
修復本身只需一個指令。注入遺失的密鑰只花了幾秒鐘,端點在第一次嘗試時就從 1.4 毫秒的 503 錯誤變為 30.5 秒的 200 成功回應。有趣的部分是,有五項檢查本可讓這四天變得毫無必要。
- 列出所有執行此映像檔的部署目標。 不是你正在測試的那一個。服務、預備環境副本、批次作業、內部工具。相同的二進位檔意味著相同的新需求。
- 更新建立腳本,而不僅僅是線上服務。 對執行中服務的一次性注入,對於下一個從腳本重新建立它的人來說是不可見的。如果你的重新部署只替換映像檔,注入的內容會存留下來,這也正是為何沒有人注意到腳本現在是錯誤的。
- 讓啟動警告明確地失敗,或讓健康狀態檢查知曉。 在一個其他方面啟動正常的服務上出現
log.Warn並不是一個信號。當宣告的功能無法運作時,要麼拒絕啟動,要麼在有人會查看的地方揭露其能力狀態。 - 回傳遺失項目的名稱。 將多個能力歸納在同一則訊息後,當其中一項缺失時,會讓你耗費整個診斷過程的成本。
- 假設本地測試無法驗證步驟 1 和 2。 這是令人不安的一點。你的機器擁有憑證。你在那裡執行的任何測試都無法證明部署環境也擁有它。
步驟 1 和 2 是真正能預防這類失敗的步驟,而且它們是任何測試套件都無法為你代勞的兩件事。它們是一種習慣,而不是一種工具。
常見問題
當憑證遺失時,服務應該拒絕啟動嗎?
這取決於該相依性是核心還是選用。如果沒有它,整個服務就無法運作,那麼就應該在啟動時快速失敗,這樣部署就會明顯失敗並回滾。如果它只是眾多功能中的其中一個,那麼啟動是正確的,但其功能狀態必須在啟動日誌以外的地方可見。一個看起來健康但功能降級的服務是兩者中最糟的情況。
部署後的煙霧測試會發現這個問題嗎?
只有在煙霧測試有執行到該特定功能時才可能發現,但對於一個需要 30 秒的語言模型呼叫來說,每次部署都執行是一件昂貴的事。一個較便宜的版本是使用一個整備度端點 (readiness endpoint) 來回報哪些選用功能正在運作,並在部署步驟中將該報告與發行版預期的內容進行比較。你測試的是設定,而不是行為,所以可以很快。
這只是基礎設施即程式碼 (infrastructure as code) 的案例嗎?
宣告式基礎設施有所幫助,因為部署規格與需要它的程式碼放在一起,審核者可以在同一個變更中看到兩者。它並不能消除這個問題。仍然需要有人將新的密鑰新增到正確的目標,而一個內容錯誤的宣告式檔案會可靠地部署錯誤的內容。它帶來的好處是,這個錯誤在差異比對 (diff) 中是可見的,而不是只存在於主控台裡。
如何找到這種偏差的現有實例?
列舉共用相同映像檔的部署目標,並對它們的環境和密鑰列表進行差異比對。差異不一定就是錯的,因為批次作業理所當然地比公開 API 需要更少的東西。但每個差異都應該有人能說出其原因。這次事件中的那個差異就沒有任何原因。它只是比需要它的功能還要舊。