容器中的一号进程
作者 | 没有文案的夏老师
来源 |运维开发故事(ID:mygsdcsf)
转载请联系授权(微信ID:wanger5354)
如何理解 init 进程?
linux 进程在树中排序。每个进程都可以产生子进程,并且除了最顶层的进程之外,每个进程都有一个父进程。
一旦我们启动了多个进程,那么容器里就会出现一个 pid 1,也就是我们常说的 1 号进程或者 init 进程,然后由这个进程创建出其他的子进程。接下来,我带你梳理一下 init 进程是怎么来的。
一个 Linux 操作系统,在系统打开电源,执行 BIOS/boot-loader 之后,就会由 boot-loader 负责加载 Linux 内核。Linux 内核执行文件一般会放在 /boot 目录下,文件名类似 vmlinuz*。在内核完成了操作系统的各种初始化之后,这个程序需要执行的第一个用户态程就是 init 进程。
内核代码启动 1 号进程的时候,在没有外面参数指定程序路径的情况下,一般会从几个缺省路径尝试执行 1 号进程的代码。这几个路径都是 Unix 常用的可执行代码路径。
系统启动的时候先是执行内核态的代码,然后在内核中调用 1 号进程的代码,从内核态切换到用户态。
目前主流的 Linux 发行版,无论是 RedHat 系的还是 Debian 系的,都会把 /sbin/init 作为符号链接指向 Systemd。Systemd 是目前最流行的 Linux init 进程,在它之前还有 SysVinit、UpStart 等 Linux init 进程。
docker中的init
在 Linux 上有了容器的概念之后,一旦容器建立了自己的 Pid Namespace(进程命名空间),这个 Namespace 里的进程号也是从 1 开始标记的。所以,容器的 init 进程也被称为 1 号进程。你只需要记住:1 号进程是第一个用户态的进程,由它直接或者间接创建了 Namespace 中的其他进程。
每个Docker容器都是一个PID命名空间,这意味着容器中的进程与主机上的其他进程是隔离的。PID命名空间是一棵树,从PID 1开始,通常称为init。
注意:当你运行一个Docker容器时,镜像的ENTRYPOINT就是你的根进程,即PID 1(如果你没有ENTRYPOINT,那么CMD就会作为根进程,你可能配置了一个shell脚本,或其他的可执行程序,容器的根进程具体是什么,完全取决于你的配置)。
PID 1在处理kill信号的特别之处
与其他进程不同的是:
- PID 1它会忽略具有默认操作的任何信号。因此除非经过编码,否则应用没有监听 SIGTERM 信号,或者应用中没有实现处理 SIGTERM 信号的逻辑,应用就不会停止。比如默认的Bash与C语言的程序,是没有注册SIGTERM 信号的handler;
- PID 1永远不会响应 SIGKILL 和 SIGSTOP 这两个特权信号;
- 对于其他的信号,如果用户自己注册了 handler,1 号进程可以响应。
把Bash当作PID 1呢?
每个基础镜像都有这个是Bash 。Bash 正确地收割了采用的子进程。Bash 可以运行任何东西。所以在你的Dockerfile中,你肯定会用这个:
CMD ["/bin/bash", "-c", "/path-to-your-app"]
Bash默认不会处理SIGTERM信号,因此这将会导致如下的问题:第一个问题是:如果将Bash作为PID 1运行,那么发送到Docker容器docker stop的信号,最终都是将 SIGTERM信号发送到Bash,但是Bash默认不会处理SIGTERM信号,也不会将它们转发到任何地方(除非您自己编写代码实现)。docker stop命令执行后,容器会有一个关闭的时限,默认为10秒,超过十秒则用kill强制关闭。换句话说,给 Bash发送SIGTERM信号终止时,会等待十秒钟,然后被内核强制终止包含所有进程的整个容器。这些进程通过 SIGKILL 信号不正常地终止。SIGKILL是特权信号,无法被捕获,因此进程无法干净地终止。假设服务正在运行的应用程序正忙于写入文件;如果应用程序在写入过程中不干净地终止,文件可能会损坏。不干净的终止是不好的。这几乎就像从服务器上拔下电源插头一样。
第二个问题是:一旦进程退出,Bash也会继续退出。如果程序出了bug退出了,Bash会退出,退出代码为0,而进程实际上崩溃了(但0表示“一切正常”;这将导致Docker或者k8s上重启策略不符合预期)。因为真正想要的可能是Bash返回与的进程相同的退出代码。
请注意,我们对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
不幸的是,这并不能解决问题。向子进程发送信号是不够的:init 进程还必须等待子进程终止,然后才能终止自己。如果 init 进程过早终止,那么所有子进程都会被内核不干净地终止。
很明显,需要一个更复杂的解决方案,但是像 Upstart、Systemd 和 SysV init 这样的完整 init 系统对于轻量级 Docker 容器来说太过分了。幸运的是,我们有很多在容器中使用的init程序。我们这里推荐使用简单的tini。
tini当作PID 1
我们在容器中启动一个init 系统有很多种,这里推荐使用 tini,它是专用于容器的轻量级 init 系统,使用方法也很简单:
请注意,Tini中还有一些额外的功能,在Bash或Java中很难实现(例如,Tini可以注册为“子收割者”,因此它实际上不需要作为PID 1运行来完成“僵尸进程”收割工作),但是这些功能对于一些高级应用场景来说非常有用。
为什么docker中会有僵尸进程?
使用容器的理想境界是一个容器只启动一个进程,但这在现实应用中有时是做不到的。
比如说,在一个容器中除了主进程之外,我们可能还会启动辅助进程,做监控或者 rotate logs;再比如说,我们需要把原来运行在虚拟机(VM)的程序移到容器里,这些原来跑在虚拟机上的程序本身就是多进程的。
一旦我们启动了多个进程,那么容器里就会出现一个 pid 1,也就是我们常说的 1 号进程或者 init 进程,然后由这个进程创建出其他的子进程。比如我们在部署java服务的时候,我们需要部署一个Arthas(阿尔萨斯),来做为java程序的诊断工具。
总结
第一个概念是 Linux 1 号进程。它是第一个用户态的进程。它直接或者间接创建了 Namespace 中的其他进程。第二个概念是容器里 1 号进程对信号处理的三个要点:
- PID 1没有默认的信号处理程序。如果应用没有监听 SIGTERM 信号,或者应用中没有实现处理 SIGTERM 信号的逻辑,应用就不会停止,容器也不会终止。
- 在容器中,1 号进程永远不会响应 SIGKILL 和 SIGSTOP 这两个特权信号;
- 对于其他的信号,如果用户自己注册了 handler,1 号进程可以响应。
第三个概念是tini作为1号进程可以给子进程传递SIGTERM信号和收割僵尸进程。