Kubernetes 中如何保證優雅地停止 Pod
一直以來我對優雅地停止 Pod 這件事理解得很單純:不就利用是 PreStop hook 做優雅退出嗎?但最近發現很多場景下 PreStop Hook 並不能很好地完成需求,這篇文章就簡單分析一下“優雅地停止 Pod”這回事兒。
何謂優雅停止?
優雅停止(Graceful shutdown)這個說法來自於作業系統,我們執行關機之後都得 OS 先完成一些清理操作,而與之相對的就是硬中止(Hard shutdown),比如拔電源。
到了分散式系統中,優雅停止就不僅僅是單機上程序自己的事了,往往還要與系統中的其它元件打交道。比如說我們起一個微服務,閘道器把一部分流量分給我們,這時:
- 假如我們一聲不吭直接把程序殺了,那這部分流量就無法得到正確處理,部分使用者受到影響。不過還好,通常來說閘道器或者服務註冊中心會和我們的服務保持一個心跳,過了心跳超時之後系統會自動摘除我們的服務,問題也就解決了;這是硬中止,雖然我們整個系統寫得不錯能夠自愈,但還是會產生一些抖動甚至錯誤。
- 假如我們先告訴閘道器或服務註冊中心我們要下線,等對方完成服務摘除操作再中止程序,那不會有任何流量受到影響;這是優雅停止,將單個元件的啟停對整個系統影響最小化。
按照慣例,SIGKILL 是硬終止的訊號,而 SIGTERM 是通知程序優雅退出的訊號,因此很多微服務框架會監聽 SIGTERM 訊號,收到之後去做反註冊等清理操作,實現優雅退出。
PreStop Hook
回到 Kubernetes(下稱 K8s),當我們想幹掉一個 Pod 的時候,理想狀況當然是 K8s 從對應的 Service(假如有的話)把這個 Pod 摘掉,同時給 Pod 發 SIGTERM 訊號讓 Pod 中的各個容器優雅退出就行了。但實際上 Pod 有可能犯各種么蛾子:
- 已經卡死了,處理不了優雅退出的程式碼邏輯或需要很久才能處理完成。
- 優雅退出的邏輯有 BUG,自己死迴圈了。
- 程式碼寫得野,根本不理會 SIGTERM。
因此,K8s 的 Pod 終止流程中還有一個“最多可以容忍的時間”,即 grace period(在 Pod 的 .spec.terminationGracePeriodSeconds
kubectl delete
的時候也可通過 --grace-period
引數顯式指定一個優雅退出時間來覆蓋 Pod 中的配置。而當 grace period 超出之後,K8s 就只能選擇 SIGKILL 強制幹掉 Pod 了。
很多場景下,除了把 Pod 從 K8s 的 Service 上摘下來以及程序內部的優雅退出之外,我們還必須做一些額外的事情,比如說從 K8s 外部的服務註冊中心上反註冊。這時就要用到 PreStop Hook 了,K8s 目前提供了 Exec
和 HTTP
兩種 PreStop Hook,實際用的時候,需要通過 Pod 的 .spec.containers[].lifecycle.preStop
欄位為 Pod 中的每個容器單獨配置,比如:
spec:
contaienrs:
- name: my-awesome-container
lifecycle:
preStop:
exec:
command: ["/bin/sh","-c","/pre-stop.sh"]
複製程式碼
/pre-stop.sh
腳本里就可以寫我們自己的清理邏輯。
最後我們串起來再整個表述一下 Pod 退出的流程(官方文件裡更嚴謹哦):
- 使用者刪除 Pod。
-
- 2.1. Pod 進入 Terminating 狀態。
- 2.2. 與此同時,K8s 會將 Pod 從對應的 service 上摘除。
- 2.3. 與此同時,針對有 PreStop Hook 的容器,kubelet 會呼叫每個容器的 PreStop Hook,假如 PreStop Hook 的執行時間超出了 grace period,kubelet 會發送 SIGTERM 並再等 2 秒。
- 2.4. 與此同時,針對沒有 PreStop Hook 的容器,kubelet 傳送 SIGTERM。
- grace period 超出之後,kubelet 傳送 SIGKILL 幹掉尚未退出的容器。
這個過程很不錯,但它存在一個問題就是我們無法預測 Pod 會在多久之內完成優雅退出,也無法優雅地應對“優雅退出”失敗的情況。而在我們的產品 TiDB Operator 中,這就是一個無法接受的事情。
有狀態分散式應用的挑戰
為什麼說無法接受這個流程呢?其實這個流程對無狀態應用來說通常是 OK 的,但下面這個場景就稍微複雜一點:
TiDB 中有一個核心的分散式 KV 儲存層 TiKV。TiKV 內部基於 Multi-Raft 做一致性儲存,這個架構比較複雜,這裡我們可以簡化描述為一主多從的架構,Leader 寫入,Follower 同步。而我們的場景是要對 TiKV 做計劃性的運維操作,比如滾動升級,遷移節點。
在這個場景下,儘管系統可以接受小於半數的節點宕機,但對於預期性的停機,我們要儘量做到優雅停止。這是因為資料庫場景本身就是非常嚴苛的,基本上都處於整個架構的核心部分,因此我們要把抖動做到越小越好。要做到這點,就得做不少清理工作,比如說我們要在停機前將當前節點上的 Leader 全部遷移到其它節點上。
得益於系統的良好設計,大多數時候這類操作都很快,然而分散式系統中異常是家常便飯,優雅退出耗時過長甚至失敗的場景是我們必須要考慮的。假如類似的事情發生了,為了業務穩定和資料安全,我們就不能強制關閉 Pod,而應該停止操作過程,通知工程師介入。 這時,上面所說的 Pod 退出流程就不再適用了。
小心翼翼:手動控制所有流程
這個問題其實 K8s 本身沒有開箱即用的解決方案,於是我們在自己的 Controller 中(TiDB 物件本身就是一個 CRD)與非常細緻地控制了各種操作場景下的服務啟停邏輯。
拋開細節不談,最後的大致邏輯是在每次停服務前,由 Controller 通知叢集進行節點下線前的各種遷移操作,操作完成後,才真正下線節點,並進行下一個節點的操作。
而假如叢集無法正常完成遷移等操作或耗時過久,我們也能“守住底線”,不會強行把節點幹掉,這就保證了諸如滾動升級,節點遷移之類操作的安全性。
但這種辦法存在一個問題就是實現起來比較複雜,我們需要自己實現一個控制器,在其中實現細粒度的控制邏輯並且在 Controller 的控制迴圈中不斷去檢查能否安全停止 Pod。
另闢蹊徑:解耦 Pod 刪除的控制流
複雜的邏輯總是沒有簡單的邏輯好維護,同時寫 CRD 和 Controller 的開發量也不小,能不能有一種更簡潔,更通用的邏輯,能實現“保證優雅關閉(否則不關閉)”的需求呢?
有,辦法就是 ValidatingAdmissionWebhook。
這裡先介紹一點點背景知識,Kubernetes 的 apiserver 一開始就有 AdmissionController 的設計,這個設計和各類 Web 框架中的 Filter 或 Middleware 很像,就是一個外掛化的責任鏈,責任鏈中的每個外掛針對 apiserver 收到的請求做一些操作或校驗。舉兩個外掛的例子:
DefaultStorageClass
,為沒有宣告 storageClass 的 PVC 自動設定 storageClass。ResourceQuota
,校驗 Pod 的資源使用是否超出了對應 Namespace 的 Quota。
雖然說這是外掛化的,但在 1.7 之前,所有的 plugin 都需要寫到 apiserver 的程式碼中一起編譯,很不靈活。而在 1.7 中 K8s 就引入了 Dynamic Admission Control 機制,允許使用者向 apiserver 註冊 webhook,而 apiserver 則通過 webhook 呼叫外部 server 來實現 filter 邏輯。1.9 中,這個特性進一步做了優化,把 webhook 分成了兩類: MutatingAdmissionWebhook
和 ValidatingAdmissionWebhook
,顧名思義,前者就是操作 api 物件的,比如上文例子中的 DefaultStroageClass
,而後者是校驗 api 物件的,比如 ResourceQuota
。拆分之後,apiserver 就能保證在校驗(Validating)之前先做完所有的修改(Mutating),下面這個示意圖非常清晰:
而我們的辦法就是,利用 ValidatingAdmissionWebhook
,在重要的 Pod 收到刪除請求時,先在 webhook server 上請求叢集進行下線前的清理和準備工作,並直接返回拒絕。這時候重點來了,Control Loop 為了達到目標狀態(比如說升級到新版本),會不斷地進行 reconcile,嘗試刪除 Pod,而我們的 webhook 則會不斷拒絕,除非叢集已經完成了所有的清理和準備工作。
下面是這個流程的分步描述:
- 使用者更新資源物件。
- controller-manager watch 到物件變更。
- controller-manager 開始同步物件狀態,嘗試刪除第一個 Pod。
- apiserver 呼叫外部 webhook。
- webhook server 請求叢集做 tikv-1 節點下線前的準備工作(這個請求是冪等的),並查詢準備工作是否完成,假如準備完成,允許刪除,假如沒有完成,則拒絕,整個流程會因為 controller manager 的控制迴圈回到第 2 步。
好像一下子所有東西都清晰了,這個 webhook 的邏輯很清晰,就是要保證所有相關的 Pod 刪除操作都要先完成優雅退出前的準備,完全不用關心外部的控制迴圈是怎麼跑的,也因此它非常容易編寫和測試,非常優雅地滿足了我們“保證優雅關閉(否則不關閉)”的需求,目前我們正在考慮用這種方式替換線上的舊方案。
後記
其實 Dynamic Admission Control 的應用很廣,比如 Istio 就是用 MutatingAdmissionWebhook
來實現 envoy 容器的注入的。從上面的例子中我們也可以看到它的擴充套件能力很強,而且常常能站在一個正交的視角上,非常乾淨地解決問題,與其它邏輯做到很好的解耦。
當然了,Kubernetes 中還有 非常多的擴充套件點,從 kubectl 到 apiserver,scheduler,kubelet(device plugin,flexvolume),自定義 Controller 再到叢集層面的網路(CNI),儲存(CSI)可以說是處處可以做事情。以前做一些常規的微服務部署對這些並不熟悉也沒用過,而現在面對 TiDB 這樣複雜的分散式系統,尤其在 Kubernetes 對有狀態應用和本地儲存的支援還不夠好的情況下,得在每一個擴充套件點上去悉心考量,做起來非常有意思,因此後續可能還有一些 TiDB Operator 中思考過的解決方案分享。