乐趣区

你的-docker-stop它优雅吗

原文链接: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 中给进程发送 SIGTERMSIGINT 信号的时候,这些进程往往都能正确的处理。但是在 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 给我们的返回值

根据这张图,我们可以看到:

  1. docker stop 命令等待了 10s 才结束
  2. 结束的 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 模式

DockerfileENTRYPOINT 有两种写法,即 execshell

# 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 都能让进程接收到信号,执行相应的操作后退出:

这张图我们可以看到很多信息:

  1. docker stop 命令很快结束,没有等待十秒
  2. 容器退出收到的信号是 SIGTERM,不是 SIGKILL
  3. Spring 进程的最后一行日志是 shutdown hook 的日志

这些信息表明,java 进程收到了 docker stop 发送的 SIGTERM 信号,并且正确的触发了相关操作,最后退出程序。

使用 trap

exec 命令在这样的场景下算是一个比较完美的方案。
但如果你还想探索一下其他方式,或者你的容器中需要运行多个进程,那我们可以接着来看看 trap 命令。

trap 是用来设置陷阱、监听 signalshell 命令,一般用来处理脚本收到的 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> 在脚本中监听 SIGTERMSIGINT 信号,然后执行 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 命令,在上面 bashtrap 的解释后面,还有一句话:

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 命令发挥了作用。

总结

  1. ctrl+cdocker stop 都只会向容器中 PID 1 进程发送信号
  2. docker stop 默认等待 10s 没有关闭容器后,会向内核发送 SIGKILL 以强制关闭容器
  3. 解决方案:

    1. 直接启动进程时,使用 ENTRYPOINTexec form
    2. 启动单一进程,并且需要一点准备工作时,使用 exec 命令
    3. 启动多个进程时,组合使用 trapwaitkill 命令
退出移动版