1. 程式人生 > 實用技巧 >你的容器程式退出是自願的還是被自願的

你的容器程式退出是自願的還是被自願的

最近注意到有時候執行kubectl delete pod xxx 的時候,這條命令要很長時間才返回,用time 命令看一下 居然要32秒,簡直令人髮指. 本著不知道就問,問不到就放棄的精神來分析一下原因.

1 刪除pod 的背後發生了什麼?

簡單來講,當用刪除pod 的時候,實際上是給pod 傳送了一個SIGTERM訊號,告訴pod裡面PID為1的程序,給你一段時間,抓緊退出,類似kill -15, 超過這個時間閾值PID為1的程序還沒退出的話,那不好意思, 直接殺死,這個對應的就是kill -9,傳遞的訊號就是SIGKILL.
說到這,就解釋的通為什麼刪除一個pod要這個長時間了,明顯是pod裡面的程序沒有在規定時間退出,超時後被強殺了,再一看

官方文件 預設的時間是30秒,跟我們之前delete pod 花的時間差不多,好了 問題解決,文章到此結束,謝謝大家.
要是放在我剛工作的時候,排錯到這裡就結束了,但是作為一名資深重啟工程師,我們還要繼續研究來裝做上班時間很忙,提升KPI.

2 為什麼容器裡面PID 為1的程序沒有處理SIGTERM訊號

這裡再把問題細分一下,容器裡面的PID 為1的程序是誰?他為什麼不處理SIGTERM訊號?

2.1 容器中的PID為1的程序是誰?

在非容器環境,也就是Linux作業系統中,PID為1的是systemd程序,也是init程序,這個PID 使用者無法使用,但是在容器中PID為1的程序是可以指定的.
!!!!!下面這段話是

抄的!!!!!

對於容器來說,init 系統不是必須的,當你通過命令 docker stop mycontainer 來停止容器時,docker CLI 會將 TERM 訊號傳送給 mycontainer 的 PID 為 1 的程序。

如果 PID 1 是 init 程序 - 那麼 PID 1 會將 TERM 訊號轉發給子程序,然後子程序開始關閉,最後容器終止。
如果沒有 init 程序 - 那麼容器中的應用程序(Dockerfile 中的 ENTRYPOINT 或 CMD 指定的應用)就是 PID 1,應用程序直接負責響應 TERM 訊號。這時又分為兩種情況:
應用不處理 SIGTERM - 如果應用沒有監聽 SIGTERM 訊號,或者應用中沒有實現處理 SIGTERM 訊號的邏輯,應用就不會停止,容器也不會終止。
容器停止時間很長 - 執行命令 docker stop mycontainer 之後,Docker 會等待 10s,如果 10s 後容器還沒有終止,Docker 就會繞過容器應用直接向核心傳送 SIGKILL,核心會強行殺死應用,從而終止容器。

!!!!!上面這段話是抄的!!!!!
我們不會討論容器裡面有init程序的情況,在生產中不會這麼用,沒什麼意義.所以容器裡的PID為1的程序是由Dockerfile裡面的ENTRYPOINT或者CMD定義的.
這兩個的使用有什麼區別呢?

2.1.1 ENTRYPOINT 和CMD的使用

牆裂推薦看看官方文件,什麼部落格都不如官方文件來的準確.
我這邊只講重要的知識點.

  • 相同點
    都是定義容器啟動後執行的一條命令
    一個Dockerfile中,如果定義多個,只有最後一個生效,
    比如下面兩個Dockerfilebuild 出來的映象執行後跑的第一條命令就是sleep:
FROM ubuntu
ENTRYPOINT ["sleep", "1000"]
FROM ubuntu
CMD ["sleep", "1000"]
  • 不同點
    CMD 可作為ENTRYPOINT的引數
    CMD可以被在docker run 的時候傳遞的命令覆蓋,ENTRYPOINT不可以(為了方便debug,一般使用CMD)

重點來了!
CMDENTRYPOINT都有兩種格式:

#exec 格式
1. ENTRYPOINT ["executable", "param1", "param2"]
#exec格式最終會被解析成json陣列,所以這裡必須是雙引號! exec格式的命令在容器執行時的PID 就是1

#shell格式
2. ENTRYPOINT command param1 param2
# shell格式的命令在容器執行時,會先執行/bin/sh -c, 然後把ENTRYPOINT定義的命令作為/bin/sh -c 的引數,
#比如: 
FROM ubuntu
ENTRYPOINT sleep 1000
# ENTRYPOINT sleep 10000,在容器中的程序是/bin/sh -c slepp 10000
root@44aa8dfe6b74:/# ps aux
USER        PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root          1  0.1  0.0   2600   648 pts/0    Ss+  07:47   0:00 /bin/sh -c sleep 1000
root          6  0.0  0.0   2500   376 pts/0    S+   07:47   0:00 sleep 1000
root          7  0.6  0.0   4100  2160 pts/1    Ss   07:47   0:00 bash
root         14  0.0  0.0   5888  1476 pts/1    R+   07:47   0:00 ps aux
可以看出,使用shell格式的ENTRYPOINT,容器中PID 為1的程序是/bin/sh,我們的程式的PID為6,需要注意的是,/bin/sh -c 是不會傳遞unix訊號到我們的程式,當執行docker stop <container>
的時候,sleep程序是不會接收到SIGTERM訊號的,只能等待docker的10秒超時,然後傳送SIGKILL訊號強制刪除容器.

shell格式和exec格式的使用上ENTRYPOINTCMD並沒有什麼區別,我只是用ENTRYPOINT舉例,換成CMD是一樣的效果,推薦使用exec格式的CMD

2.2 容器中PID為1的程序為什麼不處理SIGTERM訊號?

上面我們講到shell格式的ENTRYPOINT 在容器啟動後不會傳遞SIGTERM訊號,所以我們用exec格式的,那麼exec格式的就一定能傳遞訊號了嗎?
話不多說,上程式碼,直接以生產中場景來介紹
場景1: 預設tomcat 映象

#在一個終端啟動tomcat 官方映象
docker run -it --rm tomcat
# 開啟另一個終端,
[root@localhost ~]# docker ps -l
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
0fcbf65caf6b        tomcat              "catalina.sh run"   4 seconds ago       Up 3 seconds        8080/tcp            elegant_tesla


從圖中可以看出PID為1的程序就是tomcat程序,由此可以推斷tomcat的Dockerfile使用的是exec格式.
來證明一下我們的推斷,

再去github 裡面檢視一下Dockerfile

# verify Tomcat Native is working properly
RUN set -eux; \
	nativeLines="$(catalina.sh configtest 2>&1)"; \
	nativeLines="$(echo "$nativeLines" | grep 'Apache Tomcat Native')"; \
	nativeLines="$(echo "$nativeLines" | sort -u)"; \
	if ! echo "$nativeLines" | grep -E 'INFO: Loaded( APR based)? Apache Tomcat Native library' >&2; then \
		echo >&2 "$nativeLines"; \
		exit 1; \
	fi

EXPOSE 8080
CMD ["catalina.sh", "run"]

很明顯,官方的tomcat:latest 映象用了exec格式的CMD,那麼PID為1的程序應該可以接收到SIGTERM訊號,並且優雅的退出

果然,容器退接收到了訊號,退出只用了0.3秒, 如過容器沒有接收到SIGTERM訊號的話,stop 容器應該在10秒以上
結論:
使用exec格式的CMD命令在容器中的PID為1,如果這個程序可以處理SIGTERM訊號,那麼在stop 容器時,容器可以優雅的退出

場景2 : 封裝預設tomcat 映象
線上使用tomcat映象的時候, 不會直接使用官方的,會以官方的為基礎映象,增加些自定義的東西,比如修改配置檔案,升級映象安裝包,整合監控,日誌元件等等
啟動命令也不會用預設的CMD裡面的,在啟動之前可能會配置一寫安全相關或者密碼相關的東西,比如從vault 獲取DB 密碼
這裡簡單模擬一下:

[root@localhost test]# ll
total 8
-rw-r--r-- 1 root root 47 Dec 25 10:59 Dockerfile
-rwxr-xr-x 1 root root 69 Dec 25 11:01 start.sh
[root@localhost test]# cat Dockerfile
FROM tomcat
COPY start.sh /
CMD ["/start.sh"]  #這裡的CMD 會覆蓋FROM 映象的Dockerfile中的CDM,如果tomcatDockerfile中用的是ENTRYPOINT,那麼這裡的CMD將會作為ENTRYPOINT的引數,所以還是推薦用CMD方便擴充套件
[root@localhost test]# cat start.sh
#!/bin/bash

echo "do job1"
/usr/local/tomcat/bin/catalina.sh run

程式碼很簡單,把tomcat官方預設映象做為基礎映象,把我們自定義的start.sh指令碼作為容器啟動命令,然後在start.sh裡做環境準備,然後啟動tomncat.

下面來build 映象並且啟動

開啟另外一個終端,檢視容器中的程序情況

可以看到,我們使用了exec格式的CMD,start.sh 程序的PID為1,那麼這個容器是否能接收或者處理SIGTERM訊號呢?

可以看出,docker stop命令用了10秒鐘以上,證明這個容器是被強制刪除的
為什麼?不合理啊?
我們上面說到,docker stop會向容器中PID為1的程序傳遞SIGTERM訊號,但是這個PID為1的程序是不是要能接收並且處理這個訊號呢,如果不能訊號,那等於沒傳.
再來看一眼start.sh

[root@localhost test]# cat start.sh
#!/bin/bash
echo "do job1"
/usr/local/tomcat/bin/catalina.sh run

並沒有任何接收處理訊號的程式碼, 按照docker官方文件的做法,可以在start.sh指令碼中用命令trap捕獲SIGTERM訊號,然後做相應的處理,有關trap的用法可參考這裡

下面是官方給的例子,

#!/bin/sh
# Note: I've written this using sh so it works in the busybox container too

# USE the trap if you need to also do manual cleanup after the service is stopped,
#     or need to start multiple services in the one container
trap "echo TRAPed signal" HUP INT QUIT TERM

# start service in background here
/usr/sbin/apachectl start

echo "[hit enter key to exit] or run 'docker stop <container>'"
read

# stop service and clean up here
echo "stopping apache"
/usr/sbin/apachectl stop

echo "exited $0"

不過這裡要說的是,在啟動指令碼中去捕獲訊號沒什麼意義,因為有Kubernetes大殺器,可以實現優雅退出容器.

總結
在官方映象之上修改如果自定義的啟動指令碼不能很好的處理SIGTERM訊號的話,容器仍然會被強制刪除,可以用trap去捕獲處理訊號,但是在kubernetes上有更好的元件處理這個問題

3 Kubernetes 中處理容器Graceful shutdown

3.1 復現30秒刪除容器問題

把上面build的映象放到Kubernetes裡面,yaml 如下

[root@localhost ~]# cat tomcat.yaml
apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2
kind: Deployment
metadata:
  name: tomcat-deployment
spec:
  selector:
    matchLabels:
      app: tomcat
  replicas: 1
  template:
    metadata:
      labels:
        app: tomcat
    spec:
      containers:
      - name: tomcat
        image: tomcat:exec
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: tomcat
spec:
  selector:
    app: tomcat
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

注意這裡的映象叫tomcat:exec,就是上面build的映象,exec就是tag,你需要改成你自己的tag.

#建立資源
[root@localhost ~]# kubectl apply -f tomcat.yaml
deployment.apps/tomcat-deployment created
service/tomcat created
#檢視資源
[root@localhost ~]# kubectl get pod
NAME                                 READY   STATUS    RESTARTS   AGE
tomcat-deployment-77764cb96d-75fqh   1/1     Running   0          3m26s

這個時候開啟另外一個終端,我們要實時檢視容器的狀態
首先獲取tomcat:execimage ID

然後 while true ; do docker ps -a|grep 6e3e4186246d; sleep 0.1; done,實時檢視pod狀態
要注意觀看,關鍵資訊稍縱即逝.
切回到原來的終端刪除pod

[root@localhost ~]# time kubectl delete po tomcat-deployment-77764cb96d-75fqh
pod "tomcat-deployment-77764cb96d-75fqh" deleted
real	0m32.389s
user	0m0.079s
sys	0m0.043s

另外一個終端捕獲的資訊

可以看出刪除pod用了32秒,同時這個容器的退出狀態碼為137, 137表示容器中的程序是被SIGKILL的,也就是強制刪除的.
狀態碼詳細介紹請看這裡

我們已經復現了了30秒刪除容器的問題,這個問題產生的原因有2點

  1. 容器start.sh沒有處理SIGTERM訊號
  2. Kubernetes沒有在pod的生命週期中做處理

下面我們來了解一下pod的生命週期

3.2 容器生命週期及preStop

上圖展示了一個pod的生命週期,這裡我們只關注preStop,它是在容器退出之前執行,並且只有在preSstop執行完成之後,PID為1的程序才能接受到SIGTERM訊號
換句話說,如果preStop中的命令執行時間超過Kubernetes中預設的terminationGracePeriodSeconds 30秒,那麼容器中PID為1的程序將不能接收到SIGTERM訊號,這個容器將會被SIGKILL強制刪除

官方介紹:

This hook is called immediately before a container is terminated due to an API request or management event such as liveness probe failure, 
preemption, resource contention and others. A call to the preStop hook fails if the container is already in terminated or completed state. 
It is blocking, meaning it is synchronous, so it must complete before the signal to stop the container can be sent. 
No parameters are passed to the handler.

我們來修改一下tomcat.yaml,增加preStop,再刪除容器,觀察它的退出狀態

[root@localhost ~]# cat tomcat.yaml
apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2
kind: Deployment
metadata:
  name: tomcat-deployment
spec:
  selector:
    matchLabels:
      app: tomcat
  replicas: 1
  template:
    metadata:
      labels:
        app: tomcat
    spec:
      containers:
      - name: tomcat
        image: tomcat:exec
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 8080
        lifecycle:
          preStop:
            exec:
              command: ['/usr/local/tomcat/bin/catalina.sh',"stop"]
---
apiVersion: v1
kind: Service
metadata:
  name: tomcat
spec:
  selector:
    app: tomcat
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

建立之前先刪除,當然也可以直接apply,會自動覆蓋原有的配置

[root@localhost ~]# kubectl delete -f tomcat.yaml
[root@localhost ~]# kubectl apply -f tomcat.yaml

再次刪除pod, 同時觀察容器的退出狀態碼

這次刪除pod只用了4秒鐘,容器退出的狀態碼為0,意思是該容器沒有附加的前臺程序,表明容器完成了它的工作正常退出了
不過這個0有點出乎我意料,難道是退出的姿勢不對?
preStop修改一下

        lifecycle:
          preStop:
            exec:
              command: ['sh','-c',"kill -15 $(ps axu |grep tomcat |grep -v grep |awk '{print $2}')"]

這次直接給kill -15的方式殺掉tomcat 程序,
檢視容器退出狀態碼

143,表示容器接收到了SIGTERM訊號,這個訊號是Kubernetes傳的嗎? 不是! 是我們定義的preStop裡面kill -15傳的
感興趣的可以把preStop裡面kill -15換成kill -9,試一下

那麼為什麼這個SIGTERM訊號用的是preSstop裡面的呢? 這就要研究一下刪除容器時究竟偷偷發生了什麼

總結:
preStop定義的命令是在容器退出前執行的,容器優雅的退出狀態碼是143,強制刪除的狀態碼是137,至於上面退出狀態碼是0,我猜測可能是catalina.sh導致的,需要再研究一下

3.3 刪除容器背後發生了什麼?

我們是不是需要知道pod建立是究竟建立了什麼, 才能知道要刪除什麼?
簡單列一下pod建立的過程:

  1. POST yaml 到API-server, API-server將其儲存到ETCD
  2. scheduler 經過一系列的演算法,選擇適合pod的節點, 將節點資訊彙報給API-server,存入ETCD
  3. 節點上的kubelet監聽到有pod被排程到自己節點,呼叫本機的CRI建立容器,繫結CNI(網路),掛載CSI(儲存)
  4. CNI為pod分配IP,kubelet將IP資訊彙報給API-server
  5. API-server更新pod狀態

如果pod屬於一個service

  1. kubelet 等待pod readiness 成功
  2. Endpoints 新增pod ip:port 列表
  3. Kube-proxy根據Endpoints變更,更新防火牆規則
  4. 其他使用Endpoints的資源更新做相應更新,如DNS,Ingress,Cloud Loadbalancer等等

我們將上面這些步驟分了兩類,
第一: 管事類, 如Endpoints,API-server,ETCD,kube-proxy,iptables和一些controller的資源物件變更
第二: 一線搬磚類 kubelet 所在節點資源的變更,主要是容器

那麼刪除pod也應該刪除這兩個分類裡面的資源,這裡要注意的點是,兩個分類的資源是同時刪除的

上圖中1 和2的關係就像負載均衡後面的節點,我們要把節點從負載均衡中移除,是先把節點從負載均衡列表中刪除,在刪除節點,如果順序錯了就會導致502

如果kubelet 刪除pod的時候,endpoint資源並沒有將pod的ip 從列表中剔除,這個時候仍然有流量到被分配到被刪除的pod的ip就會出問題了,一般情況下endpoint會在pod刪除之前完成變更
但是如果叢集的API server太繁忙,這個就不能保證了,怎麼辦? 等!

來看一下kubernetes中的Graceful shutdown

前文已經提到,kubernetes中的terminationGracePeriodSeconds 30秒是從preStop開始計算的,所以preStop的執行時間不能超過terminationGracePeriodSeconds的預設值

還記得上面說的退出狀態碼143的pod中,SIGTERM訊號是我們定義在preStop中的kill -15傳遞的疑問嗎?
當kubelet開始刪除pod的時候,會先執行preStop,只有執行完preStop才會向PID為1的程序傳遞SIGTERM訊號,但是我們的yaml中在preStop中就向程式傳遞了SIGTERM(kill -15)訊號,程式就優雅退出了,
根本走不到上圖中的Graceful shutdown這一步.

下面看一下優化後的tomcat.yaml

[root@localhost ~]# cat tomcat.yaml
apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2
kind: Deployment
metadata:
  name: tomcat-deployment
spec:
  selector:
    matchLabels:
      app: tomcat
  replicas: 1
  template:
    metadata:
      labels:
        app: tomcat
    spec:
      containers:
      - name: tomcat
        image: tomcat:exec
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 8080
        lifecycle:
          preStop:
            exec:
              command: ['sh','-c',"sleep 10 && kill -15 $(ps axu |grep tomcat |grep -v grep |awk '{print $2}')"]
---
apiVersion: v1
kind: Service
metadata:
  name: tomcat
spec:
  selector:
    app: tomcat
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

我們在preStop中加了sleep 10,然後再kill -15 容器中的程序,這樣給了10秒鐘的時間等待endpoint kube-proxy以及其他一下"管事的"更新資源物件.
需要說明的是,如果使用了exec格式的CMD,並且容器的啟動指令碼能夠處理SIGTERM訊號,那麼就不需要在preStop中加kill -15.
如果你的應用停止的時間超過預設的30s,可以在yaml中設定terminationGracePeriodSeconds
來看一張完整的圖

總結

推薦在Dockerfile中使用exec格式
可以通過容器的退出狀態碼檢視容器是否是graceful shutdown
容器中PID為了的程序需要能處理SIGTERM訊號,如果不能處理, 就要借用Kubernetes中的preStop
preStop的執行時間不能超過terminationGracePeriodSeconds的值,
為了防止pod在endpoint之前刪除,可以在preStopsleep一段時間

參考

https://www.openshift.com/blog/kubernetes-pods-life
https://learnk8s.io/graceful-shutdown
https://cloud.google.com/blog/products/gcp/kubernetes-best-practices-terminating-with-grace
https://www.cnblogs.com/ryanyangcs/p/13036095.html