如何在 Kubernetes 中實現應用的無損上線和下線

小家电维修發表於2024-08-07

轉載:https://mp.weixin.qq.com/s/LdquOPS34mLFqYjfI4J6fQ

在日常工作中,經常會接收到開發團隊這樣的反饋:為什麼應用釋出或重啟的期間會出現少量的 5xx 異常,應該如何解決?

在深入分析後,我們發現導致流量有損的原因有很多,比如:

  • 上線時,應用在就緒前收到流量,導致請求無法被處理;
  • 下線時,應用沒有做優雅退出導致請求中斷,應用沒有正確監聽到終止訊號導致優雅退出無效,平臺路由規則更新不及時導致流量轉發到已經銷燬的副本等;

因此,本期我將針對這些場景來詳細講解如何在 Kubernetes 中實現應用的無損上線和下線,以解決在釋出或重啟應用時出現5xx異常的問題。

流量轉發過程

想要知道流量有損的原因,就必須先搞明白流量轉發的過程,我們透過一張圖來看看企業內部中流量轉發的過程:

其中涉及到負載均衡器、流量閘道器、業務閘道器、Ingress、Service 這類產品和物件,雖然它們都具有負載均衡和流量轉發的能力,但是他們的功能和定位各不相同:

  1. 雲廠商/自建 LB:無論是雲廠商提供的還是自建的負載均衡器都需要一個可以供外部訪問的 IP 地址來作為流量的入口,將流量分發到後端的服務或叢集中。雲廠商的負載均衡器有很多,比如阿里雲的 SLB、華為雲的 ELB 等;而自建負載均衡器則可以採用雲廠商的主機,也可以使用公共 IP 地址等;
  2. 流量閘道器:雲廠商或自建的負載均衡器已經具備流量閘道器的基本功能,能滿足一些簡單的應用場景,但面對於更高階的流量管理和控制功能,以及特定的安全需求,企業往往會引入更為複雜的流量閘道器產品,用於實現訪問控制(基於客戶端IP、使用者身份、請求內容等)、安全認證和授權(身份認證、授權、單點登入等)、流量過濾和轉發、監控和報警等功能;
  3. 業務閘道器/Ingress:雖然業務閘道器和流量閘道器在功能上有些類似,但側重點不同:流量閘道器更側重於全域性的網路通訊、效能和安全方案,業務閘道器則更加貼近特定的業務要求。業務組的定製化需求應該優先選擇放在業務閘道器而不是流量閘道器裡。Ingress 是 k8s 中的一種資源物件,它充當著在 Kubernetes 叢集中公開服務的入口,能提供部分業務閘道器的功能,比如路由轉發、SSL、負載均衡等;
  4. Service:為 Pod 提供統一的訪問入口,使用的是基於傳輸層(L4)的負載均衡技術,透過 ipvs 或 iptables 等工具配置和管理流量的轉發規則,將流量轉發到後端 Pod 中,但 Service 不擅長處理 HTTP/2、gRPC這類長連線的請求,也無法支援高階的路由需求,比如說針對 Host、Path等 L7 層協議的動態路由;

當流量訪問我們的域名時,它會首先透過雲廠商的 DNS 伺服器獲取域名對應的負載均衡器地址。然後,流量會透過 VPN 或專線傳輸至企業內部的流量閘道器,接著到達專案的業務閘道器或者 Kubernetes 的 Ingress 物件,最終流量透過 Service 的名稱,被 ipvs 或 iptables 配置的路由規則轉發到後端的 Pod 中。

當應用釋出或重啟時,流量轉發過程可能會中斷,導致流量達不到目的地,造成流量有損。好在 Kubernetes 提供了滾動更新的機制,可以在不中斷服務的情況下更新現有的服務。

滾動更新機制

在應用釋出時,Kubernetes 會將已就緒的 Pod 新增到與 Service 同名的 Endpoint 物件中,並在 Endpoint 中移除處於 Terminating 狀態的 Pod。CCM(cloud-controller-manager)和 kube-proxy 元件都會監聽 Service 和 Endpoint 物件的變化。在他們發生變化時,kube-proxy 元件會透過 ipvs 或 iptables 更新節點的流量轉發規則,CCM 元件則會更新下游的後端。

Kubernetes 常用的三種工作負載:Deployment(無狀態應用)、StatefulSet(有狀態應用)、DaemonSet(守護程序) 物件都支援滾動更新。在滾動更新的過程中,透過 maxSurge 欄位來控制允許超出期望副本數的副本個數,maxUnavailable 欄位來控制更新過程中不可用的副本個數。

以 Deployment 為例,它的 maxSurge 和 maxUnavailable 預設值分別是25%。在滾動更新時,Kubernetes 會建立一個新的 ReplicaSet 物件來啟動新的副本,而舊的 ReplicaSet 物件會逐步減少副本數量。

假設有某應用有 5 個副本,這就意味著在滾動更新時,它最多隻有 1 個副本(5 * 25% = 1.25,向下取整即為1)處於不可用的狀態,最多存在 7 個副本(新增了 2 個副本,5 * 25% = 1.25,向上取整即為2),示例圖如下:

在服務上線和下線的過程中,如果配置不當,就很容易會導致業務閘道器到 Service、Service 到 Pod 這兩個環節的流量出現有損。

我們先來看看服務上線時流量有損的問題。

服務上線有損分析

Kubernetes 為容器提供了三種健康檢測的能力:

  • 啟動檢測:用於檢測容器是否正常啟動,若檢測不透過,kubelet 將會殺死容器並根據容器的重啟策略來決定是否重啟;
  • 存活檢測:用於定期檢測容器是否正常執行,若檢測不透過,後續行為同啟動檢測;
  • 就緒檢測:用於定期檢測容器是否可以接收流量,若檢測不透過,Kubernetes 系統的控制器將會將 PodIP 從 Service 物件的 Endpoint 中移除,來確保不會有客戶端的流量進入該容器;

  我們可以從 Pod 的建立流程來看看健康探測對服務上線時流量的影響:

在滾動更新時, ReplicaSet 控制器會向 kube-apiserver 元件傳送建立 Pod 的請求,kube-scheduler 元件負責選擇合適的節點的節點來執行新的 Pod,並將選擇的節點寫入 Pod 物件的 spec.nodeName 欄位中。

部署在每個節點中的 kubelet 元件會監聽 Pod 物件的變化,當新的 Pod 被排程到所在的節點時,kubelet 元件會使用 CRI 來啟動容器,並在啟動後對 Pod 內每個容器執行啟動探測。

在啟動探測透過後,再分別對每個容器週期性地執行存活探測和就緒探測,在 Pod 內所有容器都透過就緒探測之後,kubelet 元件會把 Pod 標記為 Ready 狀態,同時上報給 kube-apiserver 元件。

而 Endpoint 控制器會監聽 Pod 的變化,當 Pod 的狀態變為已就緒時,會將 Pod 的 IP 和埠新增到 Endpoint 物件中, kube-proxy 元件在監聽 Endpoint 物件的變化後會使用 ipvs 或 iptables 在節點中生成流量轉發的規則。

在這個過程中,沒有配置就緒探測或者就緒探測配置不當,則是服務上線時流量有損的主要原因。

沒有配置就緒探測導致服務上線有損

如果沒有配置就緒探測,Pod 在啟動完成後會立即被視為就緒狀態,然後開始接收流量,如果此時容器內的程序還在初始化資源的狀態就會造成流量有損,因此在生產環境中,強烈建議大家配置上就緒探測。

就緒探測配置不當導致服務上線有損

在使用就緒探測時需要注意一些細節問題,比如說就緒探測提供了三種探測方式:HTTP 探測、命令列探測、TCP 探測,對於常規的 Web 服務,我們應該首選 HTTP 探測、備選命令列探測,儘量避免使用 TCP 探測。

在使用 TCP 探測時,kubelet 元件會向指定埠傳送 TCP SYN 包,如果 Pod 核心響應 TCP ACK包則表明監聽的埠處於開啟狀態,則探測成功。但TCP 探測只能檢測網路連線是否建立、服務埠是否開啟,無法反應服務的真實健康狀態,比如程式的埠雖然已經開啟,但如果內部正在初始化資源或者出現了死鎖等問題的話,也就無法正常處理流量,即流量打到表面健康但實際不健康的 Pod 上,造成流量有損。

服務下線有損分析

服務下線時,流量有損的原因可能出現在業務方面,也可能出現 Kubernetes 平臺方面。我們先來看看 Pod 退出時的銷燬過程,再來說業務側和平臺側的問題:

在滾動更新時, ReplicaSet 控制器會向 kube-apiserver 元件傳送刪除 Pod 的請求,kube-apiserver 不會立即刪除 Pod,而是在 Pod 的metadata 中新增 deletionTimestamp 欄位,將 Pod 的狀態標記為 Terminating。

當 kubelet 元件監聽到 Pod 物件的 deletionTimestamp 被設定時,就會呼叫 CRI 向容器發起停止容器的請求,如果容器設定了 PreStop 鉤子,kubelet 會在傳送 SIGTERM 訊號之前先執行 PreStop 鉤子,等待 PreStop 鉤子執行完成後再傳送 SIGTERM 訊號,接著在 terminationGracePeriodSeconds 後傳送 SIGKILL 訊號去殺死所有容器程序,完成容器的停止過程。

在 kubelet 停止容器的同時,Endpoint 控制器監聽到 Pod 的狀態變化,會將 Pod 的 IP 從相應的 Endpoint 物件中移除,kube-proxy 元件監聽到 Endpoint 物件的變化後,會移除 Pod IP 的轉發規則。kube-proxy 在不同的模式下移除轉發規則的方式會有所不同,比如 ipvs 模式下會把規則的權重修改為0,iptable 模式下則是直接刪除轉發規則。

接下來,我們來看在看服務下線時,流量在業務側容易出現的問題。

沒有實現優雅退出導致服務下線有損

業務側第一個可能會忽略的問題就是沒有做優雅退出,優雅退出可以讓程式在退出前有機會等待尚未完成的事務處理、清理資源(比如關閉檔案描述符、關閉socket)、持久化記憶體資料(比如將記憶體中的資料落盤到檔案中)等,我們來看一個最簡單的優雅退出示例:

var (

   httpPort = ":9081"

   grpcPort = ":9082"

)

 

func main() {

   // 監聽SIGINT、SIGTERM等訊號

   signals := []os.Signal{syscall.SIGINT, syscall.SIGTERM}

   signalChan := make(chan os.Signal, 1)

   signal.Notify(signalChan, signals...)

 

   grpcSrv := grpc.NewServer()

   httpSrv := &http.Server{Addr: httpPort}

 

   ctx, stop := signal.NotifyContext(context.Background(), signals...)

   defer stop()

 

   g, gctx := errgroup.WithContext(ctx)

   g.Go(func() error {

      log.Println("啟動http服務")

      return httpSrv.ListenAndServe()

   })

   g.Go(func() error {

      grpcListener, err := net.Listen("tcp", grpcPort)

      if err != nil {

         return err

      }

      log.Println("啟動grpc服務")

      return grpcSrv.Serve(grpcListener)

   })

   g.Go(func() error {

      // 優雅退出

      <-gctx.Done()

      c := <-signalChan

      log.Println("開始優雅退出,退出訊號為", c.String())

 

      grpcSrv.Stop()

      // 建立一個超時上下文

      sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

      defer cancel()

      return httpSrv.Shutdown(sctx)

   })

   if err := g.Wait(); err != nil {

      println("服務退出:", err.Error())

   }

}

在這份程式碼中,程式在監聽到 SIGINT、SIGTERM 訊號後開始關閉 gRPC、HTTP 等服務。為了防止服務長時間無法退出,我們建立了一個超時上下文,httpSrv.Shutdown(c) 會關閉 HTTP 服務,該方法會等待所有活動連線都關閉,或超時時間到達時立即返回。

不過如果 HTTP 連線在超時時間內無法關閉的話,依然會產生 50x 的錯誤,因此生產環境中,優雅退出的實現要複雜得多,這裡不做過多展開。

程式啟動方式不正確導致服務下線有損

業務側第二個可能會忽略的問題就是啟動方式不對導致程式無法接收到訊號,進而無法實現優雅退出。

我們發現部分同學在啟用程式時會使用啟動指令碼,示例如下:

#!/bin/sh

# start.sh
# do something
/demo/app

在 Kubernetes 環境下,這種啟動方式會使程式無法正常接收 SIGINT、SIGTERM 等訊號以進行優雅退出,最終只能被 SIGKILL 強制終止。

我們可以復現這個過程,先準備 Dockerfile,示例如下:

# 構建
FROM golang:alpine as build

WORKDIR /demo

ADD . .

RUN GOOS=linux CGO_ENABLED=0 GOARCH=amd64 go build -o app main.go

# 執行
FROM ubuntu:22.04 as prod

COPY --from=build /demo/app /demo/
COPY --from=build /demo/start.sh /demo/

# 啟動服務
CMD ["/demo/start.sh"]

Kubernetes 的資原始檔示例如下:

# pod.yaml

apiVersion: v1

kind: Pod

metadata:

  name: graceful

spec:

  containers:

    - name: graceful

      image: graceful-app:latest

      imagePullPolicy: IfNotPresent

  接著,執行以下命令:

# 構建映象

$ docker build -t graceful-app:latest -f Dockerfile  .

 

# 啟動服務

$ kubectl apply -f pod.yaml

 

# 檢視日誌

$ kubectl logs graceful -f

2023/09/06 04:34:35 啟動http服務

2023/09/06 04:34:35 啟動grpc服務

  然後,我們開啟新的終端執行刪除操作。按照預期的設想,程式會輸出『優雅退出』相關的字樣:

$ kubectl delete -f pod.yaml

繼續觀察日誌,發現程式並沒有按照預期執行,而是在寬限期 terminationGracePeriodSeconds(預設是30s)後直接退出。

這個問題的根源在於業務程式在容器中沒有作為1號程序啟動,而是作為啟動指令碼start.sh的子程序啟動。我們可以使用 strace 命令監聽啟動指令碼和業務程式所在的程序,操作如下:

# 在容器中檢視程序

$ ps aux

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND

root         1  0.0  0.0   2484   520 ?        Ss   06:47   0:00 /bin/sh ./start.sh

root         8  0.0  0.0 711492  6648 ?        Sl   06:47   0:00 /demo/app

 

# 在宿主機中檢視程序

$ ps aux

USER     PID   %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND

root     44377  0.0  0.0   2484   520 ?        Ss   14:47   0:00 /bin/sh ./start.sh

root     44403  0.0  0.0 711492  6648 ?        Sl   14:47   0:00 /demo/app

 

# 在宿主機中監聽start.sh的程序

$ strace -p 44377

strace: Process 44377 attached

wait4(-1,

0x7ffd60d9dd1c, 0, NULL)      = ? ERESTARTSYS (To be restarted if SA_RESTART is set)

--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0} ---

wait4(-1,  <unfinished ...>)            = ?

+++ killed by SIGKILL +++

 

# 在宿主機中監聽業務程式的程序

$ strace -p 44403

strace: Process 44403 attached

futex(0xd19ca8, FUTEX_WAIT_PRIVATE, 0, NULL) = ?

+++ killed by SIGKILL +++

可以發現,在執行刪除操作的時候,容器中的 1 號程序 start.sh 收到了 SIGTERM 訊號,但並沒有轉發給它的子程序,導致業務程式沒有辦法做優雅退出,最後因為 SIGKILL 訊號被強制殺掉。

解決這個問題,有兩種比較簡單的方法:

  1. 在 Dockerfile 中移除啟動指令碼 start.sh,讓業務程式作為1號程序被啟動:
# 構建

FROM golang:alpine as build
WORKDIR /demo
ADD . .
RUN GOOS=linux CGO_ENABLED=0 GOARCH=amd64 go build -o app main.go

# 執行
FROM ubuntu:22.04 as prod
COPY --from=build /demo/app /demo/

# 讓業務程式作為1號程序被啟動
CMD ["/demo/app"]

  1. 保留啟動指令碼start.sh,使用 exec 命令啟動業務程式,exec 用於執行新的命令,同時替換掉當前程序,即覆蓋掉原來啟動指令碼 start.sh 的程序:
#!/bin/sh


# do something
exec /demo/app

當然,還有其他方法,比如使用 tini 等輕量級的 init 系統,它專門為 Docker 容器設計,主要用於解決容器環境中的訊號處理問題,這裡就不做展開,有興趣的同學可以自行了解。

路由轉發規則更新不及時導致服務下線有損

服務下線的過程中,Kubernetes 的 kubelet 和 kube-proxy 元件會同時監聽 Pod 的變化,然後分別去清理容器和網路的路由轉發規則,這個過程是同時進行的。在某些情況下,kubelet 有可能在 kube-proxy 更新完路由轉發規則前就已經銷燬了容器,這時新的流量被轉發進來時,就會出現異常。

為了避免這種情況,我們可以使用 Kubernetes 提供的 preStop 鉤子,讓 kubelet 元件在等待一段時間後在執行回收容器的操作,給 kube-proxy 留夠清理的時間,示例如下:

lifecycle:
  preStop:
    exec:
      command:
        - /bin/sleep
        - '15'

除此之外,還有其他導致路由轉發規則更新不及時的場景,比如有些同學在閘道器中透過 NodeIP+NodePort 的形式轉發流量,如果節點出現異常,運維需要對節點進行下線,這時在閘道器的 backend 沒有及時移除該節點,也會造成流量有損:

這裡我們一般建議研發同學使用平臺開發的 ccm 元件,監聽節點變化,及時更新閘道器的 backend 列表。

總結

在本期文章中,我給大家分析了服務在上下線時常見的流量有損原因以及對應的解決方案:透過配置就緒探測、做好優雅退出、選擇正確的啟動方式、適當配置 preStop 鉤子等方式,我們就可以解決大部分的流量有損問題。

但是,光靠這些手段還不能避免所有的流量有損問題,而更多的生產環境經驗和教訓.

相關文章