後端

在 Go 中於 SIGTERM 時的優雅關閉與請求耗盡

當您的平台傳送 SIGTERM 時,立即終止程序會捨棄處理中的請求。以下說明如何依序停止新流量、排空請求,並進行清理。

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

一個在收到終止訊號的瞬間就退出的容器,會切斷所有仍在處理中的請求。客戶端會看到連線重設或 5xx 錯誤,一個開啟的資料庫交易會在寫入中途回滾,一批緩衝的日誌永遠不會被寫入,而該行程持有的鎖會一直被佔用,直到在其他地方逾時。解決方法並不複雜,但必須按照特定的順序進行:停止接受新的工作,讓進行中的工作在期限內完成,然後釋放資源。這篇文章將在 Go 語言中逐步說明這個順序,包括訊號處理、伺服器排空 (drain)、以及清理掛鉤 (cleanup hooks),還有期限必須如何對齊,以確保平台不會在中途終止你的程式。

終止是正常事件,而非例外

第一個心態轉變是,停止將關閉視為罕見的失敗。在容器平台中,這是您的程序最常發生的事情之一。自動擴展器在負載下新增實例,並在負載下降時將其移除。滾動部署逐一替換每個實例。為了維護而排空節點,其上的每個容器都被要求離開。縮減至零的服務在閒置時段後被拆除。這些事件的結尾都是向您的程序發送相同的訊息:一個終止信號(通常是 SIGTERM),若您在寬限期後尚未自行退出,則會接著一個強制的 SIGKILL。

如果您只在機器當機時才考慮關閉問題,您將會建構不足,因為當機是罕見的,您可以容忍偶爾遺失的請求。但部署並不罕見。如果每次部署都遺失了正在被替換的實例上處理中的請求,那麼每次部署都會向某些使用者顯示錯誤。對於一個一天部署數次、且整天都在擴展和縮減的服務而言,「正確處理 SIGTERM」並非「有很好」的選項。它是部署過程無形可見與部署造成小型中斷之間的區別。

預設情況下,許多程式對 SIGTERM 不做任何特殊處理。該信號的預設動作是立即終止程序,而這正是您不想要的。具體來說,這種立即退出會帶來以下成本:

  • 正在處理中的 HTTP 請求被中斷。客戶端會收到連線重置或不完整的響應,且根據請求方法,重試可能安全也可能不安全。
  • 開啟的交易被中止。如果請求正處於多語句寫入的中途,資料庫會將其回滾,這至少能保持一致性,但使用者的操作卻悄無聲息地沒有發生。
  • 緩衝的寫入會遺失。您在記憶體中批次處理以供稍後刷寫的任何東西,如日誌行、指標、向外的事件佇列,都會隨著程序一同消失。
  • 持有的資源無法乾淨地釋放。分散式鎖、租約、線路另一端連線池中正在使用的連線,所有這些現在都依賴逾時來回收,而不是被交還。

這些問題在本地測試中都不會出現,因為在本地,您通常在伺服器閒置時用 Ctrl-C 來停止它。這些問題會出現在生產環境中,稀疏地分佈在每次部署中,成為一種難以歸因的低背景錯誤率,因為它與您自己的部署相關,而不是與流量相關。

關閉順序

整個技術分為三個固定順序的階段。搞錯順序是最常見的錯誤,因此在展示任何程式碼之前,有必要清楚地說明。

階段 動作 為何在此位置
1. 停止接收 讓就緒檢查失敗,停止接受新連線 必須在等待前停止新請求,否則佇列永遠不會清空
2. 排空 在期限內等待處理中的請求完成 已經接受的請求理應完成
3. 清理 清空緩衝區、釋放鎖定、關閉連線與連線池 只有在沒有任何東西仍在使用這些資源時才安全
4. 放棄剩餘工作 放棄任何超過期限的工作,但要記錄下來 平台很快就會發送 SIGKILL;記錄下剩餘工作比讓程序卡住要好

必須先停止接收的原因是機制上的。如果你在等待處理中請求的同時,新請求仍在到達,那麼處理中請求的集合永遠不會縮減到零,而你的排空作業每次都會執行到期限為止。你停止流入,現有的請求會逐一完成,計數便會自然達到零。只有到那時,關閉資料庫連線和清空緩衝區才是安全的,因為在處理函式仍在執行時這樣做,會讓它失去賴以運作的基礎。

在 Go 中捕捉信號

Go 使信號處理的部分變得簡潔。自從標準函式庫加入了 signal.NotifyContext,你可以將 SIGTERM 和 SIGINT 轉換成一個 context,該 context 會在任一信號送達時取消,並將整個關閉程序繫於該取消事件上。

func main() {
	ctx, stop := signal.NotifyContext(context.Background(),
		syscall.SIGTERM, syscall.SIGINT)
	defer stop()

	srv := &http.Server{Addr: ":8080", Handler: newRouter()}

	// Serve in a goroutine so main can wait on the signal.
	go func() {
		if err := srv.ListenAndServe(); err != nil &&
			!errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("listen: %v", err)
		}
	}()

	<-ctx.Done() // blocks until SIGTERM or SIGINT
	log.Println("shutdown signal received, draining")

	if err := drain(srv); err != nil {
		log.Printf("graceful shutdown incomplete: %v", err)
	}
	log.Println("shutdown complete")
}

重要的細節是,當您刻意將其關閉時,ListenAndServe 會回傳 http.ErrServerClosed,而這是一個正常、預期的回傳值,並非應當作失敗處理的錯誤。將其視為致命錯誤是一個常見的程式錯誤,這會導致在正常關閉時記錄下雜亂的錯誤訊息。

使用 Shutdown 清空進行中的請求

伺服器自身的 Shutdown 方法就是這個清空機制。它會停止監聽器,因此不再接受新的連線,然後會阻塞,直到每個進行中的請求都已返回,或直到你傳入的 context 被取消為止。那個 context 就是你的 deadline 所在之處。

func drain(srv *http.Server) error {
	// Stop routing to this instance first.
	health.SetNotReady()

	// Give in-flight requests a bounded window to finish.
	ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
	defer cancel()

	// Shutdown stops new connections, then waits for active ones.
	if err := srv.Shutdown(ctx); err != nil {
		// Deadline hit: some requests were still running. Give up on
		// them but record it, do not hang waiting for a SIGKILL.
		return fmt.Errorf("drain deadline exceeded: %w", err)
	}

	// Safe now: nothing is still serving. Release resources.
	return cleanup()
}

Shutdown 返回 nil 意味著排空程序已順利完成,且每個請求都已完成。它返回 context 的 deadline 錯誤意味著時間已用盡,但仍有請求在處理中。那並不是崩潰。而是你決定繼續等待比直接繼續執行更糟,這是一個正確的決定,因為平台無論如何都即將發送 SIGKILL。記錄下這個事實,這樣 deadline 逾時的發生率上升時才會被看見,因為這通常意味著請求處理的時間比允許的時間範圍更長,而這個時間範圍或請求本身需要關注。

一個注意事項:Shutdown 不會關閉被劫持的連線,例如 WebSockets 或其他長壽命的串流,並且會等待它們。如果你有這些連線,你需要另外發出信號來關閉它們,例如,透過取消它們所監聽的 context,這樣排空程序才能真正完成。

在 drain 前先讓健康檢查失敗

請注意,drain 會在呼叫 Shutdown 之前先呼叫 health.SetNotReady()。這正是人們會忽略的部分,如果沒有這麼做,drain 就會產生競爭條件。

在你的執行個體前方會有一個負載平衡器或服務網格,它會根據整備度或健康檢查來決定要將流量路由到何處。如果你直接呼叫 Shutdown,會有一段時間空窗,負載平衡器仍然認為這個執行個體是健康的,並持續將新的請求傳送給它,而這些請求現在會打到一個正在拒絕連線的伺服器。結果就正是你試圖避免的請求遺失,只是問題換到另一個地方發生而已。

解決方法是先將你的整備度端點切換為失敗狀態,然後等待足夠長的時間,讓負載平衡器注意到並將你從其池中移除,之後才開始真正的 drain 程序。在實務上,這看起來會像是一個根據健康檢查間隔調整過的短暫休眠。

func (h *Health) SetNotReady() { h.ready.Store(false) }

func (h *Health) ReadyHandler(w http.ResponseWriter, r *http.Request) {
	if h.ready.Load() {
		w.WriteHeader(http.StatusOK)
		return
	}
	w.WriteHeader(http.StatusServiceUnavailable)
}

SetNotReady 之後,短暫暫停(幾倍的健康檢查間隔)能讓負載平衡器在 Shutdown 關上大門前停止路由。確切的暫停時間取決於平台如何探測您,但這個原則放諸四海皆準:在您停止提供服務前,先停止對外宣告為可用。

清理掛鉤:刷新、釋放、關閉

一旦 Shutdown 正常返回,就沒有任何處理常式在執行,所以終於可以安全地拆卸處理常式所依賴的東西了。這時您就可以依序刷新緩衝區、歸還租約與鎖定,以及關閉連線池。

func cleanup() error {
	var errs []error

	// Flush anything batched in memory before connections close.
	if err := logBuffer.Flush(context.Background()); err != nil {
		errs = append(errs, fmt.Errorf("flush logs: %w", err))
	}
	if err := metrics.Flush(context.Background()); err != nil {
		errs = append(errs, fmt.Errorf("flush metrics: %w", err))
	}

	// Release distributed locks and leases so peers can proceed now,
	// instead of waiting for the lease to time out.
	if err := locks.ReleaseAll(context.Background()); err != nil {
		errs = append(errs, fmt.Errorf("release locks: %w", err))
	}

	// Close pools last, after flush and release have used them.
	db.Close()
	cache.Close()

	return errors.Join(errs...)
}

在清理作業中,順序也很重要。在關閉 flush 所需的連線之前,先執行 flush。在關閉與鎖定儲存區(lock store)通訊的客戶端(client)之前,先釋放鎖定。最後才關閉池(pool)。如果你先關閉資料庫池(database pool),然後才嘗試 flush 一個會寫入資料庫的批次(batch),那麼 flush 將會失敗,而你會遺失該批次,這正是你試圖避免的損失。

明確釋放鎖定這一點值得特別強調。如果你只依賴租約到期(lease expiry),那麼即使是乾淨的關閉,鎖定仍會在整個租約期間被持有,在此期間沒有任何對等節點(peer)可以取得它。在退出時交還鎖定,能讓下一個實例(instance)立即繼續執行,而租約到期機制則作為一道安全網,以應對處理程序(process)被直接終止的非乾淨情況。

讓清空期限短於平台的寬限期

你的清空期限必須嚴格短於平台在 SIGTERM 和 SIGKILL 之間給予你的寬限期。這是將整個機制串連起來的約束條件,而且很容易出錯,因為這兩個數字存在於兩個不同的地方。

假設平台發送 SIGTERM,若你在 30 秒後尚未退出,便會發送 SIGKILL。你的總關閉時間,也就是就緒暫停、清空和清理的總和,必須在 30 秒內完成,並留有餘裕。如果單單你的清空時間就設定為 30 秒,那麼就緒暫停和清理作業會將你推過寬限期,SIGKILL 會在清理中途到達,而你就會遺失正在清寫的緩衝區。對於 30 秒的寬限期,一個安全的分配可能是幾秒鐘的就緒暫停、大約 20 到 25 秒的清空時間,以及保留幾秒鐘用於清理。

兩點實務上的注意事項。首先,如果你的平台寬限期是可設定的,請明確地設定它,而不是相信預設值,因為預設值通常很短(常見為 10 秒),而 25 秒的清空時間對上 10 秒的寬限期,意味著 SIGKILL 總是會贏。其次,這個期限是上限,而不是目標。大多數的關閉程序都能在遠低於此限制的時間內完成清空,因為大多數請求都很短。這個期限只會對處理緩慢的長尾請求造成影響,而觸及此期限的情況應該要足夠罕見,以至於每次發生都值得記錄一行日誌。

冪等性、重試與請求排空的成本

即使是正確的請求排空,最終仍會丟棄某些東西。一個真正比您整個寬限期還慢的請求、一個在負載平衡器反應前於就緒視窗期間中斷的連線、一個因程序卡住而導致的不乾淨 SIGKILL。優雅關機將被丟棄的集合縮小到幾乎為零,但幾乎為零不等於零,所以最後一層是讓被丟棄的請求能夠存活。

兩個屬性完成了大部分工作。讓寫入操作具備冪等性,如此一來,當客戶端或上游重試一個不確定的請求時,就不會重複應用它。請求上的冪等性金鑰,經與近期操作比對後,會將重試的付款或重試的建立操作轉變為一個安全的無操作 (no-op)。並且,讓客戶端在連線重設時重試冪等請求,因為在滾動部署期間,重試通常會落在一個健康的實例上並成功。這些措施加在一起,意味著罕見地穿過請求排空的請求會被重試並完成,而不是遺失。

這也是為什麼您不應該追求完美的請求排空。提高截止時間以捕捉最後一個緩慢的請求有其實際成本,而冪等性加上重試能以比更長截止時間便宜得多的方式涵蓋那個長尾。

請求排空不是免費的。現在每次部署都會等待每個實例上最慢的進行中請求完成後,該實例才會退出,所以滾動部署會花費更長的時間,其上限取決於您的請求排空截止時間乘以部署的規模形態。縮減規模也因同樣原因而變慢,因為被移除的實例會一直逗留直到請求排空完成。如果您設定了一個寬裕的 25 秒截止時間,而您的部署是依序替換實例,那麼增加的分鐘數是實實在在的。

可調整的旋鈕是請求排空的截止時間,這是一個真正的權衡取捨,而不是一個要最大化的值。太短的話,您會切斷那些一秒後就能完成的請求,這就違背了初衷。太長的話,每次部署和每次縮減規模事件都會拖延,而您大部分時間都在等待一個無論如何都能由冪等性和重試處理的長尾。選擇一個能涵蓋您大部分請求延遲分佈的截止時間,也就是正常請求的高百分位數,而不是絕對的最壞情況,並讓重試來收拾其餘的部分。目標不是保證永遠不會有任何請求被丟棄。而是讓關機成為常態,這樣自動擴展器和部署管線就可以整天移動實例,而這一切都不會影響到使用者。