You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

472 lines
23 KiB
Markdown

2 years ago
# 02 | 理解进程1为什么我在容器中不能kill 1号进程
你好,我是程远。
今天我们正式进入理解进程的模块。我会通过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上的这个[例子](https://github.com/chengyli/training/tree/master/init_proc/handle_sig),运行 `make image` 来做一个容器镜像。
然后我们用Docker构建一个容器用例子中的 **init.sh脚本**作为这个容器的init进程。
最后,我们在容器中运行 `kill 1``kill -9 1` ,看看会发生什么。
```shell
# 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进程
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里的代码实现如下
```shell
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.");
```
```shell
$ 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中的其他进程。**
### 如何理解Linux信号
刚才我给你讲了什么是1号进程要想解决“为什么我在容器中不能kill 1号进程”这个问题我们还得看看kill命令起到的作用。
我们运行kill命令其实在Linux里就是发送一个信号那么信号到底是什么呢这就涉及到Linux信号的概念了。
其实信号这个概念在很早期的Unix系统上就有了。它一般会从1开始编号通常来说信号编号是1到31这个编号在所有的Unix系统上都是一样的。
在Linux上我们可以用 `kill -l` 来看这些信号的编号和名字,具体的编号和名字我给你列在了下面,你可以看一看。
```shell
$ 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进程收到的一个通知。**这些通知产生的源头有很多种,通知的类型也有很多种。
比如下面这几个典型的场景,你可以看一下:
* 如果我们按下键盘“Ctrl+C”当前运行的进程就会收到一个信号SIGINT而退出
* 如果我们的代码写得有问题导致内存访问出错了当前的进程就会收到另一个信号SIGSEGV
* 我们也可以通过命令kill <pid>直接向一个进程发送一个信号缺省情况下不指定信号的类型那么这个信号就是SIGTERM。也可以指定信号类型比如命令 "kill -9 <pid>", 这里的9就是编号为9的信号SIGKILL信号。
在这一讲中,我们主要用到 **SIGTERM15和SIGKILL9这两个信号**,所以这里你主要了解这两个信号就可以了,其他信号以后用到时再做介绍。
进程在收到信号后,就会去做相应的处理。怎么处理呢?对于每一个信号,进程对它的处理都有下面三个选择。
第一个选择是**忽略Ignore**就是对这个信号不做任何处理但是有两个信号例外对于SIGKILL和SIGSTOP这个两个信号进程是不能忽略的。这是因为它们的主要作用是为Linux kernel和超级用户提供删除任意进程的特权。
第二个选择,就是**捕获Catch**这个是指让用户进程可以注册自己针对这个信号的handler。具体怎么做我们目前暂时涉及不到你先知道就行我们在后面课程会进行详细介绍。
**对于捕获SIGKILL和SIGSTOP这两个信号也同样例外这两个信号不能有用户自己的处理代码只能执行系统的缺省行为。**
还有一个选择是**缺省行为Default**Linux为每个信号都定义了一个缺省的行为你可以在Linux系统中运行 `man 7 signal`来查看每个信号的缺省行为。
对于大部分的信号而言应用程序不需要注册自己的handler使用系统缺省定义行为就可以了。
![](https://static001.geekbang.org/resource/image/da/0d/dae0e2bdfb4bae2d900e58cb3490dc0d.jpeg)
我刚才说了SIGTERM15和SIGKILL9这两个信号是我们重点掌握的。现在我们已经讲解了信号的概念和处理方式我就拿这两个信号为例再带你具体分析一下。
首先我们来看SIGTERM15这个信号是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号进程。
```shell
# 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;
}
```
```shell
# 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进程给杀了容器退出了。**
```shell
# cat go-init.go
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("Start app\n")
time.Sleep(time.Duration(100000) * time.Millisecond)
}
```
```shell
# 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()的这个内核函数的实现。**
![](https://static001.geekbang.org/resource/image/ce/7f/cec445b6af1c0f678cc1b538bb03d67f.jpeg "sig_task_ignored()内核函数实现示意图")
在sig\_task\_ignored()这个函数中有三个if{}判断第一个和第三个if{}判断和我们的问题没有关系,并且代码有注释,我们就不讨论了。
我们重点来看第二个if{}。我来给你分析一下,在容器中执行 `kill 1` 或者 `kill -9 1` 的时候这第二个if{}里的三个子条件是否可以被满足呢?
我们来看下面这串代码,这里表示**一旦这三个子条件都被满足,那么这个信号就不会发送给进程。**
```shell
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这个条件也是满足的。
```shell
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程序里注册了两个handlerbit 2和bit 17也就是SIGINT和SIGCHLD但是没有注册SIGTERM。
所以C程序和bash程序里SIGTERM的handler是SIG\_DFL系统缺省行为那么它们就不能被SIGTERM所杀。
具体我们可以看一下这段/proc系统的进程状态
```shell
### 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进程给杀死了。**
```shell
#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;
}
```
```shell
# 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
```
好了,到这里我们可以确定这两点:
1. `kill -9 1` 在容器中是不工作的内核阻止了1号进程对SIGKILL特权信号的响应。
2. `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号进程对信号处理的两个要点这也是这一讲里我想让你记住的两句话
1. **在容器中1号进程永远不会响应SIGKILL和SIGSTOP这两个特权信号**
2. **对于其他的信号如果用户自己注册了handler1号进程可以响应。**
## 思考题
这一讲的最开始有这样一个C语言的init进程它没有注册任何信号的handler。如果我们从Host Namespace向它发送SIGTERM会发生什么情况呢
欢迎留言和我分享你的想法。如果你的朋友也对1号进程有困惑欢迎你把这篇文章分享给他说不定就帮他解决了一个难题。