Docker 和 PID 1 僵尸进程问题

当构建Docker 容器时,需要注意PID 1 僵尸回收问题,那个问题会在你最不期望出现问题的时候,导致一些不期望的结果和看起来很困惑的问题。本文解释了PID 1问题,解释怎样解决它,并且作为一个预先构建的方案--可以作为一个基本的Docker镜像来使用。

当上面的问题解决了,你可能会想去读 第二部分:Docker基础镜像,胖容器 和 “把容器当虚拟机” 

介绍

大概一年前-回溯到Docker0.6 的时期-我第一次介绍Docker基础镜像。这是为了对Docker友好而修改的最小的Ubuntu 基础镜像。其他人可以 从Docker 登记下载Docker基础镜像并且把它作为他们自己镜像的基础镜像。

我们是早期的Docker使用者,用Docker来持续集成并且在Docker达到1.0版本之前,用作搭建开发环境的方式。为了解决一些使用Docker能够解决的问题,我们开发了docker基础镜像。例如,Docker不会在某个恰当处理子进程的初始化进程下运行进程。以至于容器有可能结束导致各种各样的问题僵尸进程。Docker 也不会做任何事情,以至于让重要的消息能够正常的被处理等等。

然而,我们已经发现很多人对我们解决的问题理解上有问题。Granted,是Unix操作系统底层很少人知道和理解的系统级机制。所以在本文中我们将会详细描述这个我们已经解决了的最重要的PID 1僵尸进程问题。

Zombies

我们发现:

  1. 我们解决的问题适用于很多人

  2. 大多数人甚至没有意识到这些问题,所以很多事情会以意想不到的方式被打断(墨菲定律)

  3. 如果每个人都一遍又一遍的重复解决这些问题是低效率的。

所以我们在空闲时间,把解决方案提取为每个人可以复用的基础镜像:Baseimage-docker.这个镜像也加入了一些有用的,相信大多数Docker镜像开发者都需要的工具。我们把Baseimage-docker作为我们所有Docker镜像的一个基础镜像。

社区看起来喜欢我们所做得事情:我们是Docker注册处最流行的第三方镜像。只是排在了官方的Ubuntu和CentOServer镜像下面。

PID 1 问题: 进程僵尸

回想一下Unix的进程是一个有序的树。每个进程可以派生子进程,每个进程具有一个除了最顶层以外的父进程。

这个最顶层的进程是init进程。它是当你启动系统时由内核启动。这个init进程负责启动系统的其余部分,如启动SSH服务,从启动Docker守护进程,启动Apache / Nginx的,启动你的GUI桌面环境,等等。他们每个进程都可能会反过来派生出更多的子进程。

Unix process hierarchy

到目前为止还没有什么特别的。但考虑到如果一个进程终止会发生什么。比方说,bash(PID 5)进程终止。它变成了一个所谓的“停止活动的进程”,也称为“僵尸进程”。

Zombie process

为什么会这样?这是因为Unix被设计为这样一种方式,父进程必须明确地“等待”子进程终止,以便收集它的退出状态.。僵尸进程一直存在,直到父进程已经执行该操作,使用系统调用waitpid()函数。我从手册页引用

“一个子进程终止了,但一直被等待就变成了”僵尸“。内核维护了一组关于僵尸进程最小的信息列表(PID,终止状态,资源使用信息),为了让父进程以后进行等待时,能够获取有关子进程的信息。”

在日常的语言中,人们认为“僵尸进程”是会造成严重破坏的混乱进程。但正式的说 - 从Unix操作系统观点 - 僵尸进程有一个非常明确的定义。他们是已经终止,但没有(还)被他们的父进程等待的进程。

大多数时间这都不是问题,在子进程上调用waitpid()的动作是为了消除它的僵死进程,这就是所谓的“收割”。许多应用正确的收割它们的子进程。在上面的例子中用的是sshd,如果bash终止了然后操作系统将会向sshd发送一个SIGCHLD信号把它唤醒。sshd注意到了这个信号后就收割子进程。

Zombie process reaping

但是有个特殊情况,假设父进程终结了,或者是故意的(因为程序逻辑决定该退出系统了)或者是用户的操作导致的(例如用户将这个进程杀死了)。这个父进程的子进程将会发生什么?他们不再有父进程了,所以他们变成了“孤儿”(这是实际的专业术语)。

这就是init进程起作用的地方。init进程--PID 1--有一个特殊的任务。就是“接收”孤儿进程(注意,这是实际的技术术语)。这就意味着init进程变为了这些进程的父进程。尽管这些进程从来都没有被init进程直接创建。

拿Nginx作为例子,默认是作为后台守护进程。它是这么工作的。第一,Nginx创建一个子进程。第二,原始的Nginx进程退出了。第三,Nginx子进程被init进程给接收了。

Orphaned process adoption

你可能知道我将要表达什么。操作系统内核自动的处理收容,所以这就意味着内核期望init进程要有一个专门的职责:操作系统也期望init进程收割被接收的孤儿进程。

这是Unix系统中一个非常重要的职责。它是如此基础的职责以至于很多很多软件的都利用了这一点。所有的守护软件非常期望被守护的子进程都被init进程收容和收割。

尽管我用守护进程作为例子,但不限于守护进程。每当一个进程退出了,虽然它还有子进程存在。这是因为它们期待init进程稍后来清理。这些已经详细的在这两本书中描述了:操作系统概念 著 Silberschatz等和Unix环境中的高级编程 著 Stevens 等。

Operating System Concepts by Silberschatz et al

Avanced Programming in the Unix Environment by Stevens et al

为什么僵尸进程是有害的

即使他们终止了进程,为什么僵尸进程是一件坏事 ? 原始应用程序的内存已经被释放,对啊?这不仅仅是一个条目,你在ps中看到它了吗?

你是对的,原始应用程序的内存已经被释放。 但事实上,你还看到它在ps中,这意味着它仍然占用一些内核资源。 我参考Linux waitpid手册:

“只要一个僵尸进程通过等待没有在系统中被移除, 它就会在内核进程表中消耗一个位置,并且要是这个表被填满,那它就没办法创建一个新的进程。”

与Docker的关系

那么这怎么涉及到Docker?我们看到很多人在他们的容器里只运行一个进程,他们认为运行单进程,他们的工作就结束了。但是,这个进程写出来并不是为了完全像init进程的行为。也就是说,非但没有恰当的收割被收容的孤儿进程,反而没准它还期望其他的init进程来正确地做那样的工作。

让我们来看看具体的例子.假设你的容器运行了一个web服务器,web服务器运行一个CGI,它是用bash写的脚本。CGI脚本调用grep.然后web服务器决定CGI脚本运行的时间太长了并且杀死了这个脚本,但是grep 没有受影响并继续运行。当grep结束了,它成为了僵尸并且被PID 1收容(web服务器)。web服务器不知道grep,所以web容器不收割它,然后grep僵尸进程停留在系统中了。

这个问题也适用于其他状况。人们经常为第三方应用创建Docker容器--比如PostgreSQL--并且把这些应用当做灵魂进程在容器中运行。当你正运行其他人的代码,你能确保这些应用接下来不会大量产生僵尸进程吗?如果你运行你自己的代码,并且你审计了类库。没发现问题。但是通常情况下还是应该运行一个适当的init系统进程来阻止问题发生。

但是运行一个全初始化系统不会让container重量级并且像一个虚拟机吗?

一个初始化系统没有必要是重量级的,你可能很轻易地就想到了Upstart,Systemd,SysV等初始化系统。可能你认为完整的系统需要在容器中被启动。其实不是这样的。我们所说的“全初始化系统”,是没有必要的也不是令人满意的。

我所谈论的初始化系统是小的,它的唯一职责就是启动你的应用,并且收割收容的子进程。使用如此简单的初始化系统是完全符合Docker的哲学的。

一个简单的初始化系统

是否已经存在一个能够运行其他应用并且能够同时收割收容的子进程的软件?

有一个几近完美的解决方案,每个人都有--它是简单陈旧的bash. Bash正确的收割收容的子进程。Bash能够运行任何事。所以不是要把这些放到你的Dockerfile中...

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

…你可能有兴趣用这个替代:

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

(-e 指令阻止bash把这个脚本当做简单的命令直接执行exec())

这回导致如下处理层次结构:

bash

但是不幸的是,这个程序有一个致命问题,它没有正确处理信号!假设你用kill发送SIGTERM信号给bash.Bash终止了,但是没有发送SIGTERM给它的子进程!

当bash结束了,内核结束整个容器中的所有进程。包扩通过SIGKILL信号没有被干净的终结的进程。SIGKILL不能被捕获,所以进程是没有办法干净的终结。假设你运行的应用程序正忙于写文件;在写的过程中,应用被不干净的终止了这个文件可能会崩溃。不干净的终止是很坏的事情。很像把服务器的电源给拔掉。

但是为什么要关心init进程是否被SIGTERM给终结了呢?那是因为docker stop 发送 SIGTERM信号给init进程了。“docker stop” 应该干净的停止容器,以至于稍后你能够用“docker start”启动它。

Bash专家现在可能会有兴趣写一个EIXT处理器,它简单的发送信号给子进程,像这样:

#!/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

不幸的是,这个不解决问题。仅仅是给子进程发送信号是不够的:init进程在终结自己前必须等待子进程终结。如果init进程过早的结束了,所有的子进程又没有干净的被内核终结。

所以明显的一个更加复杂的解决方案是需要的。但是一个全初始化系统像Upstart,Systemd 和SysV init对于轻量级的Docker容器来说就是赶尽杀绝。幸运的是,Baseimage-docker有一个解决方案。我们已经写了一个自定义的,轻量级的初始化系统。特别是在Docker容器内。由于缺少一个好的名字,我们把这个程序叫做my init,一个350行最小资源使用率的Python程序。

my_init的一些关键特性:

Docker 会解决这些吗?

理想的,这个PID1问题是被Docker本地解决的。如果Docker提供一些内嵌初始化系统能够正确收割被收容的子进程那将是很伟大的事情。但是直到2015年一月,我们也没有注意到Docker团队为解决这个问题付出任的何努力。这不是苛求--Docker是非常有雄心的,并且我相信Docker端对有更大的事情需要关心,比如进一步开发它们的编配工具。PID1问题在用户层面是可以解决的。所以在Docker官方解决这个问题之前,我们建议人们自己通过使用恰当的和上面描述的行为一样的初始化系统来解决这个问题。

这真是问题吗?

从这一点来说,这个问题可能听起来仍然不真实。如果你从来没有在容器中看到过任何僵尸进程,那么你可能倾向于认为所有的事情都很正常。但是唯一让你认为这个问题从来没有发生过的方式,是当你已经评审完你所有的代码,类库代码,以及类库依赖的类库代码。除非你已经那样做了,否则有些通过上面描述的方式产生进程的代码,他们随后可能会成为僵尸进程。

你可能会认为,我从来没有看到它出现,所以机会是很小的。但是墨菲定律说道,当事情可能会出错,那么它们就会出错。

除了僵尸进程持有内核资源这个事实外,僵尸进程的不离开也会干扰那些检测进程是否存在的软件。例如 Phusion 乘客应用服务器 管理进程。 它重启那些崩溃的进程。崩溃监测通过分析 ps 的输出实现和发送0信号到进程ID实现的。僵尸进程是通过ps 和对0信号的响应来显示的,所以Phusion 乘客认为这个进程依然存活,尽管他已经终止了。

再想想取舍。阻止僵尸进程的发生这个问题的发生,你所需要做的就是花5分钟,或者使用docker基础镜像,或者导入 our 350 lines my_init init system  到你的容器中。内存和磁盘占用很小:只占用内存和硬盘几MB空间就能够阻止墨菲定律发生。

总结

所以PID 1 问题是需要注意的的问题。一个方法就是使用Dock基础镜像

是否Dock基础镜像是唯一的解决方式?当然不是,Dock基础镜像的主旨是:

1.使人们书一道一些重要Docker容器的警告和缺陷。

2.提供一些预先解决方案方便其他人使用,以至于其他人不至于针对此问题重新发明解决方案。

这也意味着多种解决方案的存在,一旦他们解决了我们描述的这个问题。你可以自由的重新用C,Go,Ruby 或者其他什么语言来实现该解决方案。但是我们已经提供了一个很好的解决方案了,你为什么还要这样呢?

可能你不想使用Ubuntu作为基础镜像。可能你会使用CentOS。 但是不要停止使用image-docker所给你带来的好处。 举例来说,我们的 passenger_rpm_automation 项目使用CentOS容器。 我们简单地提取了基础的image-docker的my_init并且将其引入。

因此,即使你不使用, 或者不想要使用Baseimage-docker,好好看看我们描述的问题,考虑你能做什么来解决这些问题。

快乐的Dockering.

第二部分: 我们将讨论这一现象,很多人将Baseimage-docker与"胖容器"联系起来。 Baseimage-docker根本不是关于胖容器的, 那么他是什么? 参看 Baseimage-docker,胖容器和 “将容器作为VM”