原文地址:https://blog.csdn.net/u012271526/article/details/115961061

参考:【k8s】理解Docker容器的进程管理(PID1进程(容器内kill命令无法杀死)、进程信号处理、僵尸进程)

一、问题:容器里面kill -9 1为什么不起作用

最近在网上看到一道题目:

已运行 docker run -d -t --name demo ubuntu top 命令, docker exec -it demo kill -9 1 强行给容器内一号进程发KILL信号,容器是否会退出?
A. 是
B. 否

正确答案是B,容器不会退出

image-20220720164012554

二、知识详解

2.1、如何理解init进程

init 进程 就是 1号进程。

Linux 操作系统中,打开电源执行BIOS/boot-loader之后,boot-loader 负责加载Linux 内核。

Linux内核执行文件一般放在 /boot 目录下,文件名类似 vmlinuxz* ,在内核完成了操作系统的各种初始化后。 这个程序需要执行的第一个用户态进程就是init 进程。

内核代码启动1号进程的时候,默认从几个缺省路径尝试执行1号进程的代码。

系统启动的时候先执行内核态代码,然后在内核中调用1号进程的代码,从内核态切换到用户态。

多数系统会把 /sbin/init 作为符合链接指向 Systemd ,Systemd是目前最流行的Linux init 进程。
在此之前还有 SysVinit、Upstart等Linux init进程。

Linux init 进程的基本功能:创建出Linux系统中其他所有进程,并且管理这些进程

kernel 中代码实现如下:

https://github.com/torvalds/linux/blob/ca85855bdcae8f84f1512e88b4c75009ea17ea2f/init/main.c#L1533

	 * 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 (CONFIG_DEFAULT_INIT[0] != '\0') {
		ret = run_init_process(CONFIG_DEFAULT_INIT);
		if (ret)
			pr_err("Default init %s failed (error %d)\n",
			       CONFIG_DEFAULT_INIT, ret);
		else
			return 0;
	}

	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.");
}
[root@VM-16-9-centos ~]# ls /sbin/init  -l
lrwxrwxrwx 1 root root 22 Sep  2  2020 /sbin/init -> ../lib/systemd/systemd

容器的 init 进程也被称为 1 号进程

1 号进程是第一个用户态的进程,由它直接或者间接创建了 Namespace 中的其他进程

[root@VM-16-9-centos ~]# docker run -d --name demo ubuntu:20.04 top -b
f95ef54745f3029811f95af4f51b2d9343680ef50cc790f5d7fa05cb0c40161f
[root@VM-16-9-centos ~]# docker exec -it demo bash
root@f95ef54745f3:/# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 08:47 ?        00:00:00 top -b
root         7     0  2 08:47 pts/0    00:00:00 bash
root        15     7  0 08:47 pts/0    00:00:00 ps -ef

2.2、理解Linux信号

查看Linux所有的信号

[root@VM-16-9-centos ~]# 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	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX

进程接收到信号后,有三种处理方式:
1、忽略(Ignore)
:对接收到的信号不做任何处理,有2个信号除外 SIGTERM(15) 和 SIGKILL(9) 。因为这2个信号的作用是为 Linux kernel 和 超级用户提供删除任意进程的特权。

2、捕获(Catch)
让用户进程可以注册自己针对这个信号的handler。
SIGKILL 和 SIGSTOP 例外,这两个信号不能有用户自己的处理代码,只能执行系统的缺省行为。

3、缺省行为(Default)
每个信号都有缺省的行为。
man 7 signal 来查看每个信号的缺省行为。

image-20220720164925375

2.3、SIGTERM(15)和 SIGKILL(9)分析

kill 默认发的信号类型是SIGTERM。

SIGTERM 可以被捕获的,即用户进程可以为这个信号注册自己的 handler 。这个handler后面会看到,可以处理进程的 graceful-shutdown问题。

Linux 有2个特权信号,其中之一就是SIGKILL(9)

特权信号:Linux为kernel 和超级用户去删除任意进程所保留的,不能被忽略,也不能被捕获。进程一旦收到 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

用 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() 的这个内核函数的实现。

image-20220720165340288

在 sig_task_ignored() 这个函数中有三个 if{}判断,第一个和第三个 if{}判断和我们的问题没有关系,并且代码有注释,我们就不讨论了。

我们重点来看第二个 if{}。我来给你分析一下,在容器中执行 kill 1 或者 kill -9 1 的时候,这第二个 if{}里的三个子条件是否可以被满足呢?

我们来看下面这串代码,这里表示一旦这三个子条件都被满足,那么这个信号就不会发送给进程。

# https://github.com/torvalds/linux/blob/ca85855bdcae8f84f1512e88b4c75009ea17ea2f/kernel/signal.c#L79
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

结论:
1、kill -9 1 在容器中不工作,内核阻止了1号进程对SIGKILL 特权信号的响应
2、kill 1 分两种情况,如果1号进程没有注册SIGTERM 的 handler ,那么对SIGTERM 信号也不影响,如果注册了,那就可以响应 SIGTERM信号。

三、重点总结

2个基本概念:
1、linux的1号进程是第一个用户态的进程,直接或者间接创建了Namespace中的其他进程
2、Linux中有31种信号,进程处理信号时有三个选择,忽略,捕获,和缺省行为。 其中有两个特权信号 SIGKILL SIGSTOP 不能被忽略或者捕获。

信号的最终处理都是在Linux 内核中进行的。

容器里1号进程对信号处理的两个要点:
1、在容器中,1号进程永远不会响应 SIGKILL 和 SIGSTOP 这2个特权信号
2、对于其他的信号,如果用户自己注册了 handler,1号进程可以响应。