Docker 优雅终止方案
作为一名系统工程师,你可能经常需要重启容器,毕竟Kubernetes的优势就是快速弹性伸缩和故障恢复,遇到问题先重启容器再说,几秒钟即可恢复,实在不行再重启系统,这就是系统重启工程师的杀手锏。然而现实并没有理论上那么美好,某些容器需要花费10s左右才能停止,这是为啥?有以下几种可能性:
1.容器中的进程没有收到SIGTERM信号。
2.容器中的进程收到了信号,但忽略了。
3.容器中应用的关闭时间确实就是这么长。
对于第3种可能性我们无能为力,本文主要解决1和2。
如果要构建一个新的Docker镜像,肯定希望镜像越小越好,这样它的下载和启动速度都很快,一般我们都会选择一个瘦了身的操作系统(例如Alpine,Busybox等)作为基础镜像。
问题就在这里,这些基础镜像的init系统也被抹掉了,这就是问题的根源!
init系统有以下几个特点:
1.它是系统的第一个进程,负责产生其他所有用户进程。
2.init 以守护进程方式存在,是所有其他进程的祖先。
它主要负责:
1.启动守护进程
2.回收孤儿进程
3.将操作系统信号转发给子进程
1.Docker容器停止过程
对于容器来说,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,内核会强行杀死应用,从而终止容器。
2.容器进程收不到SIGTERM信号?
如果容器中的进程没有收到SIGTERM 信号,很有可能是因为应用进程不是 PID 1,PID 1 是 shell,而应用进程只是 shell 的子进程。而 shell 不具备 init 系统的功能,也就不会将操作系统的信号转发到子进程上,这也是容器中的应用没有收到 SIGTERM 信号的常见原因。
问题的根源就来自Dockerfile,例如:
FROM alpine:3.7 COPY popcorn.sh . RUN chmod +x popcorn.sh ENTRYPOINT ./popcorn.sh
ENTRYPOINT指令使用的是shell模式,这样Docker就会把应用放到shell中运行,因此shell是PID 1。
解决方案有以下几种:
1.使用exec模式的ENTRYPOINT指令
与其使用shell模式,不如使用exec模式,例如:
FROM alpine:3.7 COPY popcorn.sh . RUN chmod +x popcorn.sh ENTRYPOINT ["./popcorn.sh"]
这样PID 1就是./popcorn.sh,它将负责响应所有发送到容器的信号,至于./popcorn.sh 是否真的能捕捉到系统信号,那是另一回事。
举个例子,假设使用上面的Dockerfile来构建镜像,popcorn.sh脚本每过一秒打印一次日期:
#!/bin/sh while true do date sleep 1 done
构建镜像并创建容器:
🐳 → docker build -t truek8s/popcorn .
🐳 → docker run -it --name corny --rm truek8s/popcorn
打开另外一个终端执行停止容器的命令,并计时:
🐳 → time docker stop corny
因为popcorn.sh并没有实现捕获和处理SIGTERM信号的逻辑,所以需要10s左右才能停止容器。要想解决这个问题,就要往脚本中添加信号处理代码,让它捕获到SIGTERM信号时就终止进程:
#!/bin/sh # catch the TERM signal and then exit trap "exit" TERM while true do date sleep 1 done
注意:下面这条指令与shell模式的ENTRYPOINT指令是等效的:
ENTRYPOINT ["/bin/sh", "./popcorn.sh"]
2.直接使用exec命令
如果你就想使用shell模式的ENTRYPOINT 指令,也不是不可以,只需将启动命令追加到exec后面即可,例如:
FROM alpine:3.7 COPY popcorn.sh . RUN chmod +x popcorn.sh ENTRYPOINT exec ./popcorn.sh
这样exec就会将shell进程替换为./popcorn.sh进程,PID 1仍然是./popcorn.sh。
3.使用init系统
如果容器中的应用默认无法处理SIGTERM信号,又不能修改代码,这时候方案1和2都行不通了,只能在容器中添加一个init系统。init 系统有很多种,这里推荐使用tini,它是专用于容器的轻量级init系统,使用方法也很简单:
1.安装tini
2.将tini设为容器的默认应用
3.将popcorn.sh作为tini的参数
具体的Dockerfile如下:
FROM alpine:3.7 COPY popcorn.sh . RUN chmod +x popcorn.sh RUN apk add --no-cache tini ENTRYPOINT ["/sbin/tini", "--", "./popcorn.sh"]
现在tini就是PID 1,它会将收到的系统信号转发给子进程popcorn.sh。
注意: 如果你想直接通过docker命令来运行容器,可以直接通过参数--init来使用 tini,不需要在镜像中安装tini。如果是Kubernetes就不行了,还得老老实实安装tini。
3.使用tini后应用还需要处理SIGTERM吗?
最后一个问题:如果移除popcorn.sh中对SIGTERM信号的处理逻辑,容器会在我们执行停止命令后立即终止吗?
答案是肯定的。在Linux系统中,PID 1和其他进程不太一样,准确地说应该是init 进程和其他进程不一样,它不会执行与接收到的信号相关的默认动作,必须在代码中明确实现捕获处理SIGTERM信号的逻辑,方案1和2干的就是这个事。
普通进程就简单多了,只要它收到系统信号,就会执行与该信号相关的默认动作,不需要在代码中显示实现逻辑,因此可以优雅终止。