前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >(译)Docker 中的 PID-1、孤儿、僵尸和信号

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

作者头像
崔秀龙
发布2020-12-16 11:14:03
2.3K0
发布2020-12-16 11:14:03
举报
文章被收录于专栏:伪架构师伪架构师伪架构师

使用 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 <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 解决这个问题:

$ 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 讨论了该问题的详情),我们可以通过 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 以及其他的特性,最好还是用一个最小化的选择。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-12-13,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 伪架构师 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 和 Entrypoint 一起玩耍
  • 向进程发送 TERM 信号
  • Ctrl+C sigterm 和 sleep
  • 僵尸进程
  • 进程清理
  • Docker Run 和 PID1
  • entrypoint 的替代方案
  • Dockerfile,command vs exec
  • 结论
相关产品与服务
容器镜像服务
容器镜像服务(Tencent Container Registry,TCR)为您提供安全独享、高性能的容器镜像托管分发服务。您可同时在全球多个地域创建独享实例,以实现容器镜像的就近拉取,降低拉取时间,节约带宽成本。TCR 提供细颗粒度的权限管理及访问控制,保障您的数据安全。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档