天天看點

Kubernetes在生産環境中的一些讨論

pod是所有一切資源的中心,毫無疑問是Kubernetes中最重要的資源。畢竟, 每個應用都運作在pod中。為了確定知道如何開發能充分利用應用所在環境資源的應用,最後再從應用的角度來仔細看一下pod。

1.了解pod的生命周期

  可以将pod比作隻運作單個應用的虛拟機。盡管在pod中運作的應用和虛拟機中運作的應用沒什麼不同,但是還是存在顯著的差異。其中一個例子就是pod中運作的應用随時可能會被殺死,因為Kubernetes需要将這個pod排程到另外一個節點,或者是請求縮容。接下來将探讨這方面的内容。

1.1 應用必須預料到會被殺死或者重新排程

  在Kubernetes之外,運作在虛拟機中的應用很少會被從一台機器遷移到另外一台。當一個操作者遷移應用的時候,他們可以重新配置應用并且手動檢查應用是否在新的位置正常運作。借助于Kubernetes,應用可以更加頻繁地進行自動遷移而無須人工介入,也就是說沒有人會再對應用進行配置并且確定它們在遷移之後能夠正常運作。這就意味着應用開發者必須允許他們的應用可以被相對頻繁地遷移。

  預料到本地IP和主機名會發生變化

  當一個pod被殺死并且在其他地方運作之後(技術上來講是一個新的pod替換了舊的pod,舊pod沒有被遷移),它不僅擁有了一個新的IP位址還有了一個新的名稱和主機名。大部分無狀态的應用都可以處理這種場景同時不會有不利的影響, 但是有狀态服務通常不能。已經了解到有狀态應用可以通過一個StatefulSet來運作,StatefulSet會保證在将應用排程到新的節點并啟動之後,它可以看到和之前一樣的主機名和持久化狀态。當然pod的IP還是會發生變化,應用必須能夠應對這種變化。是以應用開發者在一個叢集應用中不應該依賴成員的IP位址來建構彼此的關系,另外如果使用主機名來建構關系,必須使用StatefulSet。

  預料到寫入磁盤的資料會消失

  還有一件事情需要記住的是,在應用往磁盤寫入資料的情況下,當應用在新的pod中啟動後這些資料可能會丢失,除非将持久化的存儲挂載到應用的資料寫入路徑。在pod被重新排程的時候,資料丢失是一定的,但是即使在沒有排程的情況下,寫入磁盤的檔案仍然會丢失。甚至是在單個pod的生命周期過程中,pod中的應用寫入磁盤的檔案也會丢失。通過一個例子來解釋一下這個問題。

  假設有個應用,它的啟動過程是比較耗時的而且需要很多的計算操作。為了能夠讓這個應用在後續的啟動中更快,開發者一般會把啟動過程中的一些計算結果緩存到磁盤上(例如啟動時掃描所有的用作注解的Java類然後把結果寫入到索引檔案)。由于在Kubernetes中應用預設運作在容器中,這些檔案會被寫入到容器的檔案系統中。如果這個時候容器重新開機了,這些檔案都會丢失,因為新的容器啟動的時候會使用一個全新的可寫入層。

  不要忘了,單個容器可能因為各種原因被重新開機,例如程序崩潰了,例如存活探針傳回失敗了,或者是因為節點記憶體逐漸耗盡,程序被OOMKiller殺死了。當上述情況發生的時候,pod還是一樣,但是容器卻是全新的了。Kubelet不會一個容器運作多次,而是會重新建立一個容器。

  使用存儲卷來跨容器持久化資料

  當pod的容器重新開機後,本例中的應用仍然需要執行有大量計算過程的啟動程式。這個或許不是你所期望的。為了保證這種情況下資料不丢失,你需要至少使用一個 pod級别的卷。因為卷的存在和銷毀與pod生命周期是一緻的,是以新的容器将可以重用之前容器寫到卷上的資料。

  有時候使用存儲卷來跨容器存儲資料是個好辦法,但是也不總是如此。萬一由于資料損壞而導緻新建立的程序再次崩潰呢?這會導緻一個持續性的循環崩潰(Pod會提示CrashLoopBackOff狀态)。如果不使用存儲卷的話,新的容器會從零開始啟動, 并且很可能不會崩潰。使用存儲卷來跨容器存儲資料是把雙刃劍。需要仔細思考是否使用它們。

1.2 重新排程死亡的或者部分死亡的pod

  如果一個pod的容器一直處于崩潰狀态,Kubelet将會一直不停地重新開機它們。每次重新開機的時間間隔将會以指數級增加,直到達到5分鐘。在這個5分鐘的時間間隔中,pod基本上是死亡了,因為它們的容器程序沒有運作。公平來講,如果是個多容器的Pod,其中的一些容器可能是正常運作的,是以這個pod隻是部分死亡了。但是如果pod中僅包含一個容器,那麼這個pod是完全死亡的而且己經毫無用處了,因為裡面己經沒有程序在運作了。

  你或許會奇怪,為什麼這些pod不會被自動移除或者重新排程,盡管它們是ReplicaSet或者相似控制器的一部分。如果建立了一個期望副本數是3的ReplicaSet,當那些pod中的一個容器開始崩潰,Kubernetes将不會删除或者替換這個pod。結果就是這個ReplicaSet隻剩下了兩個正确運作的副本,而不是你期望的三個。

  你或許期望能夠删除這個pod然後重新啟動一個可以在其他節點上成功運作的pod。畢竟這個容器可能是因為一個節點相關的問題而導緻的崩潰,這個問題在其他的節點上不會出現。很遺憾,并不是這樣的。ReplicaSet本身并不關心pod是否處于死亡狀态,它隻關心pod的數量是否比對期望的副本數量,在這種情況下,副本數量确實是比對的。

  如果想自己研究一下,這裡有一個ReplicaSet的YAML manifest檔案,它裡面定義的pod會不停地崩潰。如果建立了這個ReplicaSet然後檢查一下建立的pod,你會看到如下的代碼清單。

#代碼清單17.1 ReplicaSet和持續崩潰的pod
$ kubectl get po
NAME     READY   STATUS   RESTARTS   AGE
crashing-pods-fltcd  0/1    CrashLoopOff 5     6m
crashing-pods-k7l6k  0/1    CrashLoopOff 5     6m
crashing-pods-z713v  0/1    CrashLoopOff 5     6m
$ kubect1 describe rs crashing-pods
Name: crashing-pods 
Replicas: 3 current/ 3 desired                                  #控制器沒有采取任何動作,因為目前的副本數量和期望的相符
Pods Status: 3 Running / 0 Waiting / O Succeeded / O Failed  #顯示有三個副本在運作中
$ kubectl describe po crashing-pods-fltcd
Name:    Crashing-pods-fltcd
NameSpace:   default
Node:    minikube/192.168.99.102
Start Time:   Thu, 02 Mar 2017 14:02:23 +0100
Labels:    app=crashing-pods
Status:    Running      #kubectl describe 也顯示pod的狀态是運作中      

  在某種程度上,可以了解為什麼Kubernetes會這樣做。容器将會每5分鐘重新開機一次,在這個過程中Kubernetes期望崩潰的底層原因會被解決。這個機制依據的基本原理就是将pod重新排程到其他節點通常并不會解決崩潰的問題,因為應用運作在容器的内部,所有的節點理論上應該都是相同的。雖然上面的情況并不總是如此,但是大多數情況下都是這樣。

1.3 以固定的順序啟動pod

  pod中運作的應用和手動運作的應用之間的另外一個不同就是運維人員在手動部署應用的時候知道應用之間的依賴關系,這樣他們就可以按照順序來啟動應用。

  了解pod是如何啟動的

  當使用Kubernetes來運作多個pod的應用的時候,Kubernetes沒有内置的方法來先運作某些pod然後等這些pod運作成功後再運作其他pod。當然你也可以先釋出第一個應用的配置,然後等待pod啟動完畢再釋出第二個應用的配置。但是你的整個系統通常都是定義在一個單獨的YAML或者JSON檔案中,這些檔案包含了多個pod、服務或者其他對象的定義。

  KubernetesAPI伺服器确實是按照YAML/JS0N檔案中定義的對象的順序來進行處理的,但是僅僅意味着它們在被寫入到etcd的時候是有順序的。無法確定pod會按照那個順序啟動。

  但是可以阻止一個主容器的啟動,直到它的預置條件被滿足。這個是通過在pod中包含一個叫作init的容器來實作的。

  Init容器介紹

  除了正常的容器,pod還可以包括init容器。如容器名所示,它們可以用來初始化pod,這通常意味着向容器的存儲卷中寫入資料,然後将這個存儲卷挂載到主容器中。

  一個pod可以擁有任意數量的init容器。init容器是順序執行的,并且僅當最後一個init容器執行完畢才會去啟動主容器。換句話說,init容器也可以用來延遲pod的主容器的啟動——例如,直到滿足某一個條件的時候。init容器可以一直等待直到主容器所依賴的服務啟動完成并可以提供服務。當這個服務啟動并且可以提供服務之後,init容器就執行結束了,然後主容器就可以啟動了。這樣主容器就不會發生在所依賴服務準備好之前使用它的情況了。

  下面讓來看一個pod使用init容器來延遲主容器啟動的例子。這裡有一個名叫fortune的pod。它是一個能夠傳回給用戶端請求一個人生格言作為響應的web服務。現在假設有一個叫作fortune-client的pod,它的主容器需要依賴fortune服務先啟動并且運作之後才能啟動。可以給fortune-client的pod添加一個init容器,這個容器主要檢查發送給fortune服務的請求是否被響應。如果沒有響應,那麼這個init容器将一直重試。當這個init容器獲得響應之後,它的執行就結束了然後讓主容器啟動。

  将init容器加入pod

  init容器可以在pod spec檔案中像主容器那樣定義,不過是通過字段spec.initContainers來定義的。下面的代碼清單展示了init容器定義的部分。

#代碼17.2 pod中定義的init容器:fortune-client.yaml
apiVersion: v1
kind: Pod
metadata:
  name: fortune-client
spec:
  initContainers:          #在定義一個init容器,而不是正常容器
  - name: init
    image: busybox
    command:
    - sh
    - -c
    - 'while true; do echo "Waiting for fortune service to come up..."; wget http://fortune -q -T 1 -O /dev/null >/dev/null 2>/dev/null && break; sleep 1; done; echo "Service is up! Starting main container."'             #init容器運作一個循環并且在fortune服務啟動之後循環才退出
  containers:
  - image: busybox
    name: main
    command:
    - sh
    - -c
    - 'echo "Main container started. Reading fortune very 10 seconds."; while true; do echo "-------------"; wget -q -O - http://fortune; sleep 10; done'      

  當部署這個pod的時候,隻有pod的init容器會啟動起來。這個可以通過指令kubectl get檢視pod的狀态來展示:

$ kubectl get po
NAME             READY    STATUS    RESTARTS    AGE
fortune-client    0/1     init:0/1     0        1m      

  STATUS列展示了目前沒有init容器執行完畢。可以通過kubectl logs指令來檢視init容器的日志:

$ kubectl logs fortune-client -c init
Waiting for      

  當運作kubectl logs指令的時候,需要通過選項-c來指定init容器的名稱(在這個例子中,pod的init容器的名稱就叫作init,如代碼清單17.2所示)。

  主容器直到部署的fortune服務和fortune-server pod啟動之後才會運作。這些配置内容都在檔案fortune-sever.yaml中。

  處理pod内部依賴的最佳實踐

  己經了解如何通過init容器來延遲pod主容器的啟動,直到預置的條件被滿足(例如,為了確定pod所依賴的服務己經準備好),但是更佳的情況是建構一個不需要它所依賴的服務都準備好後才能啟動的應用。畢竟,這些服務在後面也有可能下線,但是這個時候應用己經在運作中了。

  應用需要自身能夠應對它所依賴的服務沒有準備好的情況。另外不要忘了Readiness探針。如果一個應用在其中一個依賴缺失的情況下無法工作,那麼它需要通過它的Readiness探針來通知這個情況,這樣Kubernetes也會知道這個應用沒有準備好。需要這樣做的原因不僅僅是因為這個就緒探針收到的信号會阻止應用成為一個服務端點,另外還因為Deployment控制器在滾動更新的時候會使用應用的就緒探針,是以可以避免錯誤版本的出現。

1.4 增加生命周期鈎子

  己經讨論了如果使用init容器來介入pod的啟動過程,另外pod還允許定義兩種類型的生命周期鈎子:

  • 啟動後(Post-start)鈎子
  • 停止前(Pre-stop)鈎子

  這些生命周期的鈎子是基于每個容器來指定的,和init容器不同的是,init容器是應用到整個pod。這些鈎子,如它們的名字所示,是在容器啟動後和停止前執行的。生命周期鈎子與存活探針和就緒探針相似的是它們都可以:

  • 在容器内部執行一個指令
  • 向一個URL發送HTTP GET請求

  分别來看一下這兩個鈎子,看看它們是如何在容器的生命周期中起作用的。

  使用啟動後容器生命周期鈎子

  啟動後鈎子是在容器的主程序啟動之後立即執行的。可以用它在應用啟動時做一些額外的工作。當然,如果你是容器中運作的應用的開發者,可以在應用的代碼中加入這些操作。但是,如果在運作一個其他人開發的應用,大部分情況下并不想(或者無法)修改它的源代碼。啟動後鈎子可以讓在不改動應用的情況下,運作一些額外的指令。這些指令可能包括向外部監聽器發送應用己啟動的信号,或者是初始化應用以使得應用能夠順利運作。

  這個鈎子和主程序是并行執行的。鈎子的名稱或許有誤導性,因為它并不是等到主程序完全啟動後(如果這個程序有一個初始化的過程,Kubelet顯然不會等待這個過程完成,因為它并不知道什麼時候會完成)才執行的。

  即使鈎子是以異步方式運作的,它确實通過兩種方式來影響容器。在鈎子執行完畢之前,容器會一直停留在Waiting狀态,其原因是ContainerCreating。是以,pod的狀态會是Pending而不是Running。如果鈎子運作失敗或者傳回了非零的狀态碼,主容器會被殺死。

  一個包含啟動後鈎子的pod manifest内容如下面的代碼所示。

#代碼17.3 一個包含啟動後生命周期鈎子的pod: post-start-hook.yaml
apiVersion: v1
kind: Pod
metadata:
  name: pod-with-poststart-hook
spec:
  containers:
  - image: luksa/kubia
    name: kubia
    lifecycle:              #鈎子是在容器啟動時執行的
      postStart:
        exec:               #它在容器内部執行/bin目錄下的postStart.sh腳本
          command: 
          - sh
          - -c
          - "echo 'hook will fail with exit code 15'; sleep 5 ; exit 15"      

  在這個例子中,指令echo、sleep和exit是在容器建立時和容器的主程序一起執行的。典型情況下,并不會像這樣來執行指令,而是通過存儲在容器鏡像中的shell腳本或者二進制可執行檔案來運作。

  遺憾的是,如果鈎子程式啟動的程序将日志輸出到标準輸出終端,你将無法在任何地方看到它們。這樣就會導緻調試生命周期鈎子程式非常痛苦。如果鈎子程式失敗了,僅僅會在pod的事件中看到一個FailedPostStartHook的告警資訊(可以通過指令kubectl describe pod來檢視)。稍等一會兒,你就可以看到更多關于鈎子為什麼失敗的資訊,如下面的代碼清單所示。

#代碼17.4 pod的事件顯示了基于指令的鈎子程式的退出碼
FailedSync Error syncing pod, skipping: failed to "StartContainer" for 
     "kubia" with Poststart handler: command 'sh -c echo 'hook 
     will fail with exit code 15'; sleep 5; exit 15 ' exited
               with 15: : "PostStartHook Failed"      

  最後一行的數字15就是指令的退出碼。當使用HTTP GET請求作為鈎子的時候,失敗原因可能類似于如下代碼清單。

#HTTPGET
apiVersion: v1
kind: Pod
metadata:
  name: pod-with-poststart-hook
spec:
  containers:
  - image: luksa/kubia
    name: kubia
    ports:
    - containerPort: 8080
      protocol: TCP
    lifecycle:
      postStart:
        httpGet:
          port: 9090
          path: postStart
#代碼17.5 pod的事件顯示了基于HTTP GET的鈎子程式的失敗原因
FailedSync Error syncing pod, skipping: failed to "StartContainer" for 
                 "kubia" with PostStart handler: Get
                  http://10.32.0.2:9090/postStart: dial tcp 10.32.0.2:9090: 
                  getsockopt: connection refused: "PostStart Hook Failed"      

  注意:這個啟動後鈎子是故意地使用錯誤的端口9090而不是正确的端口8080來示範鈎子失敗時會發生什麼情況的。

  基于指令的啟動後鈎子輸出到标準輸出終端和錯誤輸出終端的内容在任何地方都不會記錄,是以或許想把鈎子程式的程序輸出記錄到容器的檔案系統檔案中,這樣可以通過如下的指令來檢視檔案的内容:

$ kubectl exec      

  如果容器因為各種原因重新開機了(包括由于鈎子執行失敗導緻的),這個檔案在你能夠檢視之前就消失了。這種情況下,可以通過給容器挂載一個emptyDir卷,并且讓鈎子程式向這個存儲卷寫入内容來解決。

  使用停止前容器生命周期鈎子

  停止前鈎子是在容器被終止之前立即執行的。當一個容器需要終止運作的時候,Kubelet在配置了停止前鈎子的時候就會執行這個停止前鈎子,并且僅在執行完鈎子程式後才會向容器程序發送SIGTERM信号(如果這個程序沒有優雅地終止運作,則會被殺死)。

  停止前鈎子在容器收到SIGTERM信号後沒有優雅地關閉的時候,可以利用它來觸發容器以優雅的方式關閉。這些鈎子也可以在容器終止之前執行任意的操作,并且并不需要在應用内部實作這些操作(當在運作一個第三方應用,并且在無法通路應用或者修改應用源碼的情況下很有用)。

  在pod的manifest中配置停止前鈎子和增加一個啟動後鈎子方法差不多。上面的例子示範了執行指令的啟動後鈎子,這裡來看看執行一個HTTPGET請求的停止前鈎子。下面的代碼清單示範了如何在pod中定義一個停止前HTTPGET的鈎子。

#代碼17.6 停止前鈎子的YAML配置片段:pre-stop-hook-httpget.yaml
lifecycle:                     #這是一個執行HTTP GET請求的停止前鈎子
  prestop:
    httpGet:
      port: 8080              #這個請求發送到http://POD_IP:8080/shutdown      

  這個代碼中定義的停止前鈎子在Kubelet開始終止容器的時候就立即執行到​​http://pod_IP:8080​​ shutdown的HTTPGET請求。除了代碼清單中所示的port和path,還可以設定scheme(HTTP或HTTPS)和host,當然也可以設定發送出去的請求的httpHeaders。預設情況下,host的值是pod的IP位址。確定請求不會發送到localhost,因為localhost表示節點,而不是pod。

  和啟動後鈎子不同的是,無論鈎子執行是否成功容器都會被終止。無論是HTTP傳回的錯誤狀态碼或者基于指令的鈎子傳回的非零退出碼都不會阻止容器的終止。如果停止前鈎子執行失敗了,會在pod的事件中看到一個FailedPreStopHook的告警,但是因為pod不久就會被删除了(畢竟是pod的删除動作觸發的停止前鈎子的執行),你或許都看不到停止前鈎子執行失敗了。

  在應用沒有收到SIGTERM信号時使用停止前鈎子

  很多開發者在定義停止前鈎子的時候會犯錯誤,他們在鈎子中隻向應用發送了SIGTERM信号。他們這樣做是因為他們沒有看到他們的應用接收到Kubelet發送的SIGTERM信号。應用沒有接收到信号的原因并不是Kubernetes沒有發送信号,而是因為在容器内部信号沒有被傳遞給應用的程序。如果你的容器鏡像配置是通過執行一個shell程序,然後在shell程序内部執行應用程序,那麼這個信号就被這個shell程序吞沒了,這樣就不會傳遞給子程序。

  在這種情況下,合理的做法是讓shell程序傳遞這個信号給應用程序,而不是添加一個停止前鈎子來發送信号給應用程序。可以通過在作為主程序執行的shell程序内處理信号并把它傳遞給應用程序的方式來實作。或者如果你無法配置容器鏡像執行shell程序,而是通過直接運作應用的二進制檔案,可以通過在Dockerfile中使用ENTRYPOINT或者CMD的exec方式來實作,即ENTRYPOINT ["/mybinary"] 而不是ENTRYPOINT /mybinary。

  在通過第一種方式運作二進制檔案mybinary的容器中,這個程序就是容器的主程序,而在第二種方式中,是先運作一個shell作為主程序,然後mybinary程序作為shell程序的子程序運作。

  了解生命周期鈎子是針對容器而不是pod

  作為對啟動後和停止前鈎子最後的思考,強調的是這些生命周期的鈎子是針對容器而不是pod的。不應該使用停止前鈎子來運作那些需要在pod終止的時候執行的操作。原因是停止前鈎子隻會在容器被終止前調用(大部分可能是因為存活探針失敗導緻的終止)。這個過程會在pod的生命周期中發生多次,而不僅僅是在pod被關閉的時候。

1.5 了解pod的關閉

  己經接觸過關于pod終止的話題,是以這裡會進一步探讨相關細節來看看pod關閉的時候具體發生了什麼。這個對了解如何幹淨地關閉pod中運作的應用很重要。

  從頭開始,pod的關閉是通過API伺服器删除pod的對象來觸發的。當接收到HTTP DELETE請求後,API伺服器還沒有删除pod對象,而是給pod設定一個deletionTimestamp值。擁有deletionTimestamp的pod就開始停止了。

  當Kubelet意識到需要終止pod的時候,它開始終止pod中的每個容器。Kubelet會給每個容器一定的時間來優雅地停止。這個時間叫作終止寬限期(Terniination GracePeriod),每個pod可以單獨配置。在終止程序開始之後,計時器就開始計時,接着按照順序執行以下事件:

1. 執行停止前鈎子(如果配置了的話),然後等待它執行完畢

2. 向容器的主程序發送SIGTERM信号

3. 等待容器優雅地關閉或者等待終止寬限期逾時

4. 如果容器主程序沒有優雅地關閉,使用SIGKILL信号強制終止程序

  指定終止寬限期

  終止寬限期可以通過pod spec中的spec.terminationGracePeriod Periods字段來設定。預設情況下,值為30,表示容器在被強制終止之前會有30秒的時間來自行優雅地終止。

  提示:應該将終止寬限時間設定得足夠長,這樣容器程序才可以在這個時間段内完成清理工作。

  在删除pod的時候,pod spec中指定的終止寬限時間也可以通過如下方式來覆寫:

$ kubectl delete po mypod --grace-period=5      

  這個指令将會讓Kubectl等待5秒鐘,讓pod自行關閉。當pod所有的容器都停止後,Kubelet會通知API伺服器,然後pod資源最終都會被删除。可以強制API伺服器立即删除pod資源,而不用等待确認。可以通過設定寬限時間為0,然後增加一個--force選項來實作:

$ kubectl delete po mypod --grace-period=0 --force      

  在使用這個選項的時候需要注意,尤其是StatefulSet的pod。StatefulSet控制器會非常小心地避免在同一時間運作相同pod的兩個執行個體(兩個pod擁有相同的序号、名稱,并且挂載到相同的PersistentVolume)。強制删除一個pod會導緻控制器不會等待被删的pod裡面的容器完成關閉就建立一個替代的pod。換句話說,相同pod的兩個執行個體可能在同一時間運作,這樣會導緻有狀态的叢集服務工作異常。隻有在确認pod不會再運作,或者無法和叢集中的其他成員通信(可以通過托管pod的節點網絡連接配接失敗并且無法重連來确認)的情況下再強制删除有狀态的pod。

  現在己經了解了容器關閉的方式,接下來從應用的角度來看一下應用應該如何處理容器的關閉流程。

  在應用中合理地處理容器關閉操作

  應用應該通過啟動關閉流程來響應SIGTERM信号,并且在流程結束後終止運作。除了處理SIGTERM信号,應用還可以通過停止前鈎子來收到關閉通知。在這兩種情況下,應用隻有固定的時間來幹淨地終止運作。

  但是如果無法預測應用需要多長時間來幹淨地終止運作怎麼辦呢?例如,假設你的應用是一個分布式資料存儲。在縮容的時候,其中一個pod的執行個體會被删除然後關閉。在這個關閉的過程中,這個pod需要将它的資料遷移到其他存活的pod上面以確定資料不會丢失。那麼這個pod是否應該在接收到終止信号的時候就開始遷移資料(無論是通過SIGTERM信号還是停止前鈎子)?

  完全不是!這種做法是不推薦的,理由至少有兩點:

  • 一個容器終止運作并不一定代表整個pod被終止了。
  • 你無法保證這個關閉流程能夠在程序被殺死之前執行完畢。

  第二種場景不僅會在應用在超過終止寬限期還沒有優雅地關閉時發生,還會在容器關閉過程中運作pod的節點出現故障時發生。即使這個時候節點又重新開機了,Kubelet不會重新開機容器的關閉流程(甚至都不會再啟動這個容器了)。這樣就無法保證pod可以完成它整個關閉的流程。

  将重要的關閉流程替換為專注于關閉流程的pod

  如何确認一個必須運作完畢的重要的關閉流程真的運作完畢了呢(例如,确認一個pod的資料成功遷移到了另外一個pod) ?

  一個解決方案是讓應用(在接收到終止信号的時候)建立一個新的Job資源, 這個Job資源會運作一個新的pod,這個pod唯一的工作就是把被删除的pod的資料遷移到仍然存活的pod。但是如果注意到的話,就會了解你無法保證應用每次都能夠成功建立這個Job對象。萬一當應用要去建立Job的時候節點出現故障呢?

  這個問題的合理的解決方案是用一個專門的持續運作中的pod來持續檢查是否存在孤立的資料。當這個pod發現孤立的資料的時候,它就可以把它們遷移到仍存活的pod。當然不一定是一個持續運作的pod,也可以使用CronJob資源來周期性地運作這個pod。

  你或許以為StatefulSet在這裡會有用處,但實際上并不是這樣。給StatefulSet縮容會導緻PersistentVolumeClaim處于孤立狀态,這會導緻存儲在PersistentVolumeClaim中的資料擱淺。當然,在後續的擴容過程中,PersisitentVolume會被附加到新的pod執行個體中,但是萬一這個擴容操作永遠不會發生(或者很久之後才會發生)呢?是以,當在使用StatefulSet的時候或許想運作一個資料遷移的pod。為了避免應用在更新過程中出現資料 遷移,專門用于資料遷移的pod可以在資料遷移之前配置一個等待時間,讓有狀态的pod有時間啟動起來。

2.確定所有的用戶端請求都得到了妥善處理

  已經了解清楚如何幹淨地關閉pod了。現在,從pod的用戶端角度來看看pod的生命周期(使用pod提供的服務的用戶端)。了解這一點很重要,如果你希望pod擴容或者縮容的時候用戶端不會遇到問題的話。

  毋庸贅言,你希望所有的用戶端請求都能夠得到妥善的處理。顯然不希望pod在啟動或者關閉過程中出現斷開連接配接的情況。Kubernetes本身并沒有避免這種事情的發生。你的應用需要遵循一些規則來避免遇到連接配接斷開的情況。首先,重點看一下如何在pod啟動的時候,確定所有的連接配接都被妥善處理了。

2.1 在pod啟動時避免用戶端連接配接斷開

  確定pod啟動的時候每個連接配接都被妥善處理很容易,隻要了解了服務和服務端點是如何工作的。當一個pod啟動的時候,它以服務端點的方式提供給所有的服務,這些服務的标簽選擇器和pod的标簽比對。pod需要發送信号給Kubernetes通知它自己已經準備好了。pod在準備好之後,它才能變成一個服務端點,否則無法接收任何用戶端的連接配接請求。

  如果在pod spec中沒有指定就緒探針,那麼pod總是被認為是準備好了的。當第一個kube-proxy在它的節點上面更新了iptables規則之後,并且第一個用戶端pod開始連接配接服務的時候,這個預設被認為是準備好了的pod幾乎會立即開始接收請求。如果應用這個時候還沒有準備好接收連接配接,那麼所有的用戶端都會看 到“連接配接被拒絕”一類的錯誤資訊。

  你需要做的是當且僅當你的應用準備好處理進來的請求的時候,才去讓就緒探針傳回成功。實踐第一步是添加一個指向應用根URL的HTTP GET請求的就緒探針。在很多情況下,這樣做就足夠了,免得還需要在應用中實作一個特殊的readiness endpoint。

2.2 在pod關閉時避免用戶端連接配接斷開

  現在來看一下在pod生命周期的另一端--當pod被删除,pod的容器被終止的時候會發生什麼。己經讨論過pod的容器應該如何在它們收到SIGTERM信号的時候幹淨地關閉(或者容器的停止前鈎子被執行的時候)。但是這就能確定所有的用戶端請求都被妥善處理了嗎?

  當應用接收到終止信号的時候應該如何做呢?它應該繼續接收請求麼?那些己經被接收但是還沒有處理完畢的請求該怎麼辦呢?那些打開的HTTP長連接配接(連接配接上己經沒有活躍的請求了)該怎麼辦呢?在回答這些問題之前,需要詳細地看一下當pod删除的時候,叢集中的一連串事件是如何發生的。

  了解pod删除時發生的一連串事件

  Kubernetes元件都是運作在不同機器上面的不同的程序。它們并不是在一個龐大的單一程序中。讓叢集中的所有元件同步到一緻的叢集狀态需要時間。通過pod删除時叢集中發生的一連串事件來探究一下真相。

  當API伺服器接收到删除pod的請求之後,它首先修改了etcd中的狀态并且把删除事件通知給觀察者。其中的兩個觀察者就是Kubelet和端點控制器(Endpoint Controller)。圖17.7展示了并行發生的兩串事件(用A或B辨別)。

Kubernetes在生産環境中的一些讨論

  在辨別為A的一串事件中,當Kubelet接收到pod應該被終止的通知的時候,它初始化了關閉動作序列(執行停止前鈎子,發送SIGTERM信号,等待一段時間,然後在容器沒有自我終止時強制殺死容器)。如果應用立即停止接收用戶端的請求以作為對SIGTERM信号的響應,那麼任何嘗試連接配接到應用的請求都會收到Connection Refused的錯誤。從pod被删除到發生這個情況的時間相對來說特别短,因為這是API伺服器和Kubelet之間的直接通信。

  那麼,再看看另外一串事件中發生了什麼--就是在pod被從iptables規則中移除之前的那些事件(圖中辨別為B的序列)。當端點控制器(在Kubernetes的控制台的Controller Manager中運作)接收到pod要被删除的通知時,它從所有pod所在的服務中移除了這個pod的服務端點。它通過向API伺服器發送REST請求來修改Endpoint API對象。然後API伺服器會通知所有的用戶端關注這個Endpoint對象。其中的一些觀察者都是運作在工作節點上面的kube-proxy服務。每個kube-proxy服務都會在自己的節點上更新iptables規則,以阻止新的連接配接被轉發到這些處于停止狀态的pod上。這裡一個重要的細節是,移除iptables規則對已存在的連接配接沒有影響——已經連接配接到pod的用戶端仍然可以通過這些連接配接向pod發送額外的請求。

  上面的兩串事件是并行發生的。最有可能的是,關閉pod中應用程序所消耗的時間比完成iptables規則更新所需要的時間稍微短一點。導緻iptables規則更新的那一串事件相對比較長(見圖17.8),因為這些事件必須先到達Endpoint控制器,然後Endpoint控制器向API伺服器發送新的請求,然後API伺服器必須修改kube-proxy,最後kube-proxy再修改iptables規則。存在一個很大的可能性是SIGTERM信号會在iptables規則更新到所有的節點之前發送出去。

  最終的結果是,在發送終止信号給pod之後,pod仍然可以接收用戶端請求。如果應用立即關閉服務端套接字,停止接收請求的話,這會導緻用戶端收到“連接配接被拒絕”一類的錯誤(這個情形和pod啟動時應用還無法立即接收請求,并且還沒有給pod定義一個就緒探針時發生的一樣)。

Kubernetes在生産環境中的一些讨論

  解決問題

  用Google搜尋這個問題的解決方案看上去就是給pod添加一個就緒探針來解決問題。假設所需要做的事情就是在pod接收到SIGTERM信号的時候就緒探針開始失敗。這會導緻pod從服務的端點中被移除。但是這個移除動作隻會在就緒探針持續性失敗一段時間後才會發生(可以在就緒探針的spec中配置),并且這個移除動作還是需要先到達kube-proxy然後iptables規則才會移除這個pod。

  實際上,就緒探針完全不影響這個過程。端點控制器在接收到pod要被删除(當pod spec中的deletionTimestamp字段不再是null)的通知的時候就會從Endpoint中移除pod。從那個時候開始,就緒探針的結果己經無關緊要了。

  那麼這個問題的合适的解決方案是什麼呢?如何保證所有的請求都被處理了呢?

  很明顯,pod必須在接收到終止信号之後仍然保持接收連接配接直到所有的kube-proxy完成了iptables規則的更新。當然,不僅僅是kube-proxy,這裡還會有Ingress控制器或者負載均衡器直接把請求轉發給pod而不經過Service。這也包括使用用戶端負載均衡的用戶端。為了確定不會有用戶端遇到連接配接斷開的情況,需要等到它們通知你它們不會再轉發請求給pod的時候。

  這是不可能的,因為這些元件分布在不同的機器上面。即使知道每一個元件的位置并且可以等到它們都來通知你可以關閉pod了,萬一其中有一個元件未響應呢?這個時候,需要等待這個回複多長時間?記住,在這個時間段内,延阻了關閉的過程。

  你可以做的唯一的合理的事情就是等待足夠長的時間讓所有的kube-proxy可以完成它們的工作。那麼多長時間才是足夠的呢?在大部分場景下,幾秒鐘應該就足夠了,但是無法保證每次都是足夠的。當API伺服器或者端點控制器過載的時候,通知到達kube-proxy的時間會更長。你無法完美地解決這個問題,了解這一點很重要,但是即使增加5秒或者10秒延遲也會極大提升使用者體驗。可以用長一點的延遲時間,但是别太長,因為這會導緻容器無法正常關閉,而且會導緻pod被删除很長一段時間後還顯示在清單裡面,這個會給删除pod的使用者帶來困擾。

  小結

  簡要概括一下,妥善關閉一個應用包括如下步驟:

  • 等待幾秒鐘,然後停止接收新的連接配接。
  • 關閉所有沒有請求過來的長連接配接。
  • 等待所有的請求都完成。
  • 然後完全關閉應用。

  為了了解這個過程中連接配接和請求都發生了什麼,看圖17.9。

Kubernetes在生産環境中的一些讨論

  這個過程不像程序接收到終止信号立即退出那麼簡單,真的值得這麼做嗎?這個取決于你。但是至少可以添加一個停止前鈎子來等待幾秒鐘再退出,或許就像下面代碼清單中所示的一樣。

#代碼17.7 用于避免連接配接斷開的停止前鈎子
lifecycle:
  preStop:
    exec:
      command:
      - sh
      - -c
      - "sleep 5"      

  這樣就不需要修改代碼了。如果應用己經能夠確定所有的進來的請求都得到了處理,那麼這個停止前鈎子帶來的等待己經足夠了。

作者:​​小家電維修​​

轉世燕還故榻,為你銜來二月的花。

繼續閱讀