0%

docker stop 或者 docker kill 不能停止容器

可能经常要重启容器,某些容器需要花费 10s 左右才能停止,这是为啥?有以下几种可能性:

  1. 容器中的进程没有收到 SIGTERM 信号。
  2. 容器中的进程收到了信号,但忽略了。
  3. 容器中应用的关闭时间确实就是这么长。

如果要构建一个新的 Docker 镜像,肯定希望镜像越小越好,这样它的下载和启动速度都很快,一般我们都会选择一个瘦了身的操作系统(例如 AlpineBusybox 等)作为基础镜像。

1
2
3
4
FROM alpine:3.7
COPY popcorn.sh .
RUN chmod +x popcorn.sh
ENTRYPOINT ["./popcorn.sh"]

问题就在这里,这些基础镜像的 init 系统也被抹掉了,这就是问题的根源!

init 系统有以下几个特点:

  • 它是系统的第一个进程,负责产生其他所有用户进程。
  • init 以守护进程方式存在,是所有其他进程的祖先。
  • 它主要负责:
    • 启动守护进程
    • 回收孤儿进程
    • 将操作系统信号转发给子进程

docker stop 主流程

  • docker 通过 containerd 向容器主进程发送SIGTERM(终止进程)信号后等待一段时间后(默认是10s,可以通过-t 参数来修改),如果从containerd 收到了容器退出消息,那么容器退出成功。

  • 如果超过等待的时间之后,还是没收到容器退出的消息,那么docker 将使用docker kill方式试图终止容器。

但是对于容器来说,init 系统进程并不是必须的,所以当我们停止容器的时候,docker 通过 containerd 向容器Pid 为 1 的进程发送 SIGTERM信号并不一定会被采纳。其实可以分为以下两种情况来说明:

  • 如果 PID是1 的进程是 init 进程:那么 PID是 1 会将 SIGTERM 信号转发给子进程,然后子进程开始关闭,最后容器终止

  • 如果 PID是1 的进程不是 init 进程:那么容器中的应用进程(Dockerfile 中的 ENTRYPOINT 或 CMD 指令指定的应用)的 PId 就是 1,应用进程直接负责响应 SIGTERM 信号。这个时候又分为两种情况

1,应用不处理 SIGTERM 信号:

应用没有监听 SIGTERM 信号,或者应用中没有事先处理 SIGTERM 信号的逻辑,应用就不会停止,容器也不会正常终止,会被 调用 docker kill 方式杀死(我们的程序目前就是这种)

2,容器停止时间很长:

运行命令 docker stop 之后,docker 会默认等待 10S(默认值,可以修改 docker stop -t 指令),如果 10s后容器还没有终止,docker 就会绕过容器应用直接向内核发送 SIGKILL,内核强行杀死应用,从而终止容器。

docker kill 主流程

  • docker 引擎通过containerd 使用 SIGKILL 发向容器主进程,等待一段时间后,如果从containerd收到容器退出消息,那么容器kill成功。

  • 在上一步中如果等待超时,Docker引擎将跳过 containerd 自己亲自动手通过kill系统调用向容器主进程发送 SIGKILL 信号。如果此时 kill 系统调用返回主进程不存在,那么 Docker Kill 成功。否则引擎将一直死等到 containerd 通过引擎,容器退出。

docker 中 PID 进程不能处理 SIGTERM 信号的危害

上面我们讲到如果容器内的 PID 进程不能处理 SIGTERM 信号的时候,docker 会等 10S(默认时间),然后调用 kill 去杀死容器的进程,其实这样会造成下面两个问题

进程不能正常终止

Linux 内核中其实会对 PID 1 进程发送特殊的信号量。一般情况下,当给一个进程发送信号时,内核会先检查是否有用户定义的处理函数,如果没有,就会回退到默认行为。例如使用 SIGTERM 直接杀死进程。然而,如果进程的 PID 是 1,那么内核就会特殊对待它。如果没有注册用户处理函数,内核不会回退到默认行为,什么也不做,换句话说,如果你的进程没有处理信号的函数,给他发送 SIGTERM 会一点效果也没有,这个我们在上面讲过了。

常见的使用是 docker run my-container script. 给 docker run 进程发送SIGTERM 信号会杀掉 docker run 进程,但是容器还在后台运行。

孤儿僵尸进程不能正常回收

当进程退出时,它会变成僵尸进程,直到它的父进程调用 wait() ( 或其变种 ) 的系统调用。process table 里面会把它的标记为 defunct 状态。一般情况下,父进程应该立即调用 wait(), 以防僵尸进程时间过长。

如果父进程在子进程之前退出,子进程会变成孤儿进程, 它的父进程会变成 PID 1。因此,init 进程就要对这些进程负责,并在适当的时候调用 wait() 方法。

但是,通常情况下,大部分进程不会处理偶然依附在自己进程上的随机子进程,所以在容器中,会出现许多僵尸进程。

解决容器进程收不到 SIGTERM 信号

通过上面的解释应该能明白,我们不能正常退出,或者等 10s 才能退出的主要原因就是 PID 1 的进程不能处理/不处理 SIGTERM 信号造成的,知道问题所在了,那么就好办了,有如下几种解决方案:

让你们公司的程序代码支持处理 SIGTERM 信号。

当我们 pid 1 的进程(自己公司的代码)能处理 SIGTERM 信号,那么这个问题不就解决了吗?比较推荐这种方式,但是涉及到开发有一定的开发量,还是我们自己先用下面的方式解决。

构建 docker 包的时候使用 exec 模式的 ENTRYPOINT 指令

docker 官方文档指出:

You can specify a plain string for the ENTRYPOINT and it will execute in /bin/sh -c. This form will use shell processing to substitute shell environment variables, and will ignore any CMD or docker run command line arguments. To ensure that docker stop will signal any long running ENTRYPOINT executable correctly, you need to remember to start it with exec:

你可以为ENTRYPOINT指定一个普通字符串,它将在/bin/sh -c中执行。这个形式将使用shell处理来替代shell环境变量,并且会忽略任何CMD或docker运行命令行参数。为了确保docker stop会正确地提示任何长期运行的ENTRYPOINT可执行文件,你需要记得用exec启动它。

使用方式很简单,我们只需要按照如下格式编写 Dockerfile 即可

ENTRYPOINT exec COMMAND param1 param2

以这种方式启动,exec 就会将 shell 进程替换为 COMMAND 进程,

但是这种方式还是需要程序支持 SIGTERM,所以不推荐

在容器中使用 init 进程

当上面两种情况我都不推荐的时候,那我们就只能用这种方式了。

在容器里面添加一个 init 系统,让他去处理 SIGTERM 信号。

如果容器中的应用默认无法处理 SIGTERM 信号,又不能修改代码,这时候方案 1 和 2 都行不通了,只能在容器中添加一个 init 系统。init 系统有很多种,这里推荐使用 tini,它是专用于容器的轻量级 init 系统,使用方法也很简单:

tini

1
2
3
4
FROM alpine:3.7
...
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--", "COMMAND"]

现在 tini 就是 PID 1,它会将收到的系统信号转发给子进程 COMMAND。

使用 tini 后应用还需要处理 SIGTERM 吗?

最后一个问题:如果移除 popcorn.sh 中对 SIGTERM 信号的处理逻辑,容器会在我们执行停止命令后立即终止吗?

答案是肯定的。在 Linux 系统中,PID 1 和其他进程不太一样,准确地说应该是 init 进程和其他进程不一样,它不会执行与接收到的信号相关的默认动作,必须在代码中明确实现捕获处理 SIGTERM 信号的逻辑,方案 1 和 2 干的就是这个事。

当一个进程为普通进程,只要他收到系统信号,就会执行与该信号相关的默认动作,不需要再代码中显示实现逻辑,因此容器可以优雅的终止,而不需要强制 kill

dumb-init

他也是一个小型的 init 服务,他启动一个子进程并转发所有接收到的信号量给子进程。而且不需要修改应用代码。

1
2
3
4
5
6
7
FROM alpine:3.7
...
RUN wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.0/dumb-init_1.2.0_amd64 &&
chmod +x /usr/local/bin/dumb-init
# Runs "/usr/bin/dumb-init -- /my/script --with --args"
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["/my/script", "--with", "--args"]

需要注意的一点是:

虽然现在 PID 1 进程不是应用进程了,应用的行为和在没有 init 进程时是一样的。如果应用进程死掉,那么 init进程也会死掉,并会清理所有其他的子进程。