你的容器程序退出是自愿的还是被自愿的
最近注意到有时候执行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)
重点来了!
CMD和ENTRYPOINT都有两种格式:
#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格式的使用上ENTRYPOINT和CMD并没有什么区别,我只是用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:exec的 image 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点
- 容器
start.sh没有处理SIGTERM信号 - 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创建的过程:
- POST yaml 到API-server, API-server将其存储到ETCD
- scheduler 经过一系列的算法,选择适合pod的节点, 将节点信息汇报给API-server,存入ETCD
- 节点上的kubelet监听到有pod被调度到自己节点,调用本机的CRI创建容器,绑定CNI(网络),挂载CSI(存储)
- CNI为pod分配IP,kubelet将IP信息汇报给API-server
- API-server更新pod状态
如果pod属于一个service
- kubelet 等待pod readiness 成功
- Endpoints 添加pod ip:port 列表
- Kube-proxy根据Endpoints变更,更新防火墙规则
- 其他使用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之前删除,可以在preStop中sleep一段时间
参考
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

浙公网安备 33010602011771号