Backend Engineering·2026年6月15日·12 分鐘閱讀

別在部署時掉訂單:我在 Go 服務裡實作優雅關閉的完整心法

L
Louis Wu

後端工程師,做過交易所撮合與金流系統,把每天的滾動部署從提心吊膽變成無感操作。

每次滾動更新,舊的 Pod 都要被換掉。問題是:那一刻你的服務正在做什麼?可能正處理一筆下單、正在跟金流對帳、正在把一則訊息寫進資料庫。如果這時候行程被一刀斬斷,輕則使用者看到一個莫名其妙的錯誤,重則一筆交易做到一半、錢扣了單沒成立。這篇講我怎麼在 Go 服務裡做優雅關閉,把「部署」這件每天都在發生的事,從一個風險變成一個無感的動作。

先講清楚:直接 kill 行程會出什麼事

很多人覺得部署就是「停掉舊的、啟動新的」,中間那零點幾秒沒人會注意。但在高併發系統裡,零點幾秒可能就是幾百筆請求。我實際看過的幾種災難:

  • 處理到一半的請求被斬斷:使用者送出下單,後端正在寫入訂單表,行程突然死掉。連線被重置,使用者那端看到的是一個連線錯誤。但訂單到底成立了沒?這要看你被砍在哪一行。最怕的是「資料庫已經寫進去了,但回應還沒送回去」,使用者以為失敗、其實成功,於是他重送一次,變成重複下單。
  • 訊息沒 ack:consumer 從佇列拉了一則訊息,處理到一半被砍。如果你還沒 ack,這則訊息會被重新投遞,下一個 consumer 再做一次——如果你的處理不是冪等的,就重複執行了。如果你太早 ack 才被砍,這則訊息直接消失,事情沒做。兩種都是坑。
  • 交易做一半:金流場景最怕這個。你呼叫了第三方支付、對方扣款成功,但你還沒把「已扣款」這個狀態寫回自己的資料庫就被砍了。你的系統不知道這筆錢已經收了,帳就對不起來。
  • 連線池與資源沒收乾淨:資料庫連線、檔案 handle、暫存的檔案,行程被硬砍時不會幫你好好收尾。短時間大量這種情況,下游的資料庫可能會被殘留連線塞滿。

優雅關閉要解決的,就是這些「做到一半」的狀態。核心精神只有一句話:收到關閉訊號後,先停止接新工作,把手上的做完,再退出

SIGTERM 與 SIGINT:先搞懂誰會送什麼訊號

在談怎麼接訊號之前,要先知道訊號是誰送的、代表什麼意思。

  • SIGTERM:這是「請你正常結束」的訊號。Kubernetes 要終止一個 Pod、docker stop、systemd 停服務,預設送的都是 SIGTERM。這是你在正式環境最常遇到的關閉訊號,也是優雅關閉真正要處理好的對象。
  • SIGINT:這是你在終端機按 Ctrl+C 時送出的。本機開發時常遇到。
  • SIGKILL:這是「立刻去死」,行程沒辦法攔截、沒辦法處理。一旦收到這個就是硬砍,什麼優雅都來不及。

這裡有個很重要的觀念:SIGTERM 是一個請求,不是命令。系統送了 SIGTERM 之後,會給你一段寬限時間。如果你在這段時間內自己結束了,皆大歡喜;如果超過了還沒結束,系統才會送出 SIGKILL 把你硬砍掉。所以優雅關閉的整個設計,本質上是在「寬限時間用完之前,把該收的尾收完」。

我的原則是:SIGTERM 和 SIGINT 我都用同一套關閉流程處理。本機 Ctrl+C 跟正式環境的終止,行為一致,才不會出現「本機測都好好的,上線才出事」。

用 signal.Notify 接訊號並觸發關閉

Go 接訊號的方式很直接:開一個 channel,告訴 runtime 把指定的訊號送進來,然後在主流程裡等這個 channel。

我的標準寫法,講重點概念而不貼整段程式碼:

  • 建立一個能接 os.Signal 的 channel,用 signal.Notify 註冊我要攔截的訊號(SIGTERM 與 SIGINT)。
  • 主 goroutine 在這個 channel 上阻塞等待。在它被叫醒之前,HTTP server、背景 worker 都正常跑著。
  • 一旦 channel 收到訊號,就代表關閉開始,主流程往下走,依序去停掉各個元件。

Go 從 1.16 之後我更常用 signal.NotifyContext,它把「收到訊號」直接包成一個 context 的取消。這樣很漂亮:我有一個 root context,收到 SIGTERM 時這個 context 就被 cancel,所有監聽這個 context 的元件自然會知道「該收工了」。整個關閉流程用 context 串起來,比自己管 channel 乾淨很多。

幾個我踩過或看別人踩過的細節:

  • channel 要有緩衝:signal.Notify 對沒有緩衝的 channel 來說,如果你來不及收,訊號會被丟掉。給它至少 1 的緩衝是基本盤。
  • 只攔你要處理的訊號:不要圖方便攔一堆訊號,攔了卻不知道怎麼處理反而更亂。
  • 收到第二次訊號要能強制退出:有時候第一次優雅關閉卡住了(某個請求就是不結束),這時候你按第二次 Ctrl+C,應該要能直接強制退出,而不是繼續傻等。這個體驗在 debug 的時候很重要。

http.Server 的 Shutdown:停收新請求,等舊請求做完

這是整個優雅關閉裡最核心的一塊。Go 的 http.Server 內建了 Shutdown 方法,它做的事情正是優雅關閉的精髓:

  • 停止接受新的連線:呼叫 Shutdown 之後,server 不再接受新進來的請求。
  • 等待進行中的請求做完:已經在處理的請求,會讓它們跑完。
  • 關閉閒置連線:那些 keep-alive 但目前沒在用的連線,直接關掉。

要注意 Shutdown 跟 Close 是兩回事。Close 是直接把所有連線砍斷,包含正在處理的——那就是我們要避免的暴力關閉。Shutdown 才是優雅的那個。

實作上的關鍵流程是這樣:

  • 我會在一個獨立的 goroutine 裡跑 server.ListenAndServe,因為它是阻塞的。
  • 主流程在等訊號。收到訊號後,呼叫 server.Shutdown,傳入一個帶 deadline 的 context。
  • Shutdown 會阻塞,直到所有進行中的請求做完,或者 context 的 deadline 到了。

這裡有個容易被忽略的點:ListenAndServe 在 Shutdown 被呼叫後,會回傳一個 http.ErrServerClosed 的錯誤。這不是真的錯誤,是「我正常被關閉了」的訊號。如果你沒特別判斷,可能會在 log 裡看到一個嚇人的 error,或甚至誤觸發告警。我都會明確判斷:如果回傳的是 ErrServerClosed,那就是正常關閉,不算錯。

context 與 deadline:控制最長等待時間

優雅關閉不能無限等。萬一某個請求邏輯有 bug 卡死了,你總不能讓部署永遠卡在那裡。所以一定要有一個「最長等多久」的上限,這就是 context 的 deadline 在做的事。

我的做法是給 Shutdown 一個 context.WithTimeout,例如 25 秒。意思是:「我給進行中的請求最多 25 秒做完,超過就不等了。」

這個秒數怎麼抓,是一門取捨:

  • 太短:那些本來就比較慢但合法的請求(例如一個複雜報表查詢)會被砍掉,等於沒做到優雅。
  • 太長:部署會變慢,而且如果真的有請求卡死,你要等很久才會放棄。更糟的是,這個秒數如果超過 Kubernetes 給的寬限時間,那再長也沒用,時間到了照樣被 SIGKILL。

所以這個 deadline 不是憑感覺設的,它必須跟你部署環境給的寬限時間對齊。我的原則是:應用層的 shutdown timeout 要明顯小於環境的寬限時間,留一點緩衝給其他收尾動作(關 DB 連線之類)。等一下講 Kubernetes 時會再回來談這個對齊。

背景 worker 與 consumer 怎麼優雅收尾

HTTP server 有現成的 Shutdown,但你自己寫的背景 worker、佇列 consumer 沒有,得自己設計。原則一樣:停止拉新的、處理完手上的、再退出

以一個從佇列拉訊息的 consumer 為例,優雅收尾的步驟是:

  • 先停止拉取:收到關閉訊號後,第一件事是不要再從佇列拉新訊息。通常我會用前面那個被 cancel 的 context 來控制拉取迴圈——context 一被取消,迴圈就跳出,不再去拉。
  • 處理完手上的:已經拉出來、正在處理的那則(或那批)訊息,要讓它做完,並且正確地 ack。這一步沒做好,就是前面講的「訊息沒 ack 被重投」或「太早 ack 被丟失」。
  • 確認都收尾了才退出:我會用 sync.WaitGroup 來追蹤所有在跑的 worker goroutine。每個 worker 開始時 Add,結束時 Done,主流程在關閉時 Wait,直到所有 worker 都真的結束。

這裡有個跟 HTTP 不一樣的眉角:consumer 的「一個工作單位」是一則訊息,而不是一個短請求。如果你的單則訊息處理可能跑很久(例如要呼叫好幾個下游、跑一段運算),你的關閉 deadline 就要把這個納入考量。我會在每則訊息的處理裡也傳入 context,讓那些可以中斷的步驟(例如對下游的呼叫)在關閉時能提早收手,而不是傻傻跑到底。

另外,worker 的關閉順序很重要。如果 worker A 的輸出是 worker B 的輸入,你要先停 A、等 A 排空、再停 B,不能反過來,否則 B 停了之後 A 還在塞東西給它,那些東西就卡住沒人處理了。這種有依賴關係的 pipeline,關閉順序要跟資料流方向一致。

Kubernetes 下的眉角:這裡最容易出錯

前面講的都是應用層。但在 Kubernetes 下,有一整套環境行為會影響優雅關閉,而且這些是最多人栽跟頭的地方。

為什麼 SIGTERM 之後還會收到一小段流量

這是最反直覺、也最常掉訂單的點。很多人以為:「K8s 送了 SIGTERM,代表它已經不會再把流量導給我了,我關掉 server 就好。」錯。

K8s 終止一個 Pod 時,這兩件事幾乎是同時發生的:

  • 把 Pod 從 Service 的 endpoint 列表移除(停止導流)。
  • 送 SIGTERM 給容器。

問題在於「從 endpoint 移除」這個資訊,要傳播到所有節點的 kube-proxy、Ingress、負載平衡器,是需要時間的、而且是非同步的。在這個傳播完成之前,還是會有新請求被導到這個正在關閉的 Pod。

所以如果你一收到 SIGTERM 就馬上呼叫 Shutdown 停收新請求,那段還在路上的流量就會吃到連線拒絕——這就是部署時零星掉請求的真兇。

用 preStop hook 爭取緩衝時間

解法是 preStop hook。K8s 在送 SIGTERM 之前,會先執行 preStop hook,而且會等它做完才送 SIGTERM。我會在 preStop 裡放一個單純的「睡幾秒」(例如 sleep 5 到 10 秒)。

這幾秒的作用是:給「endpoint 移除」這個資訊足夠的時間傳播出去。等到真正的 SIGTERM 來、我開始 Shutdown 時,新流量早就不會再導進來了。這幾秒換來的是「Shutdown 的那一刻,確實沒有新請求會來」,非常划算。

順帶一提,preStop 在跑的時候,應用其實還活著、還在正常處理請求,所以這幾秒不會掉任何東西,反而是在保護你。

terminationGracePeriodSeconds 與時間預算的對齊

terminationGracePeriodSeconds 是 K8s 給整個 Pod 的總寬限時間,預設是 30 秒。從 SIGTERM 送出開始算(注意:preStop 的時間也算在這個總額裡面),超過了就 SIGKILL 硬砍。

所以整個時間預算是一條線,要這樣拆:

  • preStop sleep:例如 10 秒。
  • 應用層 Shutdown timeout:要小於「寬限總時間減掉 preStop 的時間」。如果總共 30 秒、preStop 用了 10 秒,那 Shutdown 最多只剩 20 秒可用,我會設成例如 15 秒,留點緩衝給關 DB 連線等收尾。

最常見的錯誤就是這三個數字沒對齊:應用層設了 30 秒的 shutdown timeout,但 K8s 寬限只有 30 秒、preStop 還吃掉 10 秒,結果應用根本等不到自己的 timeout,20 秒時就被 SIGKILL 了——你寫的那套優雅關閉,根本沒機會跑完。我看過好幾次「優雅關閉的程式碼明明寫了卻沒用」,追到最後都是這個對齊問題。

就緒探針先摘流量

readiness probe(就緒探針)是另一道保險。原理是:當就緒探針回報「我還沒準備好」時,K8s 就不會把流量導給這個 Pod。

有些人會在收到 SIGTERM 後,主動讓就緒探針開始回報失敗,等於主動告訴 K8s「別再給我流量了」。這跟 preStop sleep 是互補的——preStop 是被動等傳播,主動摘流量則是更早一步告知。兩者搭配,能把「關閉瞬間還有流量進來」的窗口壓到最小。

不過要小心:摘流量靠的是 readiness probe,不要把 liveness probe(存活探針)跟關閉流程搞混。如果你在關閉時讓存活探針失敗,K8s 可能會以為你掛了而把你重啟,那就亂了。關閉時動的是就緒,不是存活

資料庫連線與資源釋放順序

走到這一步,HTTP 也停了、worker 也排空了,最後是收尾資源。順序很重要,講錯方向會出事。

我的釋放順序原則是「由外而內,跟著依賴關係的反方向」:

  • 先停對外的入口:HTTP server、gRPC server 先 Shutdown。這是源頭,先把進水口關掉。
  • 再停背景工作者:worker、consumer 排空並退出。
  • 最後才關底層資源:資料庫連線池、Redis 連線、訊息佇列連線、檔案 handle。

為什麼底層資源要最後關?因為前面那些東西在收尾的時候,還會用到它們。如果你先把資料庫連線池關了,結果某個正在做完最後一筆的請求還要寫資料庫,它就會拿到一個「連線池已關閉」的錯誤——你的優雅關閉反而製造了一個本來不會發生的失敗。

所以順序一定是:確定沒有人會再用資料庫了,才關資料庫。具體就是等 server.Shutdown 回來、等 worker 的 WaitGroup Wait 完,這兩件都確定結束之後,才去呼叫 db.Close。

關閉資料庫連線池本身也有眉角。連線池的 Close 通常會等借出去的連線都還回來。如果前面的步驟有做好(請求和 worker 都收尾了),這時候連線都該還回來了,Close 會很快。如果你跳過前面直接關,它可能會卡住等那些永遠還不回來的連線。

還有一個常被忘記的:有 buffer 的東西要 flush。如果你有非同步的 log、有批次寫入的 metrics、有暫存在記憶體還沒送出的資料,這些都要在退出前主動 flush。不然行程一結束,buffer 裡的東西就跟著消失了。我習慣把這些 flush 動作放在所有資源關閉的最後、真正 return 之前。

一個完整部署場景串起來

把上面所有東西用一次滾動更新串起來,畫面是這樣:

  • 我推了新版本,K8s 開始滾動更新,決定要終止一個舊 Pod。
  • K8s 同時做兩件事:把這個 Pod 從 Service endpoint 移除、執行 preStop hook(sleep 10 秒)。這 10 秒裡,應用還活著、還在處理請求,同時 endpoint 移除的資訊正在各節點傳播。
  • preStop 結束,K8s 送 SIGTERM。此時新流量基本上已經不會再導進來了。
  • 我的應用收到 SIGTERM,root context 被 cancel。HTTP server 開始 Shutdown(給它 15 秒 deadline),把手上的請求做完。同時 consumer 停止拉新訊息,把手上那則處理完並 ack。
  • server.Shutdown 回來、worker 的 WaitGroup Wait 完。確認真的沒人會再用底層資源了。
  • 關閉資料庫連線池、Redis 連線,flush 掉非同步 log 與 metrics。
  • 主流程 return,行程乾淨地以 0 退出。整個過程在寬限時間內完成,K8s 根本不需要動用 SIGKILL。

整個過程,使用者端零感知。沒有掉的請求、沒有重投的訊息、沒有做一半的交易。這就是優雅關閉該有的樣子。

小結

優雅關閉聽起來像個錦上添花的細節,但在每天都要部署、又碰錢碰訂單的系統裡,它是底線而不是加分題。整套東西的精神其實很單純:收到訊號先停收新工作,把手上的做完,按依賴反方向收尾資源,全部在寬限時間內完成

真正會掉訂單的地方,往往不在那段你精心寫的關閉程式碼裡,而在你沒注意到的對齊——SIGTERM 之後那一小段還在路上的流量、應用 timeout 與 K8s 寬限時間沒對齊、資料庫連線關得太早。把這幾個對齊弄對,部署就會從一件需要挑半夜、提心吊膽的事,變成一個白天隨手就能做、沒人會察覺的動作。能讓部署變得無聊,就是這套功夫最大的價值。

#Go#Graceful Shutdown#Kubernetes#SIGTERM#高併發#後端工程

相關文章