你的 docker stop,它优雅吗?
我们平时在使用 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 <command>
,不会执行在 PID 1 上,也就不会收到signal
所以,我们应该选择 exec
模式,让我们的程序成为 PID 1 进程。
ENTRYPOINT ["java", "-jar", "app.jar"]
更详细的信息,可以查看官方文档。
exec 命令
exec
形式的 ENTRYPOINT
只能解决 无需任何准备工作就启动进程 的场景,而不能解决一些需要准备工作的复杂场景。
在这样的场景中,我们的 ENTRYPOINT
往往需要执行一个 shell
脚本:
ENTRYPOINT ["./entrypoint.sh"]
然后在这个脚本中执行我们的准备工作,完成后再启动真正的进程。
比如上面的例子,做完准备后,启动 java
进程。
这时候,我们的 java
进程就无法成为 PID 1 进程。
我们可以看到,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 #(1)
表示在接收到
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
找到执行的进程,使用
kill
命令向其发送SIGTERM
在脚本中监听
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.
在我们的场景中,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)
后台执行
java
使用
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.
也就是说,虽然 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 $!
在
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
命令