基礎設施

自動重新啟動無回應的 Worker 並避免造成重啟風暴

我們的監控系統在 2 分鐘內偵測到一個無回應的 GPU 工作單元,然後看著它持續停止運作了 6 小時。安全地縮小偵測與修復之間的差距。

本文由 AI 模型從英文原文翻譯而來,用字可能與原文有所出入。 閱讀英文原文

一個 GPU 機群中的兩台工作節點在同一個晚上當機,前後相隔六分鐘。監控堆疊在兩分鐘內就注意到了,並發布了一則詳細的警報:伺服器名稱、判斷結果,以及導致其當機的任務。然後,在接下來的六個小時裡什麼也沒發生,直到有人醒來並輸入 docker restart

那則警報堪稱完美。但中斷時間依然很長。這篇文章要探討的是這兩件事實之間的差距:為什麼偵測系統常常有眼無手,以及要如何將重啟操作接入偵測路徑中,同時又不會造成更糟的問題——一場會扼殺健康機器的重啟風暴。

偵測與修復各自獨立成長

沒有人會刻意設計這樣的鴻溝。它是逐漸累積而成的。

我們的系統有兩個獨立的安全機制,是為了不同的事件而相隔數月建立的。第一個是停滯偵測器:如果一個 worker 回報 processing 但其心跳停止更新超過 120 秒,就宣告它無回應並呼叫相關頻道。第二個是自動重啟器:如果一個 worker 處於明確的 error 狀態達 30 分鐘,就收集其日誌、發布它們,並透過 SSH 重新啟動容器,同時設有每日斷路器,限制每台伺服器最多重啟三次。

兩者都如設計般運作。陷阱在於兩者的結合處:重啟器的觸發條件與偵測器的不同。重啟需要 state == "error"。我們實際遇到的停滯是堆積區毀損,導致程序在工作中途凍結,所以狀態欄位永遠停在 processing,而心跳則停止了。偵測器觸發了。但重啟器的條件從未成立。更糟的是,重啟器有一條禮讓規則:如果停滯偵測器已確認發生停滯,它就會退到一旁,讓偵測器來處理。但它所讓步的那個元件卻無能為力。

當你的修復機制與偵測機制的觸發條件不同時,任何只有其中一方能識別的故障模式,都會變成一個沒有後續處理的警報。將這兩者作為一個單元來審核:對於偵測器可能產生的每種判斷,都要指明由哪個元件來處理。任何判斷的答案若是「最終由人來處理」,那這就是你所選擇的一個鴻溝,而它至少應該是個有文件記載的選擇。

預設情況下,重新啟動並非安全操作

這個修復聽起來微不足道:從當機路徑呼叫重新啟動函式。之所以需要進行設計審查而非單行修補程式,是因為自動化重新啟動就像一把上膛的槍。一個糟糕的重新啟動策略的失敗模式,不是一台 worker 當機,而是一場遍及整個機群的風暴,自動化系統扼殺機器的速度比它們開機的速度還快。

使其得以發布的安全措施,依其執行順序排列如下:

行動前持續確認。 在觸發重新啟動之前,已確認的當機狀態必須持續一段固定的延遲時間(我們選擇了 3 分鐘)。偵測機制原本就要求連續兩次不良的觀測結果;這個延遲增加了對 GC 暫停、暫時性負載高峰和監控小突波的容忍度。計時器透過 SETNX 存於 Redis 中,而非在程序的記憶體裡,原因如下所述。

每個伺服器的執行鎖。 兩個後端實例運行相同的監控迴圈。兩者大約會在同一時間得出相同的結論。一個帶有短 TTL 的 SETNX 鎖確保只有其中一個執行重新啟動;失敗者則直接跳過。警報的重複數據刪除和操作的序列化是不同的考量,所以我們分開為它們設定鍵值:一個重複數據刪除鍵抑制每個事件的重複通知,而鎖則抑制每個伺服器的重複指令。

在最後一刻重新驗證。 在做出決定和執行 SSH 指令之間,有日誌收集和警報發布,延遲可能長達一分鐘。worker 可能在那段時間內自行恢復了。在執行前立即重新讀取狀態;如果心跳是新的,就取消並清理。因為一台機器一分鐘前生病就重新啟動健康的它,這正是自動化系統絕不能造成的傷害。

共用的斷路器。 所有重新啟動路徑每天為每個伺服器增加一個計數器,超過三次後,自動化系統會停止並只發出警報。關鍵的是,計數器在指令實際執行前才增加,而不是在管線開始時。早期版本計算每一次嘗試,所以一次在日誌收集階段失敗的運行仍然會消耗預算,而一個吵雜的偵測器可能在沒有發生任何一次重新啟動的情況下耗盡額度。

當藥方失效時的升級處理。 一次無法解決當機問題的重新啟動,絕不能就此了結。我們會記錄重新啟動的時間;如果同一台伺服器十分鐘後仍然當機,系統會發出一次性的「重新啟動未能恢復」的呼叫並附上提及。若沒有這個機制,系統會發送「已觸發重新啟動」的訊息後便靜默下來,而讀者會以為問題已經處理好了。那種行動後靜默的狀態,正是最初那場六小時中斷發生的原因,而自動化系統可以完美地重現它。

帶安全防護重啟管線 卡住 已確認 3 分鐘延遲 + 鎖 重新驗證 + 斷路器 restart 未恢復: 呼叫真人 // 每個關卡都可以取消;只有最後一個步驟會接觸到機器

你剛才做的重啟看起來會像是當機

我們在審查中發現最微妙的失敗,發生機率大約是擲硬幣的一半,是自我造成的:雙重重啟。

docker restart 之後,容器會花費一分鐘或更長時間來開機和載入模型。在那段時間裡,舊的狀態記錄仍然存在 Redis 中,顯示為 processing 且心跳已過時,因為垂死的程序從未更新它。對復原迴圈來說,這與一次新的當機無法區分。它會重新啟動其計時器,等待延遲結束,然後在開機中途再次重啟機器。每一輪都會消耗斷路器的預算,所以一次真正的當機可能會因為重複扼殺一個正在開機的容器而耗盡每日的限額,然後自動化系統就在最需要它的時候放棄了。

修復方法是一個冷卻鍵:在重啟後,在一個固定的時間窗(我們使用 10 分鐘)內抑制重新啟動計時器,並在冷卻期間清除持久性計時器。通用規則是:任何暫時讓系統看起來損壞的自動化操作,都必須留下一個標記,告訴偵測器「這是我,不是新的事件」。

同一審查中兩個相鄰的陷阱值得一提:

在共享儲存中測量事件持續時間,而非在程序記憶體中。 我們的初稿是從一個記憶體內的結構體讀取當機的開始時間。該欄位在每個重新確認的週期都會被刷新,所以測量到的持續時間永遠徘徊在零附近,而重啟的閾值永遠無法達到。可行的設計是使用 SETNX 將開始時間錨定在 Redis 中:第一個觀察者獲勝,每個實例都看到相同的時鐘,而多實例的部署不能重置彼此的計時器。

從資料中推導觸發條件,而非從本地結論。 若有兩個監控實例,每個實例對「已確認」都有自己的看法。如果實例 A 啟動了共享計時器,而落後一個觀察週期的實例 B 仍然認為伺服器是健康的,那麼 B 就會清除 A 的計時器,兩者將無限期地相互對抗。直接從共享的狀態記錄中推導斷言,可以讓每個實例計算出相同的答案。

有些故障不應觸發重啟

將一種判定結果與修復措施連結,不代表要連結所有結果。我們刻意不處理其中兩種。

缺少狀態紀錄代表代理程式無法連線:伺服器可能正在重新開機、網路分區,或被刻意關機,而且我們目前有一台機器為了節省成本而關閉。透過 SSH 重啟一台電源狀態未知的機器,其效果介於無用和有害之間,所以該判定結果只會觸發警報。而一個心跳正常但進度標記停滯的作業也不會被重啟,因為長時間執行的作業本來就可能看起來像這樣,為了緩慢的任務而終止一個仍在運作的 worker,是用一個誤報換來真實的損害。

由此得出的界線規則是:只有在行動是近乎冪等的、爆炸半徑僅限於一個已經失效的 worker,且猜錯的代價很低的情況下,才自動化修復故障模式。其他所有情況都應迅速且大聲地升級給人員處理。

常見問題

為什麼不直接使用容器健康檢查,讓協調器重新啟動? 如果你在 Kubernetes 上有適當的 liveness probe,請先這麼做。當「掛起」的情況僅在應用程式層級可見時(例如 Redis 中的心跳、工作進度),此模式才適用,而此時處理程序仍然接受 TCP 連線,這會通過大多數連接埠層級的健康檢查。我們的殭屍程序回應了六個小時的連線。

延遲 3 分鐘會不會太慢? 加總一下實際的時間線:過期閾值、兩個確認週期、延遲,然後是重新啟動和模型載入。我們的案例從故障到恢復大約需要十分鐘,相較之下,有人工介入則需要六個小時。將延遲時間縮短到低於服務的啟動時間幾乎沒有好處,反而會增加誤報的成本。

為什麼在所有重新啟動路徑上共用一個斷路器,而不是每個路徑各用一個? 斷路器保護的是實體機器,而機器並不在乎是哪個程式碼路徑重新啟動了它。分開的預算會使最壞情況加劇。如果你要分開它們,請為總和設定上限。

如果重新啟動指令本身失敗了怎麼辦? 將其視為一個第一級的結果,而不只是一行日誌。釋放去重複鍵,以便下一個週期可以重試,並立即通知人員,因為一台甚至無法遠端重新啟動的機器,已經超出了自動化應該決定的範圍。