(译)Docker 中的 PID-1、孤儿、僵尸和信号

共 7645字,需浏览 16分钟

 ·

2020-12-18 09:53

使用 Docker 的时候,在多进程、信号方面会有一些边缘用例。在 Phusion 博客上有一篇相关文章,后续内容中会尝试接触这些问题,并使用 fpco/pid1 解决问题。

Phusion 博文中试用了他们的 基础镜像。这个镜像提供了 my_init 作为 entrypoint 来解决问题,同时还提供了 syslog 之类的额外的功能。不幸的是我们在使用其中的 syslog-ng 时遇到了麻烦,会产生占用 100% CPU 且无法杀死的进程。我们还在调查其根本原因,但在实践中我们发现,一个简单的 init 是更加迫切的需求,因此我们创建了 pid1 Haskell 包 和一个 Docker 镜像 fpco/pid1

建议读者阅读本文的同时打开终端运行命令,以求获得最大收益。看到一个 Ctrl+C 无法杀死的进程会让人更有动力。

我们用 Haskell 自行实现的目的是嵌入到 Stack build tool 之中。还有一些其它轻量级初始化进程,例如 dumb-init。我也写了关于 dumb-init 的文章。这里用的 pid1 跟其它的初始化进程之间没有什么差别。

和 Entrypoint 一起玩耍

Docker 有个 Entrypoint 的概念,其中对使用 docker run 运行容器时的命令进行缺省封装。例如下面的情况:

docker run --entrypoint /usr/bin/env ubuntu:16.04 FOO=BAR bash -c 'echo $FOO'
BAR

与之等价的命令是 docker run ubuntu:16.04 /usr/bin/env FOO=BAR bash -c 'echo $FOO'

这两个等价的命令展示了在命令行中替代 Entrypoint 的情况。后面还会在 Dockerfile 中进行指定。Ubuntu 镜像的缺省 entrypoint 是空的,也就是说命令部分不会经过任何封装,直接运行。因为目前版本的 Docker 还不支持将 entrypoint 设置为空,所以我们准备使用 /usr/bin/env 作为 entrypoint 来模拟这种状况。当运行 /usr/bin/env foo bar baz 时,env 进程会执行 foo 命令,foo 会变成新的 PID 1,这样的运行结果是和空的 entrypoint 是一致的。

fpco/pid1snoyberg/docker-testing 都会把 /sbin/pid1 作为缺省的 entrypoint。在示例命令中,为了清晰的示范,我们显式地使用了 --entrypoint /sbin/pid1,实际上去掉这个选项,也会是同样的效果。

向进程发送 TERM 信号

我们会以 sigterm.hs 命令开始,这个命令会执行 ps,然后给自己发送一个 SIGTERM,持续循环。在 Unix 系统中,进程收到 SIGTERM 的缺省操作就是退出。因此我们推测我们的进程应该启动之后直接退出,实际情况:

$ docker run --rm --entrypoint /usr/bin/env snoyberg/docker-testing sigterm
PID TTY TIME CMD
1 ? 00:00:00 sigterm
9 ? 00:00:00 ps
Still alive!
Still alive!
Still alive!
^C
$

该进程忽略了 SIGTERM 保持运行,直到我们手工输入了 Ctrl+C。这个脚本还有个功能就是,如果使用了 install-handler 参数,就会显式地安装一个 SIGTERM 的接收器,用于杀死进程。使用这个参数之后情况就不同了:

$ docker run --rm --entrypoint /usr/bin/env snoyberg/docker-testing sigterm install-handler
PID TTY TIME CMD
1 ? 00:00:00 sigterm
8 ? 00:00:00 ps
Still alive!
$

这个结果涉及到 Linux 内核:内核对 PID 1 是另眼相看的,缺省情况下收到 SIGTERM 或者 SIGINT 信号不会杀死进程。这个情况让人很不习惯。下一个测试中,使用两个不同的终端分别执行命令:

$ docker run --rm --name sleeper ubuntu:16.04 sleep 100
$ docker kill -s TERM sleeper

我们会看到,docker run 命令并没退出,如果检查一下 ps aux 的输出,会看到这个进程还在运行。原因是 sleep 进程没有针对 PID 1 的场景进行设计,也就是说没有专门设置信号处理工作,要正确响应信号,有两个选择:

  • 确保 docker run 运行的命令显式地处理 SIGTERM

  • 让命令的 PID 不为 1,用设计了信号处理的应用来充当 PID 1 的角色。

看看 sigterm 程序在使用 /sbin/pid1 作为 entrypoint 时候的表现:

$ docker run --rm --entrypoint /sbin/pid1 snoyberg/docker-testing sigterm
PID TTY TIME CMD
1 ? 00:00:00 pid1
8 ? 00:00:00 sigterm
12 ? 00:00:00 ps

程序如愿退出。但是看看 ps 的输出:第一个进程是 pid1,而不是 sigtermsigterm 这里的 PID 是 8,也就不会像 PID 为 1 时候的行为了,它会按照缺省行为处理 SIGTERM。这里的具体步骤是:

  1. 创建容器,并在其中执行 /usr/sbin/pid1 sigterm

  2. pid1 的 PID 为 1,并 fork/execsigterm

  3. sigterm 向自己发送了 SIGTERM,导致被杀。

  4. pid1 发现子进程被 SIGTERM 杀掉(sigal 15),并用 143 的返回码退出(128+15)

  5. PID 1 死掉,容器也就死掉了。

这并不是 sigterm 的特殊能力,sleep 也可以达到同样的目的:

$ docker run --rm --name sleeper fpco/pid1 sleep 100
$ docker kill -s TERM sleeper
...

ubuntu 镜像不同,fpco/pid1entrypointsbin/pid1,这个容器会被立刻杀掉。

sigterm 会给自己发送 TERM 信号(译注:只要它不是 PID1,就能正常退出,它退出之后,父进程也会退出),因此并不需要一个特别的 PID1 进程。例如可以直接运行 docker run --rm --entrypoint /usr/bin/env snoyberg/docker-testing /bin/bash -c "sigterm;echo bye",但是在 sleep 的情况下,就必须有能够正确处理信号的 PID1 了(译注:因为 docker kill 的信号是发给 PID1 的)。

Ctrl+C sigterm 和 sleep

sigtermsleep 在面对 Ctrl+C 的时候会不太一样。Ctrl+C 会发送 SIGINTdocker run 进程,它会把信号转发给容器内的信号。因为 Linux 内核的优待,sleep 也会忽略这个信号。然而 sigterm 是用 Haskell 编写的,Haskell 运行时自带一个包含 SIGINT 的信号处理过程,它会覆盖 PID1 进程的缺省行为。docker attach 文档中包含了更多关于信号转发的内容。

僵尸进程

假设有一个进程 A,A 会 exec/fork 进程 B。当进程 B 死掉时,进程 A 必须调用 waitpid,从内核获取进程 B 的退出状态,如果这个过程无法完成,进程 B 虽然死掉,但是还是会在系统进程表中留下一个记录。这种进程通常被称为僵尸。

orphans.hs 的行为:

  • 生成一个子进程,用死循环调用 ps

  • 在子进程中:

    运行 echo 命令多次,不调用 waitpid 然后退出。

如你所见,没有进程会回收成为僵尸的 echo 进程。进程输出的内容,会看到生成了僵尸:

$ docker run --rm --entrypoint /usr/bin/env snoyberg/docker-testing orphans
1
2
3
4
Still alive!
PID TTY TIME CMD
1 ? 00:00:00 orphans
8 ? 00:00:00 orphans
13 ? 00:00:00 echo
14 ? 00:00:00 echo
15 ? 00:00:00 echo
16 ? 00:00:00 echo
17 ? 00:00:00 ps
Still alive!
PID TTY TIME CMD
1 ? 00:00:00 orphans
13 ? 00:00:00 echo
14 ? 00:00:00 echo
15 ? 00:00:00 echo
16 ? 00:00:00 echo
18 ? 00:00:00 ps
Still alive!

这里看到了几个僵尸进程。原因是我们的 PID1 没有进行回收。你可能猜到,我们可以使用 /sbin/pid1 解决这个问题:

$ docker run --rm --entrypoint /sbin/pid1 snoyberg/docker-testing orphans
1
2
3
4
Still alive!
PID TTY TIME CMD
1 ? 00:00:00 pid1
10 ? 00:00:00 orphans
14 ? 00:00:00 orphans
19 ? 00:00:00 echo
20 ? 00:00:00 echo
21 ? 00:00:00 echo
22 ? 00:00:00 echo
23 ? 00:00:00 ps
Still alive!
PID TTY TIME CMD
1 ? 00:00:00 pid1
10 ? 00:00:00 orphans
24 ? 00:00:00 ps
Still alive!

pid1 会在子进程死掉时接收 echo 进程,并进行收割。

进程清理

我们来试点别的:A 进程是 Docker 容器的主进程,它生成了进程 B。如果 A 比 B 退出的早,会让 Docker 容器退出。这种情况下,运行中的进程 B 会被内核强制关闭(Stackoverflow 讨论了该问题的详情),我们可以通过 surviving.hs 来观察这个情况:

$ docker run --rm --entrypoint /usr/bin/env snoyberg/docker-testing surviving
Parent sleeping
Child: 1
Child: 2
Child: 4
Child: 3
Child: 1
Child: 2
Child: 3
Child: 4
Parent exiting

不幸的是,我们的子进程没机会进行清理。我们应该给他们发送一个 SIGTERM,在一段时间后发送 SIGKILLpid1 就是这么做的:

$ docker run --rm --entrypoint /sbin/pid1 snoyberg/docker-testing surviving
Parent sleeping
Child: 2
Child: 3
Child: 1
Child: 4
Child: 2
Child: 1
Child: 4
Child: 3
Parent exiting
Got a TERM
Got a TERM
Got a TERM
Got a TERM

Docker Run 和 PID1

如果运行 sleep 60,然后输入 Ctrl+Csleep 进程会收到 SIGINT。如果运行 docker run --rm fpco/pid1 sleep 60,再输入 Ctrl+C,事情就不同了。docker run 创建了一个 docker run 进程,它会给 Docker 服务发送一个命令,这个服务会在容器里创建真正的 sleep 进程。在终端输入 Ctrl+C 的时候,SIGINT 会被发送给 docker run,最后转换成 sleep 进程的 SIGINT

如何证明呢?

$ docker run --rm fpco/pid1 sleep 60&
[1] 417
$ kill -KILL $!
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
69fbc70e95e2 fpco/pid1 "/sbin/pid1 sleep 60" 11 seconds ago Up 11 seconds hopeful_mayer
[1]+ Killed docker run --rm fpco/pid1 sleep 60

这个案例中发送 SIGKILLdocker run,相对于 SIGINT 以及 SIGTERMSIGKILL 有些不同,docker run 无法转发这个信号,因此会杀掉自己,但是 sleep 进程和所在的容器会持续运行。

所以:

  • 用类似 pid1 的东西来保障 SIGINT 或者 SIGTERM 能够真正地停止容器。

  • 如果必须要给进程发送 SIGKILL,应该使用 docker kill

entrypoint 的替代方案

我们用了很多次 --entrypoint /sbin/pid1。实际上这很多余,fpco/pid1snoyberg/docker-testing 镜像的缺省 entrypoint 都是 /sbin/pid1

$ docker run --rm fpco/pid1 sleep 60
^C
$

如果嫌 entrypoint 麻烦,可以用在命令之中,例如:

$ docker run --rm --entrypoint /usr/bin/env fpco/pid1 /sbin/pid1 sleep 60
^C
$

Dockerfile,command vs exec

你可能想把 ENTRYPOINT /sbin/pid1 放到 Dockerfile 里,结果却不尽人意:

$ cat Dockerfile
FROM fpco/pid1
ENTRYPOINT /sbin/pid1
$ docker build --tag test .
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM fpco/pid1
---> aef1f7b702b9
Step 2 : ENTRYPOINT /sbin/pid1
---> Using cache
---> f875b43a9e40
Successfully built f875b43a9e40
$ docker run --rm test ps
pid1: No arguments provided

出现这个问题的原因是使用的 command 形式的方法,它只是定义了一个给 Shell 处理的原始字符串,无法加入额外的命令(例如 ps),这样一来,pid1 进程就没有了附加语句,无法运行。正确的定义形式是 ENTRYPOINT ["/sbin/pid1"]

$ cat Dockerfile
FROM fpco/pid1
ENTRYPOINT ["/sbin/pid1"]
$ docker build --tag test .
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM fpco/pid1
---> aef1f7b702b9
Step 2 : ENTRYPOINT /sbin/pid1
---> Running in ba0fa8c5bd41
---> 4835dec4aae6
Removing intermediate container ba0fa8c5bd41
Successfully built 4835dec4aae6
$ docker run --rm test ps
PID TTY TIME CMD
1 ? 00:00:00 pid1
8 ? 00:00:00 ps

尽量使用这种模式,可以避免对 shell 的需要。

结论

正常情况下,都需要使用一个 pid1 这样的初始化进程。Phusion/my_init 的方式是可行的,但是太过沉重。如果不需要 syslog 以及其他的特性,最好还是用一个最小化的选择。

浏览 31
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报