原文链接:https://blog.gaoyuexiang.cn/2020/06/18/graceful-shutdown-docker-container/
我们平时在使用 Docker
的时候,一般会使用 ctrl+c
或者 docker stop
的方式关闭容器。但有时候我们可能会遇到 ctrl+c
不生效,或者 docker stop
之后要等待 10s 的情况,就像这样:
也许你会觉得 10s 是一个可以忍受的时间,但这样的问题真的只有 10s 这么简单吗?为什么有的时候不能立即关闭容器呢?
docker stop 怎么关闭容器
首先我们来看一下这两个命令做了什么。
ctrl+c
到底做了什么
我们能够使用 ctrl+c
的场景,是我们在使用 foreground
模式运行容器的时候。这时我们按下 ctrl+c
就像在普通的 shell
中按下这个组合键一样,发送一个 SIGINT
信号给当前的进程,通知它终止运行。
docker stop 做了什么
docker stop
命令是在对 detached
模式运行的容器发出停止命令时使用的,从发送信号上来讲,它将发送 SIGTERM
信号给容器,通知其结束运行。
SIGINT
一般用于关闭前台进程,SIGTERM
会要求进程自己正常退出。
当我们在 shell
中给进程发送 SIGTERM
和 SIGINT
信号的时候,这些进程往往都能正确的处理。但是在 docker
中却不灵了。这是因为在 docker
中,只会将 SIGTERM
等所有的 signal
信号发送给 PID 为 1 的进程,当我们 docker
中运行的进程的进程号不是 1 时,就不会收到这样的信号。
根据这篇文章的说法,只有
alpine
会出现这个问题,但从我搜到的资料和实验来看,并不是这样,而是所有的镜像都会有这个问题。
为什么是 10s
其实这只是 docker
的默认设置,如果你愿意,等十年都可以。
文档链接:https://docs.docker.com/engine/reference/commandline/stop/
如果达到上面的时间限制,docker
将会通过给内核发送 SIGKILL
从而强制结束容器。
验证上面的回答
为了验证上面查到的这些结果,我写了一点 demo。在 demo 的场景里,我们会在 ENTRYPOINT
配置运行一个 shell
脚本,在脚本中做一些准备工作后启动进程。
FROM openjdk:8-jre-alpine
COPY ./build/libs/app.jar /app/app.jar
COPY ./docker/entrypoint.sh /app/entrypoint.sh
WORKDIR /app
EXPOSE 8080
ENTRYPOINT ["./entrypoint.sh"]
#!/bin/sh
echo 'Do something'
java -jar app.jar
后面还会用到这个例子
我们可以观察一下 docker stop
之后,shell
给我们的返回值
根据这张图,我们可以看到:
-
docker stop
命令等待了 10s 才结束 - 结束的 docker container 返回了
137
,表示进程是因为内核接收到了SIGKILL
而结束的(zsh
给美化成了 KILL)
Kill 导致的问题
现在我们已经了解了 ctrl+c
为什么不生效和 docker stop
等待 10s 的原因了。
我们再来看看另一个问题:
强制关闭容器,真的就没问题吗?
或许你能想到,很多进程在结束阶段会做一些清理工作:比如删除临时目录、执行 shutdown hook 等。但是当进程被强制关闭时,这些任务就不会被执行,那么我们就可能得到一些并不期望的结果。
以 Eureka
为例。
Eureka client
在结束进程时,需要向 Eureka server
发送 shutdown 信号,以注销 client
。
这本来没什么问题,因为 Eureka server
即使没有收到这样的信息,也会定期清理 client
信息。
但是 Eureka server
还有一个 self preservation
模式,以防止意外的网络事件导致大量的 client
下线。
这就有可能导致 Eureka
集群的注册表中出现大量的 client
信息,但它们其实已经关闭了。
如何优雅的关闭容器
通过前面的内容,我们已经了解了容器没有被优雅关闭的原因和可能导致的问题,接下来,我们来看看如何解决。
前面提到的那篇文章中提到可以使用
tini
来解决,但我没有成功过。tini
的确做到了立即关闭进程,但是进程并没有执行 shutdown hook。
使目标进程成为 PID 1
既然 docker
只会将 sigal
发送给 PID 1 的进程,那就让我们的进程成为 PID 1 的进程就好了。
docker 的 exec 与 shell 模式
Dockerfile
的 ENTRYPOINT
有两种写法,即 exec
和 shell
:
# exec form
ENTRYPOINT ["command", "param"]
# shell form
ENTRYPOINT command param
两者的区别在于:
-
exec
形式的命令会使用 PID 1 的进程 -
shell
形式的命令会被执行为/bin/sh -c
,不会执行在 PID 1 上,也就不会收到signal
所以,我们应该选择 exec
模式,让我们的程序成为 PID 1 进程。
ENTRYPOINT ["java", "-jar", "app.jar"]
更详细的信息,可以查看官方文档。
exec 命令
exec
形式的 ENTRYPOINT
只能解决 无需任何准备工作就启动进程 的场景,而不能解决一些需要准备工作的复杂场景。
在这样的场景中,我们的 ENTRYPOINT
往往需要执行一个 shell
脚本:
ENTRYPOINT ["./entrypoint.sh"]
然后在这个脚本中执行我们的准备工作,完成后再启动真正的进程。
比如上面的例子,做完准备后,启动 java
进程。
这时候,我们的 java
进程就无法成为 PID 1 进程。
[图片上传失败...(image-a478c7-1592495229234)]
我们可以看到,java
进程的 PID 是 7,也就无法优雅退出了。
为了解决这个问题,我们可以使用 exec
命令来解决。这个命令的作用就是使用新的进程替代原有的进程,并保持 PID 不变。
这就意味着我们可以在执行 java
命令的时候使用它,从而替换掉 PID 1 的 shell 脚本:
#!/bin/sh
echo "Do something"
exec java -jar app.jar
我们再来看一下容器中的进程:
使用 exec
命令之后,我们无论是使用 ctrl+c
还是 docker stop
都能让进程接收到信号,执行相应的操作后退出:
这张图我们可以看到很多信息:
-
docker stop
命令很快结束,没有等待十秒 - 容器退出收到的信号是
SIGTERM
,不是SIGKILL
-
Spring
进程的最后一行日志是 shutdown hook 的日志
这些信息表明,java
进程收到了 docker stop
发送的 SIGTERM
信号,并且正确的触发了相关操作,最后退出程序。
使用 trap
exec
命令在这样的场景下算是一个比较完美的方案。
但如果你还想探索一下其他方式,或者你的容器中需要运行多个进程,那我们可以接着来看看 trap
命令。
trap
是用来设置陷阱、监听 signal
的 shell
命令,一般用来处理脚本收到的 signal
,完成一些操作。
trap [-lp] [[arg] sigspec ...]
本文不介绍
lp
参数的含义
-
arg
代表接收到某个信号后要执行的操作,是一个shell
命令 -
sigspec
表示监听的信号,可以是多个
举个:
trap 'echo "Shutting Down"' TERM
表示在接收到 SIGTERM
信号时输出 "Shutting Down"
添加 trap
简单了解了 trap
命令后,我们就可以来改造一下 entrypoint.sh
:
#!/bin/sh
echo 'Do something'
kill_jar() {
echo 'Received TERM'
kill "$(ps -ef | grep java | grep app | awk '{print $1}')" #<1>
}
trap 'kill_jar' TERM INT #<2>
java -jar app.jar
<1> 找到执行的进程,使用 kill
命令向其发送 SIGTERM
<2> 在脚本中监听 SIGTERM
和 SIGINT
信号,然后执行 kill_jar
函数
上面的脚本看起来可以正常工作,但实际上不能。
这是因为在 bash
中,即使 trap
收到了信号,如果这个时候 bash
在等待一个命令结束的话,
那么 trap
就会等到这个命令结束才会被执行。
If Bash is waiting for a command to complete and receives a signal for which a trap has been set, the trap will not be executed until the command completes.
-- https://www.gnu.org/software/bash/manual/html_node/Signals.html#Signals
在我们的场景中,bash
就在等待 java
进程结束,才能执行 trap
中的命令。
但是 java
进程又需要 trap
来关闭才能结束,所以程序陷入了循环依赖,只能 docker stop
等待 10s。
后台运行 java
既然前面的问题是 bash
在等待 java
进程结束,那么我们就让它不等待就好了——后台执行 java
:
#!/bin/sh
echo 'Do something'
kill_jar() {
echo 'Received TERM'
kill "$(ps -ef | grep java | grep app | awk '{print $1}')"
echo 'Process finished'
}
trap 'kill_jar' TERM INT
java -jar app.jar & #<1>
wait $! #<2>
<1> 后台执行 java
<2> 使用 wait
命令等待 java
进程结束,避免 entrypoint.sh
执行完成后容器直接退出
是不是觉得这样就 OK 了?
Naive,上面的这个脚本能够帮助我们立即结束容器,但并不会等待进程自己正常退出:
我们可以看到,kill_jar
方法中的 echo
被成功执行,但是却没有看到 Spring
的 shutdown hook 日志输出。
这说明容器没有等待程序正常退出就被关闭了。
这里其实有两个问题。
kill
的问题::
第一个是 kill
命令并不会等待进程结束,它只负责向进程发送 SIG
信号。
至于程序如何处理、什么时候处理,则与它无瓜。
wait
的问题::
第二个问题则是 wait
命令,在上面 bash
对 trap
的解释后面,还有一句话:
When Bash is waiting for an asynchronous command via the wait builtin, the reception of a signal for which a trap has been set will cause the wait builtin to return immediately with an exit status greater than 128, immediately after which the trap is executed.
-- https://www.gnu.org/software/bash/manual/html_node/Signals.html#Signals
也就是说,虽然 bash
在等待 wait
结束,但是 wait
又被特殊处理了
——trap
收到任何大于 128 的信号都会让 wait
命令结束,以执行 trap
中的方法。
综合以上两点,我们会发现 trap
在执行 kill_jar
时,entrypoint.sh
中的 wait
已经结束,不再等待 java
进程结束。
kill_jar
仅仅发送了 SIGTERM
信号,也不会等待 java
进程结束。
由此,我们就可以对脚本进行改进:
#!/bin/sh
echo 'Do something'
kill_jar() {
echo 'Received TERM'
kill "$(ps -ef | grep java | grep app | awk '{print $1}')"
wait $! #<1>
echo 'Process finished'
}
trap 'kill_jar' TERM INT
java -jar app.jar &
wait $!
<1> 在 kill
后加了一行 wait
,因为 kill
会返回进程号,所以这里也可以使用 $!
。
这样,我们的 kill_jar
就会等到 java
进程完全退出后才会结束:
我们可以看到,Spring
的 shutdown hook 在 "Process finished" 之前输出,证明新加的 wait
命令发挥了作用。
总结
-
ctrl+c
与docker stop
都只会向容器中 PID 1 进程发送信号 -
docker stop
默认等待 10s 没有关闭容器后,会向内核发送SIGKILL
以强制关闭容器 - 解决方案:
- 直接启动进程时,使用
ENTRYPOINT
的exec form
- 启动单一进程,并且需要一点准备工作时,使用
exec
命令 - 启动多个进程时,组合使用
trap
、wait
、kill
命令
- 直接启动进程时,使用