(译)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 TERMDocker 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 以及其他的特性,最好还是用一个最小化的选择。
