題幹與適用場景
一個 Linux HTTP 服務執行於 Kubernetes 容器。滾動更新時,它收到 SIGTERM,終止寬限期為 30 秒;此時有 200 個處理中的請求、兩個子工作行程和一個背景工作消費者。請從訊號傳遞開始設計完整關閉協定,並解釋 SIGTERM 與 SIGKILL 的行為、訊號處理函式的安全邊界、流量排空、工作與子行程處理、截止時間和驗證方法。
30 秒和 200 個請求是面試情境輸入。Kubernetes 常見的 Pod 預設終止寬限期是 30 秒,但正式環境的數值應由請求時間、清理時間、工作負載語意和可用性需求共同決定。本題可以設定 25 秒的內部排空截止時間,保留五秒做最後清理並吸收排程波動。25 加 5 只是情境內的工程預算,不是平台保證。
題目的核心是 Linux 行程生命週期協定,Kubernetes 和 HTTP 提供執行情境。高品質回答要追蹤訊號如何到達目標行程、如何安全觸發狀態轉換、如何控制新工作與處理中工作,以及如何證明行程能在截止時間內結束。
面試官考察點
第一項是訊號語意。行程可以捕捉、封鎖或忽略 SIGTERM,它的預設動作是終止。應用程式捕捉它之後,就要在清理完成時主動結束。SIGKILL 不能被捕捉、封鎖或忽略,因此不會留下清理機會。標準訊號也不是可靠佇列:同一種標準訊號在等待期間重複抵達時,可能只保留一個待處理實例。
第二項是訊號能否到達服務。容器使用 shell 形式的入口命令時,PID 1 可能是 /bin/sh -c,實際程式收不到預期的 SIGTERM。通常應使用 exec 形式的入口;必須使用包裝指令碼時,最後應執行 exec。建立子行程的服務還要負責轉送終止訊號並回收子行程;應用程式無法承擔 PID 1 職責時,可以設定合適的微型 init。
第三項是非同步訊號安全邊界。原始 C 訊號處理函式可能在任意指令處中斷正常執行。在其中呼叫 printf、配置記憶體、取得互斥鎖、關閉整套應用物件或刷新用戶端函式庫,可能造成死鎖或狀態損壞。處理函式只應透過訊號安全操作發出最小通知,正常控制路徑負責清理。
第四項是關閉順序。服務先變成未就緒並停止接收新工作,再於截止時間內完成或取消已接收工作。背景工作消費者需要停止取得新工作,同時維持租約語意正確;請求使用完共用相依服務後才能關閉相依服務;子行程需要終止並回收;日誌與追蹤資料只能限時刷新;行程應在編排器升級為 SIGKILL 之前結束。
最後一項是執行證據。只測訊號處理函式,無法證明容器 PID 結構、端點移除、連線行為、工作重送、子行程回收和寬限期都正確。回答應包含容器層級和滾動更新層級測試,並提供可觀測的通過條件。
回答前需要釐清的問題
- 誰傳送訊號,目標 PID 是誰? 確認容器執行環境、停止訊號、入口命令、包裝指令碼,以及應用程式是否為 PID 1。
- 誰在消耗這 30 秒? Kubernetes 的
preStophook 也在同一個終止寬限期內執行,它會減少應用程式可用的排空時間。 - 「處理中」包含哪些工作? 區分已接收請求、沒有請求的長連線、串流回應、升級連線和應用佇列;它們的結束策略不同。
- 服務能否立即拒絕新工作? 確認就緒探測、負載平衡器、監聽器、服務網格和直接呼叫方。就緒狀態傳播需要時間。
- 請求時間和重試契約是什麼? 短的冪等讀取可以完成;長時間上傳或有副作用的寫入可能需要取消、移交或冪等鍵。
- 背景工作如何確認歸屬? 釐清確認時機、可見性逾時或租約、心跳、重送和冪等性。缺少這些資訊時,直接「停掉 worker」並不安全。
- 誰管理子工作行程? 確認父行程能否向行程群組傳送訊號、子行程是否有自己的關閉流程,以及誰呼叫
waitpid。 - 執行環境如何提供訊號? 專用
sigwait執行緒、事件迴圈 callback 和原始 C 處理函式有不同安全邊界,應說明實際實作。 - 怎樣才算成功? 明確定義新工作接收數、完成和取消結果、重複副作用、殭屍行程、結束時間與強制終止率的驗收標準。
30 秒回答框架
「我先確認 exec 形式入口能把 SIGTERM 交給應用程式。原始處理函式只喚醒正常控制路徑;控制路徑讓就緒失敗,停止接收請求和工作,並用 25 秒內部截止時間排空已接收工作。接著安全取消剩餘工作,終止並回收兩個子行程,限時刷新資料,在 30 秒寬限期結束前離開。SIGKILL 不會執行清理。我會在並行請求和工作下對真實容器傳送訊號,驗證接收截止、重試語意、子行程回收和結束時間。」
這個框架確定了主流程。深入回答還要涵蓋多執行緒訊號傳遞、被中斷的系統呼叫、重複關閉請求,以及端點移除與監聽器停止接收之間的差異。
分步驟深入解答
先檢查傳遞路徑。使用可執行檔形式的 ENTRYPOINT 或 CMD,讓應用程式直接收到執行環境的停止訊號。包裝指令碼確有需要時,最後執行 exec "$@"。不要只看 Dockerfile,要檢查執行中的容器:確認 PID 1、命令列、父子行程關係和停止訊號。對容器執行真實 SIGTERM 的滾動更新測試才是最終證據。
先初始化喚醒機制,再安裝處理函式;先安裝處理函式,再讓就緒狀態成功。一個接近 C 的示意實作可以使用非阻塞 self-pipe:
static volatile sig_atomic_t stop_requested = 0;
static int wake_fd; /* 在 sigaction 前初始化為非阻塞 */
static void on_term(int signo) {
int saved_errno = errno;
stop_requested = 1;
const unsigned char byte = 1;
(void)write(wake_fd, &byte, sizeof byte);
errno = saved_errno;
}write 屬於非同步訊號安全函式。檔案描述元設成非阻塞後,通知已經填滿管道時,處理函式不會等待;即使新位元組沒有寫入,旗標仍保存關閉狀態。處理函式不列印日誌、不配置記憶體、不加鎖、不等待子行程,也不呼叫應用用戶端。一般事件迴圈讀取管道並推進一個可重複執行的關閉狀態機。
多執行緒服務還可以採用另一種模式:建立工作執行緒前先封鎖終止訊號,再由一個專用執行緒呼叫 sigwait,或在 Linux 上使用 signalfd。訊號處置方式由行程共用,每個執行緒有獨立訊號遮罩。傳給行程的訊號可以落到任意沒有封鎖它的執行緒。在執行緒啟動前統一訊號遮罩並集中同步接收訊號,可以把非同步處理函式移出應用程式碼。
不要只依賴系統呼叫被訊號中斷來喚醒服務。不同介面以及是否設定 SA_RESTART,會決定阻塞呼叫是自動重新啟動還是回傳 EINTR。self-pipe、事件描述元、執行環境訊號通道或專用訊號執行緒提供明確的喚醒路徑。關閉階段的每一個阻塞等待也要有截止時間。
用明確狀態驅動服務:
RUNNING(執行)
--SIGTERM--> QUIESCING(停止接收)
--接收入口關閉--> DRAINING(排空)
--工作完成或到達 25 秒--> FINALIZING(收尾)
--子行程回收且限時刷新完成--> EXITED(結束)從 RUNNING 出發的轉換必須具備原子性與冪等性。第一次 SIGTERM 記錄開始時間和截止時間。第二次 SIGTERM 不應重新建立一套清理流程,也不能再次關閉同一資源。團隊可以規定第二次訊號只做記錄,也可以規定它縮短排空時間,但行為必須明確且經過測試。
進入 QUIESCING 後,先讓就緒探測失敗,並立即阻止新應用工作進入。Kubernetes 會把正在終止的端點標記為未就緒,但控制平面和代理傳播有延遲。應用程式應關閉監聽 socket、停止 accept,或讓接收層對尚未越過接收邊界的請求回傳可重試結果。已接收的連線可以繼續排空。HTTP keep-alive 要另外處理,避免舊連線在關閉開始後持續提交新請求。
背景消費者在同一個邊界停止取得新工作。已取得租約的工作,只有在截止時間前能安全完成時才繼續;否則應依佇列契約停止心跳、釋放租約或執行 nack,讓其他 worker 能重試。持久完成後才能確認工作。外部副作用需要冪等鍵或交易狀態轉換,因為行程可能在外部寫入完成後、確認工作前被強制終止。
進入 DRAINING 後,用計數器或登錄表追蹤已接收工作,並在它們完成前保留所需相依服務。請求處理器仍在使用資料庫連線池、快取用戶端或觀測匯出器時,不能提前關閉這些資源。到達 25 秒內部截止時間後,依協定取消剩餘工作:停止串流、傳播取消訊號、盡可能回傳已定義的回應,並保持狀態可重試。剩餘五秒用於取消 callback、子行程回收、最終狀態寫入和排程波動。
兩個子工作行程應先停止輸入,再接收約定的終止訊號,父行程在截止時間內等待。如果父行程獨佔一個行程群組,可以向該群組傳送訊號,同時避免影響無關行程。每個結束的子行程都要用 waitpid 回收,防止出現殭屍行程。微型 init 可以協助無法承擔 PID 1 職責的應用程式轉送訊號和回收子行程,但不會替應用程式定義請求和工作語意。
進入 FINALIZING 後,寫入最後的關閉指標,並在嚴格時間預算內刷新日誌和追蹤資料。觀測資訊有助於解釋強制結束,但無法使用的觀測後端不能耗盡整個寬限期。依相依順序關閉剩餘資源,優雅關閉時回傳零狀態。如果行程錯過平台截止時間,Kubernetes 最終會要求執行環境傳送 SIGKILL;之後不會執行處理函式、延遲執行區塊或結束 hook。
訊號適合作為控制通知,不適合承載工作。標準訊號可能合併、攜帶資訊有限,並可能在意外的程式碼位置抵達。工作、重試和持久歸屬應放在佇列或狀態儲存中,訊號只負責啟動或升級本地生命週期轉換。
驗證要涵蓋正式環境使用的相同邊界:
- 建置真實映像檔,檢查 PID 1,啟動服務,然後向容器傳送
SIGTERM,不要以呼叫內部關閉介面代替。 - 保持 200 個不同時長的請求,其中既有 25 秒內能完成的工作,也有必須取消的工作。斷言截止點之後沒有新請求被接收,且每個已接收請求都有明確結束結果。
- 確認就緒狀態失敗,並在端點傳播後,舊 Pod 不再接收滾動更新流量。另外測試一條直接連線,獨立驗證監聽器接收邊界。
- 在終止期間執行帶租約的背景工作。驗證完成工作只確認一次,未完成工作可以重試,重複傳遞不會產生重複業務副作用。
- 驗證兩個子行程都收到終止通知、在截止時間前結束並被回收,檢查行程表中沒有殭屍行程。
- 再傳送一次
SIGTERM,證明清理具備冪等性;另用SIGKILL證明系統沒有依賴清理 hook,並驗證持久工作仍能復原。 - 記錄
shutdown_started、接收狀態、處理中數量、排空逾時、子行程狀態、結束時間和強制終止。優雅關閉達到 30 秒就判定測試失敗。
高品質示範回答
「我會先從容器邊界檢查。入口使用 exec 形式,並在執行時確認 HTTP 服務本身是 PID 1,或位於能轉送訊號的 init 後面。shell 包裝指令碼最後執行 exec,確保 SIGTERM 不會停在 shell 上。
服務在就緒前安裝訊號路徑。原始 C 處理函式只設定 sigatomict 旗標並向非阻塞 self-pipe 寫入通知。日誌、互斥鎖、記憶體配置、資料庫呼叫和子行程等待都留在處理函式之外。多執行緒實作中,我更傾向在建立 worker 前封鎖終止訊號,再由一個 sigwait 執行緒集中接收。兩種方案都明確喚醒一般控制迴圈,不依賴 EINTR 恰好出現。
第一次 SIGTERM 以原子方式把服務從執行切換到停止接收,並設定 25 秒後的內部截止時間。服務立即讓就緒探測失敗,關閉或停用新請求接收,阻止 keep-alive 連線繼續發起請求,並停止取得背景工作。端點移除與應用程式接收邊界都要執行,因為就緒資訊傳播是非同步的。
200 個已接收請求可以繼續執行,資料庫和快取用戶端此時保持可用。我會直接追蹤這些請求。25 秒內完成的請求正常回傳;到期後透過應用協定取消其餘請求並保留可重試狀態。背景工作只有持久完成後才確認;未完成租約依佇列契約釋放或等待到期,副作用由冪等鍵保護。
隨後依約定訊號終止兩個子行程,並在限定時間內回收。請求與子行程都不再使用共用用戶端後才關閉資源。日誌和追蹤只獲得很小且有上限的刷新預算。成功路徑應在 30 秒內以零狀態結束。錯過截止時間後,SIGKILL 會直接終止行程,任何清理程式碼都不會執行,因此持久正確性不能依賴最後一個 hook。
驗證時,我在真實映像檔中維持 200 個不同耗時的並行請求和活躍租約工作,再向容器傳送 SIGTERM。我會斷言實際行程收到訊號、就緒狀態失敗、沒有新工作越過接收邊界、已接收工作在 25 秒內完成或明確取消、未完成工作能重試且副作用不重複、兩個子行程都被回收,而且行程在 30 秒前結束。我還會測試重複 SIGTERM 的冪等性與 SIGKILL 下的復原契約。滾動更新儀表板應顯示關閉耗時、處理中工作、截止時間違約和強制結束。」
常見錯誤
- 在原始處理函式裡清理 → 訊號可能在函式庫鎖或配置器狀態被占用時中斷程式 → 只發出最小的訊號安全通知,在正常控制路徑清理。
- 預設應用程式能收到
SIGTERM→ shell 形式入口可能讓 shell 成為 PID 1 → 使用 exec 形式或以exec結束的包裝指令碼,並測試真實映像檔。 - 把就緒失敗視為已停止接收 → 端點更新有延遲,直連和既有連線仍可能提交工作 → 同時讓就緒失敗,並執行應用層監聽或接收截止。
- 先關閉共用用戶端 → 已接收請求會在原本可以完成時失敗 → 先排空使用者,再關閉它們需要的資源。
- 不檢查租約就停止工作消費者 → 工作可能長時間不可見、太早確認或重複產生副作用 → 明確執行確認、租約、重試和冪等契約。
- 為了完整排空無限等待 → 編排器最終傳送
SIGKILL,所有清理機會都會消失 → 設定內部截止時間,並保留收尾時間。 - 忘記管理子行程 → 子行程可能短暫存活,未回收時還會成為殭屍行程 → 明確轉送終止訊號,並以限時
waitpid回收。 - 重複訊號重複啟動清理 → 重複關閉和刷新可能競爭或當機 → 以原子方式轉換狀態,並讓清理具備冪等性。
- 把標準訊號當命令佇列 → 相同待處理標準訊號可能合併,也沒有持久歸屬 → 工作與重試進入佇列,訊號只控制生命週期。
- 只測試內部關閉介面 → 它繞過 PID 結構、執行環境傳遞和編排行為 → 在真實並行工作下向正式映像檔傳送真實訊號。
追問及應對
追問 1:為什麼訊號處理函式不能直接呼叫一般關閉函式?
處理函式可能在目前執行緒或其他執行緒持有配置器、標準輸出入、日誌或應用鎖時中斷程式。大多數應用清理函式不屬於非同步訊號安全函式,直接呼叫會死鎖或破壞內部狀態。處理函式應設定旗標並透過訊號安全操作喚醒控制路徑,再由事件迴圈或專用訊號執行緒呼叫一般關閉程式碼。
追問 2:多執行緒行程中的訊號如何傳遞?
訊號處置方式由整個行程共用,每個執行緒有自己的訊號遮罩。傳給行程的訊號可以傳遞給任意符合條件且沒有封鎖該訊號的執行緒。一種穩健做法是在建立 worker 前封鎖終止訊號,再由一個執行緒使用 sigwait 或 signalfd 同步接收;另一種做法是採用只發出安全通知的最小處理函式。混亂的執行緒遮罩會讓行為難以推理。
追問 3:在這個情境中,SIGTERM 和 SIGKILL 的實際差別是什麼?
SIGTERM 是終止請求,可以由應用程式捕捉並觸發關閉協定;它的預設動作仍是終止行程。SIGKILL 由核心強制執行,不能捕捉、封鎖或忽略,也不會執行清理。只有 SIGTERM 路徑能到達、足夠安全且有明確上限時,寬限期才真正有價值。
追問 4:為什麼既要讓就緒失敗,又要關閉接收入口?
就緒變化通知 Kubernetes 及其代理停止路由,但端點更新和連線排空是非同步過程,既有 keep-alive 或直接連線仍可能到達行程。應用程式接收截止點精確定義了新工作何時起不能進入;就緒失敗負責把 Pod 移出正常路由。驗證也應分別觀察這兩層。
追問 5:執行到一半的背景工作應如何處理?
遵循工作歸屬契約。只有能在內部截止時間前安全完成時才繼續;否則停止或釋放租約,使工作可重試,並且只在持久完成後確認。終止可能發生在外部副作用與工作確認之間,因此副作用需要冪等鍵或交易狀態轉換。
追問 6:存在 preStop hook 時有什麼變化?
hook 會消耗同一個 Pod 終止寬限期。應測量它的最壞耗時,並從應用程式預算中扣除。hook 需要有上限,也不要與應用程式關閉流程重複執行兩套互相競爭的清理。應用程式仍要正確處理 SIGTERM,因為 hook 可能失敗,行程也可能在正常滾動更新以外收到訊號。
追問 7:如何調查正式環境中的強制終止?
把 Pod 終止原因和時間戳,與應用程式的關閉開始時間、處理中數量、工作租約、子行程狀態和最後完成的關閉階段關聯起來。區分訊號沒有送達、排空太慢、子行程卡住和最後刷新阻塞。按版本監控優雅關閉耗時與強制結束率,回歸就能在漸進發布階段被發現。