在使用docker容器的时候,应该了解“PID1僵尸进程reap”问题。如果使用的时候不加注意,可能会导致出现一些意想不到的问题。
僵尸进程是指完成执行(通过exit
系统调用,或运行时发生致命错误或收到终止信号所致),但在操作系统进程表中仍然有一个表项,处于“终止状态”的进程。这发生于子进程需要保留表项以允许其父进程读取子进程的exit status:一旦退出态通过wait
系统调用读取,僵尸进程条目就从进程表中删除,这个过程被称为reap。正常情况下,进程直接被其父进程wait
并由系统回收,进程长时间保持僵尸状态一般是错误的并导致资源泄漏。
英语中的zombie process源自丧尸--不死之人,隐喻进程已死大但没有被reap。与正常进程不同,kill
命令对僵尸进程无效。孤儿进程不同于僵尸进程,其父进程已经死掉,但孤儿进程仍能正常执行,并不会变为僵尸进程,因为init
进程会收养并wait
其退出。
子进程死后,系统会发送SIGCHLD信号给父进程,父进程对其默认处理是忽略。如果想响应这个消息,父进程通常在SIGCHLD信号处理程序中,使用wait
系统调用来响应子进程的终止。
僵尸进程被reap后,其进程号与在进程表中的表项都可以被系统重用。但如果父进程没有调用wait
,僵尸进程将保留进程表中的表项,导致资源泄漏。
reap僵尸进程的方式是通过kill
命令手工向其父进程发送SIGCHLD信号,如果其父进程仍然拒绝reap僵尸进程,则终止父进程,使得init
进程收养僵尸进程。init
进程周期执行wait
系统调用reap其所收养的所有僵尸进程。
为避免产生僵尸进程,实际应用中一般采取的方式是:
init
所“收养”、清理现在有很多人使用docker,只在容器里面运行一个进程。大多数情况下,这个进程并不会有init
进程的行为,也就是说,这个进程并不会reap收养的进程,而是期望init
进程来做这件事,这种做法是合理的。
来看一个具体的例子。假设容器中跑一个Web服务器,这个服务器运行bash编写的CGI脚本,脚本中调用了grep。Web服务器发现脚本执行超时,杀掉了它,但是grep进程没有受到影响并继续运行。当grep进程执行完后,变成了僵尸进程,被PID为1的进程收养(Web服务器进程)。Web服务器不知道grep进程,所以并没有reap它,这时grep僵尸进程就留在了系统里。
在其他的情况下,这个问题可能也存在。大家经常将第三方的应用程序跑在docker容器里,比如PostgreSQL,和上面一样,这个进程也是容器内的唯一进程。在这种情况下,真的能确定在容器中运行这些第三方应用不会产生僵尸进程吗?所以,在一般情况下,应该运行适当的init
系统来防止出现类似的问题。
现有Upstart,Systemd,SysV init等方案可用,不过把这些一股脑地放在容器里,会不会显得太重呢?其实,虽然需要这些功能,“完全init系统”却不是必要的。
这里讨论的init系统是一个简单的程序,负责fork出应用程序,并且reap收养的进程。
是否已经有现成,流行的软件可以做到这一点呢?还真有,这就是bash。bash会正确地reap收养的子进程。bash可以执行任何程序。将Dockerfile中的
CMD ["/path-to-your-app"]
改成
CMD ["/bin/bash", "-c", "set -e && /path-to-your-app"]
即可。
不过,这个办法有一个关键问题:不能正确处理信号。对bash发送一个SIGTERM信号,bash会终止,但是并不会发送SIGTERM给其子进程。
当bash程序终止时,内核会停止整个容器和其中的进程。一些进程会接收到SIGKILL信号,不正确地终止。SIGKILL无法被捕获,所以进程不能干净地终止。假如应用程序正在写文件;如果应用程序在写入过程中被不正确地终止,则文件可能会损坏。这就像拔服务器电源一样。
docker提供了一个解决的办法,在运行容器的时候添加init标志
docker run --init your_image_here
这会让docker内部的微型init系统封装应用程序,这个init系统会保证将信号传递给其子进程并确保获取所有孤儿进程。
如果想重新映射程序退出码呢?比如Java接收SIGTERM信号退出时,退出码是143,而不是0。
docker init无法处理此类情况。
Tini是能想到的最简单的init
。
Tini一般在容器中运行,用于生成子进程,等待它推出,reap僵尸进程,并执行信号转发。
在最新的版本中,能将退出码143重新映射为0。使用的命令行如下
ENTRYPOINT ["/tini", "-v", "-e", "143", "--", "/runner/init"]