(译)Docker 中的 PID-1、孤儿、僵尸和信号
使用 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/pid1
和 snoyberg/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
,而不是 sigterm
。sigterm
这里的 PID 是 8,也就不会像 PID 为 1 时候的行为了,它会按照缺省行为处理 SIGTERM
。这里的具体步骤是:
创建容器,并在其中执行
/usr/sbin/pid1 sigterm
。pid1
的 PID 为 1,并fork
/exec
了sigterm
。sigterm
向自己发送了SIGTERM
,导致被杀。pid1
发现子进程被 SIGTERM 杀掉(sigal 15),并用 143 的返回码退出(128+15)PID 1 死掉,容器也就死掉了。
这并不是 sigterm
的特殊能力,sleep
也可以达到同样的目的:
$ docker run --rm --name sleeper fpco/pid1 sleep 100
$ docker kill -s TERM sleeper
...
和 ubuntu
镜像不同,fpco/pid1
的 entrypoint
是 sbin/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
sigterm
和 sleep
在面对 Ctrl+C
的时候会不太一样。Ctrl+C
会发送 SIGINT
给 docker 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
,在一段时间后发送 SIGKILL
,pid1
就是这么做的:
$ 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+C
,sleep
进程会收到 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
这个案例中发送 SIGKILL
给 docker run
,相对于 SIGINT
以及 SIGTERM
,SIGKILL
有些不同,docker run
无法转发这个信号,因此会杀掉自己,但是 sleep
进程和所在的容器会持续运行。
所以:
用类似
pid1
的东西来保障SIGINT
或者SIGTERM
能够真正地停止容器。如果必须要给进程发送
SIGKILL
,应该使用docker kill
。
entrypoint 的替代方案
我们用了很多次 --entrypoint /sbin/pid1
。实际上这很多余,fpco/pid1
和 snoyberg/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 以及其他的特性,最好还是用一个最小化的选择。