容器化环境中,往往直接运行应用程序,而缺少初始化系统(如 systemd、sysvinit 等)。这可能需要应用程序来处理系统信号,接管子进程,进而导致容器无法停止、产生僵尸进程等问题。
Yelp 开发的 dumb-init ,旨在模拟初始化系统功能,避免上述问题的发生。
问题的根源
对于开发人员来说,希望在容器中运行的进程和普通进程行为一致,这样才能大大降低容器化迁移的成本,而无须让开发人员关注容器初始化和退出的流程。
归功于 Linux 的名字空间(namespace),从容器中看,由容器创建的第一个进程 pid 为 1。而对于 Linux 来说,pid 为 1 的进程,有着特殊的使命:
- 传递信号,确保子进程完全退出
- 等待子进程退出
子进程的优雅退出
对于第一点,如果 pid 为 1 的进程,无法向其子进程传递信号,可能导致容器发送 SIGTERM 信号之后,父进程等待子进程退出。此时,如果父进程不能将信号传递到子进程,则整个容器就将无法正常退出,除非向父进程发送 SIGKILL 信号,使其强行退出。
考虑如下进程树:
- bash(PID 1)
- app(PID2)
bash 进程在接受到 SIGTERM 信号的时候,不会向 app 进程传递这个信号,这会导致 app 进程仍然不会退出。对于传统 Linux 系统(bash 进程 PID 不为 1),在 bash 进程退出之后,app 进程的父进程会被 init 进程(PID 为 1)接管,成为其父进程。但是在容器环境中,这样的行为会使 app 进程失去父进程,因此 bash 进程不会退出。
举个例子:
docker run ubuntu /bin/bash -c '(sleep 1000 &) && sleep 2000'
该命令会启动的容器,内部进程结构为:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 0.0 0.1 17960 2816 ? Ss 13:05 0:00 /bin/bash -c (sleep 1000 &) && sleep 2000 root 7 0.0 0.0 4348 748 ? S 13:05 0:00 sleep 1000 root 8 0.0 0.0 4348 644 ? S 13:05 0:00 sleep 2000
此时,如果对这个容器发送 SIGTERM 信号,该容器将不会退出:
docker kill -s SIGTERM 8ef469d46b52
注意,直接使用 docker kill 命令,会向容器发送 SIGKILL 信号强制杀死进程。docker stop 命令会先发送 SIGTERM 信号,等待超时时间之后,发送 SIGKILL 信号。因此,此时通过这两个命令都能够结束容器,但都不能“优雅的”结束进程。
僵尸子进程
另一个问题是等待子进程退出。前面提到过,init 进程另一个任务,是需要接管子进程,确保其能正常退出。但是一般应用程序,不会考虑实现接管进程功能。当应用程序进程在容器中运行时,其子进程创建的子进程,就有可能成为僵尸进程。
这里来模拟这个过程,首先启动一个容器,执行 sleep 命令:
docker run ubuntu /bin/bash -c 'sleep 1000'
此时,容器中只有一个 sleep 进程,其 PID 为 1。这时,我们进入这个容器,再启动一个 bash 进程和一个 sleep 进程,模拟应用程序派生出来的子进程。
首先进入容器,
docker exec -it 4ecdaafb501f /bin/bash
然后创建进程:
bash -c 'sleep 1000'
这时,容器中有一个 PID 为 1 的 sleep 进程,一个 bash 进程,一个父进程为 bash 进程的 sleep 进程。
root@4ecdaafb501f:/# ps -ef UID PID PPID C STIME TTY TIME CMD root 1 0 0 13:30 ? 00:00:00 sleep 1000 root 6 0 0 13:30 ? 00:00:00 /bin/bash root 21 6 0 13:31 ? 00:00:00 sleep 1000
再新开一个回话进入容器,然后对 PID 为 6 的 bash 进程发送 SIGKILL 信号,将其杀死,该操作模拟应用程序的子进程结束场景。此时,bash 进程的子进程 sleep 进程,由于失去了父进程,将会由 PID 为 1 的 sleep 进程进行托管。但是,由于 sleep 命令不是标准的 init 系统,没有实现子进程托管的功能。此时的 PID 为 21 的进程,虽然已经结束,但是其没有被父进程回收(通过 waitpid 系统调用),进入僵尸进程状态。
root@4ecdaafb501f:/# ps aux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 0.0 0.0 4348 668 ? Ss 13:30 0:00 sleep 1000 root 21 0.0 0.0 0 0 ? Z 13:31 0:00 [sleep] <defunct></defunct>
dumb-init 来了
dumb-init 解决了上述两个问题:向子进程代理发送信号和接管子进程。
默认情况下,dumb-init 会向子进程的进程组发送其收到的信号。原因也很简单,前面已经提到过,像 bash 这样的应用,自己接收到信号之后,不会向子进程发送信号。当然,dumb-init 也可以通过设置环境变量DUMB_INIT_SETSID=0
来控制只向它的直接子进程发送信号。
另外 dumb-init 也会接管失去父进程的进程,确保其能正常退出。
dumb-init 使用
要在容器中使用 dumb-init,可以直接安装 deb 包,或者从源码构建。容器启动时,使用 dumb-init 作为初始进程,确保所有子进程都由 dumb-init 进程创建:
docker run my_container dumb-init python -c 'while True: pass'
除了在容器中使用之外,dumb-init 也可以直接在 shell 脚本中使用。使用 dumb-init 作为 shell 的父进程,可以解决 shell 创建的子进程优雅退出问题。这种场景使用方式类似于 supervisord 或者 daemontools ,直接将脚本的 shebang 改成#!/usr/bin/dumb-init /bin/sh
即可。
感谢魏星对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群(已满),InfoQ 读者交流群(#2))。
评论