【译】Docker 和子进程“僵尸化”问题

@zen
2016年9月28日

在构建 Docker 容器时,我们需要注意子进程的“僵尸化”问题(PID 1 Zombie Reaping Problem)。这会导致不可预知的和看起来匪夷所思的问题。本文解释了这个问题,也解释了如何去解决,并提供了一种预构建方案:Baseimage-docker 。

在通篇阅读之后,还有第二篇:《Baseimage-docker,胖容器和“容器也是虚拟机”》

前言#

一年以前,当 Docker 还是 0.6 版本的时候,我们已经介绍过 Baseimage-docker 了——针对 Docker 优化的 Ubuntu 极小化基础镜像。我们可以从 Docker 仓库中将其拉取回来并作为基础镜像使用。

笔者也算是 Docker 的早期使用者了,早在其 1.0 版本发布前,就已经将其用来做持续集成和构建开发环境了。因而,笔者才研发了 Baseimage-docker 以解决 Docker 工作模式中的一些问题。例如:Docker 并没有使用初始进程管理子进程地方式来运行任务,因此在容器结束时残留的僵尸进程可能会导致各种问题。Docker 也没有使用 syslog ,所以某些关键日志信息就无法被检索到,等等。

而且,很多人并不理解 Baseimage-docker 究竟是要解决什么问题。因为这些都是鲜为人知的 Unix 操作系统底层工作机制。因此在本文中,我们将会详细讲解其中最重要的问题——子进程的“僵尸化”问题。

我们认为:

  1. 这些 Baseimage-docker 所致力解决地问题适用于很多人;
  2. 大多数人并没有意识到这些问题,因此在创建自己的 Docker 镜像时会出现各种诡异地问题;
  3. 避免每个人去重复性地解决同样地问题是有价值的。

因此笔者在闲暇时间将解决方案制作成了每个人都能使用的 Baseimage-docker 基础镜像。这份镜像中也包含了大量 Docker 镜像开发人员所需要的有用的工具。自此以后,笔者所有的 Docker 镜像就都是以此作为基础了。

大家看起来也很认可这些工作:在 Docker 官方仓库中,Baseimage-docker 是紧随 Ubuntu 官方镜像和 CentOS 官方镜像之后的第三名最受欢迎镜像。

初始进程的责任:“收割”“僵尸进程”#

Unix 的进程之间是树状结构的关系。每个进程都可以派生出子进程,而除了最顶端的进程之外,也都会有一个父进程。

这个最顶端的进程就是初始进程,其在启动系统时被内核启动,并负责启动系统的其余功能部分。如:SSH 后台程序、Docker 后台程序、Apache/Nginx 和 GUI 桌面环境等等。这些程序又可能会派生出它们自己的子进程。

这一部分并没有什么问题。但问题在于当一个进程终止时,会发生什么?假设上图中的 bash (5) 进程结束了,那么其会转变为「废弃进程」(defunct process),也被称作为“僵尸进程”(zombie process)。

为什么会这样?因为 Unix 这样设计地目的,在于让父进程能够耐心“等待”子进程结束,从而获得其结束状态(exit status)。只有当父进程调用 waitpid() 之后,“僵尸进程”才会真正结束。手册里是这样描述地:

一个已经终止但并未被“等待”的进程,就成为了一个“僵尸”。内核会记录这些“僵尸进程”的基本信息(PID、终止状态、资源占用信息),以确保其父进程在之后的时间里可以通过“等待”来获取这个子进程的信息。

通常来说,人们会简单地认为“僵尸进程”就是那些会造成破坏的失控进程。但从 Unix 系统角度来分析,“僵尸进程”有着非常清晰地定义:进程已经终止,但尚未被其父进程“等待”。

绝大多数情况下,这都不会产生什么问题。在一个子进程上调用 waitpid() 以消除其“僵尸”状态,被称为“收割”。多数应用程序都能够正确地“收割”其子进程。在上例中,操作系统会在 bash 进程终止时发送 SIGCHLD 信号以唤醒 sshd 进程,其在接收到信号后就“收割”掉了此子进程。

但还有一种特殊情况——如果父进程终止了,无论是正常的(程序逻辑正常终止),还是用户操作导致的(比如用户杀死了该进程)——子进程会如何处理?它们不再拥有父进程,变成了「孤儿进程」(orphaned)(这是确切的技术术语)。

此时初始进程(PID 1)就会因其被赋予地特殊任务而介入——「领养」(adopt)(同样的,这是确切的技术术语)「孤儿进程」。这就意味着初始进程会成为这些子进程的父进程,而无论其是否由初始进程创建。

以 Nginx 为例,其默认就会作为后台程序运行。工作流程如下:Nginx 创建一个子进程后,自身进程结束,然后该子进程就被初始进程「领养」了。

其中的要点是什么?操作系统内核自动处理了「领养」逻辑,因此内核其实是希望初始进程也自动完成对这些「孤儿进程」的“收割”逻辑

这在 Unix 操作系统中是一个非常重要的机制,大量的软件都是因而设计和实现。几乎所有的服务(daemon)程序都预期初始进程会「领养」和“收割”其守护子进程

尽管我们是以服务程序做例子,但系统并没有什么机制对此进行规约。任何一个进程在结束时,都会预期初始进程能够清理(「领养」和“收割”)其子进程。这一点,在《操作系统概述》《Unix 系统高级编程》两书中描述地非常详细。

“僵尸进程”的危害#

“僵尸进程”都已经终止了,它们危害在哪里?它们原本占用的内存已经释放了吗?在 ps 中除了多了些条目,还有什么别的吗?

是的,内存确实已经释放,但能够在 ps 中看到,说明它们还仍然占用着一些内核资源。对 Linux waitpid 的文档引用如下:

在“僵尸进程”在被父进程“等待”以彻底消除之前,其仍然会被记录在内核进程表中。而当该表被写满后,新的进程将无法被创建。

对 Docker 的影响#

这个问题会如何对 Docker 产生怎样的影响?我们可以看到很多人只在他们的容器中跑一个进程,而且也认为只需要跑这么一个进程就足够了。但显而易见地,这些进程无法承担初始进程在前文中所述的任务逻辑。因此,为了能够正确地“收割”被「领养」的进程,我们需要另外的初始进程来完成这些工作。

举一个相对复杂地例子,我们的容器是一个 web 服务器,需要去跑一段基于 bash 的 CGI 脚本,而该脚本又会去调用 grep 程序。假定 web 服务器发现了 CGI 脚本执行超时,也中止了其继续执行。但此时 grep 程序并不会受到影响仍然继续执行,当其执行结束时,就变成了一个“僵尸进程”并由初始进程(即 web 服务器)「收养」。但 web 服务器无法正确地“收割”这个 grep 进程,所以该“僵尸进程”就在系统中常驻了。

这个问题同样也存在于其它场景中。我们能看到人们尝尝为第三方程序创建 Docker 容器——又如 PostgreSQL ——并将其作为容器中的主进程运行。当我们运行别人的代码时,我们如何确保这些程序并不会派生出子进程并因而堆积大量的“僵尸进程”?唯独仅有我们运行着自己的代码,同时还对所有的依赖包和依赖包的依赖包做严格地审查,才能杜绝这种问题。因此,通常来说,我们很有必要来执行一个合适的初始化系统(init system)来避免这些问题地发生。

完整的初始化系统让容器变得太重以至于像一台虚拟机?#

初始化系统与是否太重并无直接联系。我们可能会联想到 Upstart、Systemd、SysV init 之类的大型系统,也可能会联想到完整的系统启动流程。但一套“完整的初始化系统”对我们来说,既没必要,也不划算。

我们所说的初始化系统,仅仅是一个能够启动应用程序、也能“收割”被「领养」地子进程的小程序。这样简单的初始化系统,才贴合 Docker 的哲学。

一种简单的初始化系统#

哪些常用程序既能够运行其它的程序,又能够“收割”被「领养」地子进程呢?

其实每个人都拥有一种几乎完美的解决方案——bash 。Bash 就可以很好地“收割”被「领养」地子进程,也能运行任何程序或代码。因此将我们的 Dockerfile 里的:

CMD ["/path-to-your-app"]

可以替换为:

CMD ["/bin/bash", "-c", "set -e && /path-to-your-app"]

-e 选项可以避免 bash 将脚本作为简单命令直接处理。)

这样就能够产生我们所预期地进程层次:

但这种方案仍然还有一个问题,那就是无法正确地处理信号。当我们通过 kill 来发送 SIGTERM 信号给 bash 时,进程终止了,但它不会发送 SIGTERM 给子进程。

当 bash 终止时,其会发送 SIGKILL 信号来终止所有子进程(容器内的所有进程)。但因为 SIGKILL 是无法被捕获(trapped)地,所以没有办法干净地终止掉子进程。比如主程序在被终止时正在写入文件,那么该文件就会因此损坏。这就像直接拔掉了服务器的电源线一样残酷。

我们为什么要关注初始进程是否被 SIGTERM 信号终止?因为 docker stop 就是发送 SIGTERM 信号给初始进程,而这个操作应当能够干净地停止容器以备之后的 docker start

Bash 专家们会诱惑我们来写一段 EXIT 处理程序来简单地发送信号给子进程:

#!/bin/bash
function cleanup()
{
    local pids=`jobs -p`
    if [[ "$pids" != "" ]]; then
        kill $pids >/dev/null 2>/dev/null
    fi
}

trap cleanup EXIT
/path-to-your-app

但这并不足以解决问题。仅仅发送信号给子进程是不够的,初始进程还需要“等待”子进程终止,然后才是自身终止。否则子进程地终止还是不干净的。

因此,我们需要一套更合适的解决方案,但又不是像 Upstart、Systemd 和 SysV init 之类的重量级解决方案。再因此,笔者才在 Baseimage-docker 中专为 Docker 容器编写了一套轻量的初始化系统——my_init——一段 350 行的最小资源占用的 Python 程序。

其主要功能在于:

  • “收割”被「领养」地子进程;
  • 启动子进程;
  • “等待”所有子进程终止后才自我终止,但也可以配置最大超时时间进行控制;
  • 将日志写入 docker logs

Docker 会解决这个问题吗?#

相对来说,在 Docker 内部解决这一问题更为恰当。由其提供内置的初始化系统来“收割”被「领养」地子进程才最完美。但直至 2015 年一月,我们还看不到 Docker 团队在此问题上的作为。这不是批评——Docker 志向远大,而且笔者也确定 Docker 团队有更重要的事情要做,比如继续完善配置工具之类的。子进程的“僵尸化”问题在用户层面就很容易解决。因此在 Docker 官方解决这一问题之前,我们推荐大家使用一套合适的初始化系统自行搞定就好。

这真是一个问题吗?#

直到此刻,这个问题仍然有点像是危险耸听。在我们从容器中看到“僵尸进程”之前,似乎一切都没有问题。但确保这一问题绝对不会发生的唯一方法,就是仔细审核程序代码、依赖包的代码和依赖包的依赖包的代码。在这些工作做完之前,就还是有可能会有代码派生出子进程,然后“僵尸化”。

我们也许会想,我们从未见过问题发生,因此这应该只是小概率。但莫非定律说了,当事情可能变糟时,就一定会变糟。

姑且不提“僵尸进程”会占用内核资源地问题,它们还会导致其它程序对进程的存在性检查出错。比如,笔者公司的 Phusion Passenger 应用服务程序会对进程进行管理,当它们崩溃时重启。而崩溃检查就是通过对 ps 命令的输出内容进行分析,而后向进程发送 0 信号来完成的。而“僵尸进程”既会出现在 ps 输出内容中,也能够正常地回应 0 信号,因而会被误认为是有效进程,导致实际服务挂起。

再权衡一下,解决这个问题只需要 5 分钟即可,使用 Baseimage-docker 也好,在容器中使用笔者的 350 行 my_init 初始化系统也好。

结语#

子进程的“僵尸化”问题是值得关注的。解决方案之一就是使用 Baseimage-docker

Baseimage-docker 是唯一候选方案吗?显然不是。Baseimage-docker 致力于:

  1. 让人们意识到 Docker 容器的一些潜在问题和风险;
  2. 提供他人无需深入关注也能安全使用的预构建方案。

这也意味着只要是解决了我们所描述地问题的方案,都是可行的。可以随意使用 C、Go、Ruby 或者什么别的语言来实现相应的方案。但我们既然已经有了现成的好方案,为什么还要再重新做轮子呢?

或许我们使用地基础镜像并不是 Ubuntu,或者是 CentOS。但 Baseimage-docker 同样可以为我们所用。比如笔者公司的 passenger_rpm_automation 项目就基于 CentOS,直接将 Baseimage-docker 的 my_init 移植了过去。

因此即便我们没有、也不想用 Baseimage-docker ,我们在前文中描述的问题仍然值得关注,也仍然值得仔细思考如何去解决。

Happy Dockering.


作者 Hongli Lai 看着像是华人,可惜没找到文章的中文版本,于是就很土鳖地手翻了一遍。文章的措辞很口语化,因此意译为主。点击这里可以阅读原文。