你好,我是程远。
今天,我们正式进入理解进程的模块。我会通过3讲内容,带你了解容器init进程的特殊之处,还有它需要具备哪些功能,才能保证容器在运行过程中不会出现类似僵尸进程,或者应用程序无法graceful shutdown的问题。
那么通过这一讲,我会带你掌握init进程和Linux信号的核心概念。
接下来,我们一起再现用 kill 1
命令重启容器的问题。
我猜你肯定想问,为什么要在容器中执行 kill 1
或者 kill -9 1
的命令呢?其实这是我们团队里的一位同学提出的问题。
这位同学当时遇到的情况是这样的,他想修改容器镜像里的一个bug,但因为网路配置的问题,这个同学又不想为了重建pod去改变pod IP。
如果你用过Kubernetes的话,你也肯定知道,Kubernetes上是没有 restart pod
这个命令的。这样看来,他似乎只能让pod做个原地重启了。当时我首先想到的,就是在容器中使用kill pid 1的方式重启容器。
为了模拟这个过程,我们可以进行下面的这段操作。
如果你没有在容器中做过 kill 1
,你可以下载我在GitHub上的这个例子,运行 make image
来做一个容器镜像。
然后,我们用Docker构建一个容器,用例子中的 init.sh脚本作为这个容器的init进程。
最后,我们在容器中运行 kill 1
和 kill -9 1
,看看会发生什么。
# docker stop sig-proc;docker rm sig-proc
# docker run --name sig-proc -d registry/sig-proc:v1 /init.sh
# docker exec -it sig-proc bash
[root@5cc69036b7b2 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:23 ? 00:00:00 /bin/bash /init.sh
root 8 1 0 07:25 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 100
root 9 0 6 07:27 pts/0 00:00:00 bash
root 22 9 0 07:27 pts/0 00:00:00 ps -ef
[root@5cc69036b7b2 /]# kill 1
[root@5cc69036b7b2 /]# kill -9 1
[root@5cc69036b7b2 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:23 ? 00:00:00 /bin/bash /init.sh
root 9 0 0 07:27 pts/0 00:00:00 bash
root 23 1 0 07:27 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 100
root 24 9 0 07:27 pts/0 00:00:00 ps -ef
当我们完成前面的操作,就会发现无论运行 kill 1
(对应Linux中的SIGTERM信号)还是 kill -9 1
(对应Linux中的SIGKILL信号),都无法让进程终止。
那么问题来了,这两个常常用来终止进程的信号,都对容器中的init进程不起作用,这是怎么回事呢?
要解释这个问题,我们就要回到容器的两个最基本概念——init进程和Linux信号中寻找答案。
init进程的意思并不难理解,你只要认真听我讲完,这块内容基本就不会有问题了。我们下面来看一看。
使用容器的理想境界是一个容器只启动一个进程,但这在现实应用中有时是做不到的。
比如说,在一个容器中除了主进程之外,我们可能还会启动辅助进程,做监控或者rotate logs;再比如说,我们需要把原来运行在虚拟机(VM)的程序移到容器里,这些原来跑在虚拟机上的程序本身就是多进程的。
一旦我们启动了多个进程,那么容器里就会出现一个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进程。
但无论是哪种Linux init进程,它最基本的功能都是创建出Linux系统中其他所有的进程,并且管理这些进程。具体在kernel里的代码实现如下:
init/main.c
/*
* We try each of these until one succeeds.
*
* The Bourne shell can be used instead of init if we are
* trying to recover a really broken machine.
*/
if (execute_command) {
ret = run_init_process(execute_command);
if (!ret)
return 0;
panic("Requested init %s failed (error %d).",
execute_command, ret);
}
if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0;
panic("No working init found. Try passing init= option to kernel. "
"See Linux Documentation/admin-guide/init.rst for guidance.");
$ ls -l /sbin/init
lrwxrwxrwx 1 root root 20 Feb 5 01:07 /sbin/init -> /lib/systemd/systemd
在Linux上有了容器的概念之后,一旦容器建立了自己的Pid Namespace(进程命名空间),这个Namespace里的进程号也是从1开始标记的。所以,容器的init进程也被称为1号进程。
怎么样,1号进程是不是不难理解?关于这个知识点,你只需要记住: 1号进程是第一个用户态的进程,由它直接或者间接创建了Namespace中的其他进程。
刚才我给你讲了什么是1号进程,要想解决“为什么我在容器中不能kill 1号进程”这个问题,我们还得看看kill命令起到的作用。
我们运行kill命令,其实在Linux里就是发送一个信号,那么信号到底是什么呢?这就涉及到Linux信号的概念了。
其实信号这个概念在很早期的Unix系统上就有了。它一般会从1开始编号,通常来说,信号编号是1到31,这个编号在所有的Unix系统上都是一样的。
在Linux上我们可以用 kill -l
来看这些信号的编号和名字,具体的编号和名字我给你列在了下面,你可以看一看。
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS
用一句话来概括,信号(Signal)其实就是Linux进程收到的一个通知。这些通知产生的源头有很多种,通知的类型也有很多种。
比如下面这几个典型的场景,你可以看一下:
在这一讲中,我们主要用到 SIGTERM(15)和SIGKILL(9)这两个信号,所以这里你主要了解这两个信号就可以了,其他信号以后用到时再做介绍。
进程在收到信号后,就会去做相应的处理。怎么处理呢?对于每一个信号,进程对它的处理都有下面三个选择。
第一个选择是忽略(Ignore),就是对这个信号不做任何处理,但是有两个信号例外,对于SIGKILL和SIGSTOP这个两个信号,进程是不能忽略的。这是因为它们的主要作用是为Linux kernel和超级用户提供删除任意进程的特权。
第二个选择,就是捕获(Catch),这个是指让用户进程可以注册自己针对这个信号的handler。具体怎么做我们目前暂时涉及不到,你先知道就行,我们在后面课程会进行详细介绍。
对于捕获,SIGKILL和SIGSTOP这两个信号也同样例外,这两个信号不能有用户自己的处理代码,只能执行系统的缺省行为。
还有一个选择是缺省行为(Default),Linux为每个信号都定义了一个缺省的行为,你可以在Linux系统中运行 man 7 signal
来查看每个信号的缺省行为。
对于大部分的信号而言,应用程序不需要注册自己的handler,使用系统缺省定义行为就可以了。
我刚才说了,SIGTERM(15)和SIGKILL(9)这两个信号是我们重点掌握的。现在我们已经讲解了信号的概念和处理方式,我就拿这两个信号为例,再带你具体分析一下。
首先我们来看SIGTERM(15),这个信号是Linux命令kill缺省发出的。前面例子里的命令 kill 1
,就是通过kill向1号进程发送一个信号,在没有别的参数时,这个信号类型就默认为SIGTERM。
SIGTERM这个信号是可以被捕获的,这里的“捕获”指的就是用户进程可以为这个信号注册自己的handler,而这个handler,我们后面会看到,它可以处理进程的graceful-shutdown问题。
我们再来了解一下SIGKILL (9),这个信号是Linux里两个特权信号之一。什么是特权信号呢?
前面我们已经提到过了,特权信号就是Linux为kernel和超级用户去删除任意进程所保留的,不能被忽略也不能被捕获。那么进程一旦收到SIGKILL,就要退出。
在前面的例子里,我们运行的命令 kill -9 1
里的参数“-9”,其实就是指发送编号为9的这个SIGKILL信号给1号进程。
现在,你应该理解init进程和Linux信号这两个概念了,让我们回到开头的问题上来:“为什么我在容器中不能kill 1号进程,甚至SIGKILL信号也不行?”
你还记得么,在课程的最开始,我们已经尝试过用bash作为容器1号进程,这样是无法把1号进程杀掉的。那么我们再一起来看一看,用别的编程语言写的1号进程是否也杀不掉。
我们现在用C程序作为init进程,尝试一下杀掉1号进程。和bash init进程一样,无论SIGTERM信号还是SIGKILL信号,在容器里都不能杀死这个1号进程。
# cat c-init-nosig.c
#include <stdio.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
printf("Process is sleeping\n");
while (1) {
sleep(100);
}
return 0;
}
# docker stop sig-proc;docker rm sig-proc
# docker run --name sig-proc -d registry/sig-proc:v1 /c-init-nosig
# docker exec -it sig-proc bash
[root@5d3d42a031b1 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:48 ? 00:00:00 /c-init-nosig
root 6 0 5 07:48 pts/0 00:00:00 bash
root 19 6 0 07:48 pts/0 00:00:00 ps -ef
[root@5d3d42a031b1 /]# kill 1
[root@5d3d42a031b1 /]# kill -9 1
[root@5d3d42a031b1 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:48 ? 00:00:00 /c-init-nosig
root 6 0 0 07:48 pts/0 00:00:00 bash
root 20 6 0 07:49 pts/0 00:00:00 ps -ef
我们是不是这样就可以得出结论——“容器里的1号进程,完全忽略了SIGTERM和SIGKILL信号了”呢?你先别着急,我们再拿其他语言试试。
接下来,我们用 Golang程序作为1号进程,我们再在容器中执行 kill -9 1
和 kill 1
。
这次,我们发现 kill -9 1
这个命令仍然不能杀死1号进程,也就是说,SIGKILL信号和之前的两个测试一样不起作用。
但是,我们执行 kill 1 以后,SIGTERM这个信号把init进程给杀了,容器退出了。
# cat go-init.go
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("Start app\n")
time.Sleep(time.Duration(100000) * time.Millisecond)
}
# docker stop sig-proc;docker rm sig-proc
# docker run --name sig-proc -d registry/sig-proc:v1 /go-init
# docker exec -it sig-proc bash
[root@234a23aa597b /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 1 08:04 ? 00:00:00 /go-init
root 10 0 9 08:04 pts/0 00:00:00 bash
root 23 10 0 08:04 pts/0 00:00:00 ps -ef
[root@234a23aa597b /]# kill -9 1
[root@234a23aa597b /]# kill 1
[root@234a23aa597b /]# [~]# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
对于这个测试结果,你是不是反而觉得更加困惑了?
为什么使用不同程序,结果就不一样呢?接下来我们就看看kill命令下达之后,Linux里究竟发生了什么事,我给你系统地梳理一下整个过程。
在我们运行 kill 1
这个命令的时候,希望把SIGTERM这个信号发送给1号进程,就像下面图里的带箭头虚线。
在Linux实现里,kill命令调用了 kill()的这个系统调用(所谓系统调用就是内核的调用接口)而进入到了内核函数sys_kill(), 也就是下图里的实线箭头。
而内核在决定把信号发送给1号进程的时候,会调用sig_task_ignored()这个函数来做个判断,这个判断有什么用呢?
它会决定内核在哪些情况下会把发送的这个信号给忽略掉。如果信号被忽略了,那么init进程就不能收到指令了。
所以,我们想要知道init进程为什么收到或者收不到信号,都要去看看 sig_task_ignored()的这个内核函数的实现。
在sig_task_ignored()这个函数中有三个if{}判断,第一个和第三个if{}判断和我们的问题没有关系,并且代码有注释,我们就不讨论了。
我们重点来看第二个if{}。我来给你分析一下,在容器中执行 kill 1
或者 kill -9 1
的时候,这第二个if{}里的三个子条件是否可以被满足呢?
我们来看下面这串代码,这里表示一旦这三个子条件都被满足,那么这个信号就不会发送给进程。
kernel/signal.c
static bool sig_task_ignored(struct task_struct *t, int sig, bool force)
{
void __user *handler;
handler = sig_handler(t, sig);
/* SIGKILL and SIGSTOP may not be sent to the global init */
if (unlikely(is_global_init(t) && sig_kernel_only(sig)))
return true;
if (unlikely(t->signal->flags & SIGNAL_UNKILLABLE) &&
handler == SIG_DFL && !(force && sig_kernel_only(sig)))
return true;
/* Only allow kernel generated signals to this kthread */
if (unlikely((t->flags & PF_KTHREAD) &&
(handler == SIG_KTHREAD_KERNEL) && !force))
return true;
return sig_handler_ignored(handler, sig);
}
接下来,我们就逐一分析一下这三个子条件,我们来说说这个”!(force && sig_kernel_only(sig))” 。
第一个条件里force的值,对于同一个Namespace里发出的信号来说,调用值是0,所以这个条件总是满足的。
我们再来看一下第二个条件 “handler == SIG_DFL”,第二个条件判断信号的handler是否是SIG_DFL。
那么什么是SIG_DFL呢?对于每个信号,用户进程如果不注册一个自己的handler,就会有一个系统缺省的handler,这个缺省的handler就叫作SIG_DFL。
对于SIGKILL,我们前面介绍过它是特权信号,是不允许被捕获的,所以它的handler就一直是SIG_DFL。这第二个条件对SIGKILL来说总是满足的。
对于SIGTERM,它是可以被捕获的。也就是说如果用户不注册handler,那么这个条件对SIGTERM也是满足的。
最后再来看一下第三个条件,”t->signal->flags & SIGNAL_UNKILLABLE”,这里的条件判断是这样的,进程必须是SIGNAL_UNKILLABLE的。
这个SIGNAL_UNKILLABLE flag是在哪里置位的呢?
可以参考我们下面的这段代码,在每个Namespace的init进程建立的时候,就会打上 SIGNAL_UNKILLABLE 这个标签,也就是说只要是1号进程,就会有这个flag,这个条件也是满足的。
kernel/fork.c
if (is_child_reaper(pid)) {
ns_of_pid(pid)->child_reaper = p;
p->signal->flags |= SIGNAL_UNKILLABLE;
}
/*
* is_child_reaper returns true if the pid is the init process
* of the current namespace. As this one could be checked before
* pid_ns->child_reaper is assigned in copy_process, we check
* with the pid number.
*/
static inline bool is_child_reaper(struct pid *pid)
{
return pid->numbers[pid->level].nr == 1;
}
我们可以看出来,其实最关键的一点就是 handler == SIG_DFL 。Linux内核针对每个Nnamespace里的init进程,把只有default handler的信号都给忽略了。
如果我们自己注册了信号的handler(应用程序注册信号handler被称作”Catch the Signal”),那么这个信号handler就不再是SIG_DFL 。即使是init进程在接收到SIGTERM之后也是可以退出的。
不过,由于SIGKILL是一个特例,因为SIGKILL是不允许被注册用户handler的(还有一个不允许注册用户handler的信号是SIGSTOP),那么它只有SIG_DFL handler。
所以init进程是永远不能被SIGKILL所杀,但是可以被SIGTERM杀死。
说到这里,我们该怎么证实这一点呢?我们可以做下面两件事来验证。
第一件事,你可以查看1号进程状态中SigCgt Bitmap。
我们可以看到,在Golang程序里,很多信号都注册了自己的handler,当然也包括了SIGTERM(15),也就是bit 15。
而C程序里,缺省状态下,一个信号handler都没有注册;bash程序里注册了两个handler,bit 2和bit 17,也就是SIGINT和SIGCHLD,但是没有注册SIGTERM。
所以,C程序和bash程序里SIGTERM的handler是SIG_DFL(系统缺省行为),那么它们就不能被SIGTERM所杀。
具体我们可以看一下这段/proc系统的进程状态:
### golang init
# cat /proc/1/status | grep -i SigCgt
SigCgt: fffffffe7fc1feff
### C init
# cat /proc/1/status | grep -i SigCgt
SigCgt: 0000000000000000
### bash init
# cat /proc/1/status | grep -i SigCgt
SigCgt: 0000000000010002
第二件事,给C程序注册一下SIGTERM handler,捕获SIGTERM。
我们调用signal()系统调用注册SIGTERM的handler,在handler里主动退出,再看看容器中 kill 1
的结果。
这次我们就可以看到,在进程状态的SigCgt bitmap里,bit 15 (SIGTERM)已经置位了。同时,运行 kill 1 也可以把这个C程序的init进程给杀死了。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
void sig_handler(int signo)
{
if (signo == SIGTERM) {
printf("received SIGTERM\n");
exit(0);
}
}
int main(int argc, char *argv[])
{
signal(SIGTERM, sig_handler);
printf("Process is sleeping\n");
while (1) {
sleep(100);
}
return 0;
}
# docker stop sig-proc;docker rm sig-proc
# docker run --name sig-proc -d registry/sig-proc:v1 /c-init-sig
# docker exec -it sig-proc bash
[root@043f4f717cb5 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 09:05 ? 00:00:00 /c-init-sig
root 6 0 18 09:06 pts/0 00:00:00 bash
root 19 6 0 09:06 pts/0 00:00:00 ps -ef
[root@043f4f717cb5 /]# cat /proc/1/status | grep SigCgt
SigCgt: 0000000000004000
[root@043f4f717cb5 /]# kill 1
# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
好了,到这里我们可以确定这两点:
kill -9 1
在容器中是不工作的,内核阻止了1号进程对SIGKILL特权信号的响应。kill 1
分两种情况,如果1号进程没有注册SIGTERM的handler,那么对SIGTERM信号也不响应,如果注册了handler,那么就可以响应SIGTERM信号。好了,今天的内容讲完了。我们来总结一下。
这一讲我们主要讲了init进程。围绕这个知识点,我提出了一个真实发生的问题:“为什么我在容器中不能kill 1号进程?”。
想要解决这个问题,我们需要掌握两个基本概念。
第一个概念是Linux 1号进程。它是第一个用户态的进程。它直接或者间接创建了Namespace中的其他进程。
第二个概念是Linux信号。Linux有31个基本信号,进程在处理大部分信号时有三个选择:忽略、捕获和缺省行为。其中两个特权信号SIGKILL和SIGSTOP不能被忽略或者捕获。
只知道基本概念还不行,我们还要去解决问题。我带你尝试了用bash, C语言还有Golang程序作为容器init进程,发现它们对 kill 1的反应是不同的。
因为信号的最终处理都是在Linux内核中进行的,因此,我们需要对Linux内核代码进行分析。
容器里1号进程对信号处理的两个要点,这也是这一讲里我想让你记住的两句话:
这一讲的最开始,有这样一个C语言的init进程,它没有注册任何信号的handler。如果我们从Host Namespace向它发送SIGTERM,会发生什么情况呢?
欢迎留言和我分享你的想法。如果你的朋友也对1号进程有困惑,欢迎你把这篇文章分享给他,说不定就帮他解决了一个难题。