# Docker 中的 PID-1、孤儿、僵尸和信号

原文：[DOCKER DEMONS: PID-1, ORPHANS, ZOMBIES, AND SIGNALS](https://www.fpcomplete.com/blog/2016/10/docker-demons-pid1-orphans-zombies-signals/)

作者：[Michael Snoyman](https://github.com/snoyberg)

使用 Docker 的时候，在多进程、信号方面会有一些边缘用例。在 [Phusion 博客](https://blog.phusion.nl/2015/01/20/docker-and-the-pid-1-zombie-reaping-problem/)上有一篇相关文章，后续内容中会尝试接触这些问题，并使用 [fpco/pid1](https://hub.docker.com/r/fpco/pid1/) 解决问题。

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

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

> 我们用 Haskell 自行实现的目的是嵌入到 [Stack build tool](https://haskellstack.org/) 之中。还有一些其它轻量级初始化进程，例如 [`dumb-init`](https://engineeringblog.yelp.com/2016/01/dumb-init-an-init-for-docker.html)。我也写了[关于 dumb-init 的文章](https://www.fpcomplete.com/blog/2016/08/bitrot-free-scripts)。这里用的 `pid1` 跟其它的初始化进程之间没有什么差别。

## 和 Entrypoint 一起玩耍

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

```plaintext
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 设置为空](https://github.com/docker/docker/issues/23498)，所以我们准备使用 `/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](https://github.com/snoyberg/docker-testing/blob/master/sigterm.hs) 命令开始，这个命令会执行 `ps`，然后给自己发送一个 `SIGTERM`，持续循环。在 Unix 系统中，进程收到 `SIGTERM` 的缺省操作就是退出。因此我们推测我们的进程应该启动之后直接退出，实际情况：

```plaintext
$ 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` 的接收器，用于杀死进程。使用这个参数之后情况就不同了：

```plaintext
$ 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` 信号不会杀死进程。这个情况让人很不习惯。下一个测试中，使用两个不同的终端分别执行命令：

```plaintext
$ 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` 时候的表现：

```plaintext
$ 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`。这里的具体步骤是：

1. 创建容器，并在其中执行 `/usr/sbin/pid1 sigterm`。
    
2. `pid1` 的 PID 为 1，并 `fork`/`exec` 了 `sigterm`。
    
3. `sigterm` 向自己发送了 `SIGTERM`，导致被杀。
    
4. `pid1` 发现子进程被 SIGTERM 杀掉（sigal 15），并用 143 的返回码退出（128+15）
    
5. PID 1 死掉，容器也就死掉了。
    

这并不是 `sigterm` 的特殊能力，`sleep` 也可以达到同样的目的：

```plaintext
$ 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 文档](https://docs.docker.com/engine/reference/commandline/attach/)中包含了更多关于信号转发的内容。

## 僵尸进程

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

[orphans.hs](https://github.com/snoyberg/docker-testing/blob/master/orphans.hs) 的行为：

* 生成一个子进程，用死循环调用 `ps`；
    
* 在子进程中：运行 `echo` 命令多次，不调用 `waitpid` 然后退出。
    

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

```plaintext
$ 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 <defunct>
   14 ?        00:00:00 echo <defunct>
   15 ?        00:00:00 echo <defunct>
   16 ?        00:00:00 echo <defunct>
   17 ?        00:00:00 ps
Still alive!
  PID TTY          TIME CMD
    1 ?        00:00:00 orphans
   13 ?        00:00:00 echo <defunct>
   14 ?        00:00:00 echo <defunct>
   15 ?        00:00:00 echo <defunct>
   16 ?        00:00:00 echo <defunct>
   18 ?        00:00:00 ps
Still alive!
```

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

```plaintext
$ 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 <defunct>
   20 ?        00:00:00 echo <defunct>
   21 ?        00:00:00 echo <defunct>
   22 ?        00:00:00 echo <defunct>
   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 讨论了该问题的详情](https://stackoverflow.com/questions/39739658/what-happens-to-other-processes-when-a-docker-containers-pid1-exits)），我们可以通过 `surviving.hs` 来观察这个情况：

```plaintext
$ 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` 就是这么做的：

```plaintext
$ 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`。

如何证明呢？

```plaintext
$ 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`：

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

如果嫌 entrypoint 麻烦，可以用在命令之中，例如：

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

## Dockerfile，command vs exec

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

```plaintext
$ 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"]`：

```plaintext
$ 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 以及其他的特性，最好还是用一个最小化的选择。
