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上的这个例子,运行 make image 来做一个容器镜像。

然后,我们用Docker构建一个容器,用例子中的 init.sh脚本作为这个容器的init进程。

最后,我们在容器中运行 kill 1kill -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进程?

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中的其他进程。

如何理解Linux信号?

刚才我给你讲了什么是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进程收到的一个通知。这些通知产生的源头有很多种,通知的类型也有很多种。

比如下面这几个典型的场景,你可以看一下:

  • 如果我们按下键盘“Ctrl+C”,当前运行的进程就会收到一个信号SIGINT而退出;
  • 如果我们的代码写得有问题,导致内存访问出错了,当前的进程就会收到另一个信号SIGSEGV;
  • 我们也可以通过命令kill <pid>,直接向一个进程发送一个信号,缺省情况下不指定信号的类型,那么这个信号就是SIGTERM。也可以指定信号类型,比如命令 "kill -9 <pid>", 这里的9,就是编号为9的信号,SIGKILL信号。

在这一讲中,我们主要用到 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 1kill 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

好了,到这里我们可以确定这两点:

  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. 对于其他的信号,如果用户自己注册了handler,1号进程可以响应。

思考题

这一讲的最开始,有这样一个C语言的init进程,它没有注册任何信号的handler。如果我们从Host Namespace向它发送SIGTERM,会发生什么情况呢?

欢迎留言和我分享你的想法。如果你的朋友也对1号进程有困惑,欢迎你把这篇文章分享给他,说不定就帮他解决了一个难题。

精选留言

  • 赵守忠[开心每一天]

    2020-11-21 17:14:01

    kill 1 分两种情况,如果 1 号进程没有注册 SIGTERM 的 handler,那么对 SIGTERM 信号也不响应,如果注册了 handler,那么就可以响应 SIGTERM 信号。
    ---在k8s的容器环境内测试,基于tini。和老师讲的有些出入:
    bash-5.0# ps -ef
    PID USER TIME COMMAND
    1 root 0:00 /tini -- /bin/sh -c java -javaagent:/opt/jmx/jmx.jar=7080:config.yaml $JAVA_OPTS -jar /app.jar
    bash-5.0# cat /proc/1/status|grep SigCgt
    SigCgt: 0000000000000000
    bash-5.0# kill 1
    bash-5.0# command terminated with non-zero exit code: Error executing in Docker Container: 137

    实际情况是容器重启了。响应了kill 1操作。
    作者回复

    @赵守忠[开心每一天]
    非常好的发现啊!
    tini的确没有注册SIGTERM,它的退出并不是因为SIGTERM的信号让它退出的,而是它发现它子进程都退出之后,主动退出的,这样容器也退出了。

    可以看一下tini的源代码,它把所有接收到的信号(除了SIGHILD)都转发给了子进程,也包括了SIGTERM, 那么子进程收到SIGTERM就退出了,而tini自己可以收到SIGHILD, 然后tini自己退出,并且容器退出。

    tini 的源代码和主循环:
    https://github.com/krallin/tini/blob/master/src/tini.c
    while (1) {
    /* Wait for one signal, and forward it */
    if (wait_and_forward_signal(&parent_sigset, child_pid)) {
    return 1;
    }

    /* Now, reap zombies */
    if (reap_zombies(child_pid, &child_exitcode)) {
    return 1;
    }

    if (child_exitcode != -1) {
    PRINT_TRACE("Exiting: child has exited");
    return child_exitcode;
    }
    }

    2020-11-22 12:01:39

  • Helios

    2020-11-19 09:29:09

    有一种老师说了一大圈,但是没有说容器的本质就是宿主机上的一个进程这个本质。
    作者回复

    @Helios,
    很好的一个问题。
    很多介绍容器的文章可能都会强调容器是进程,不过它们讨论的背景应该是和虚拟机做比较之后这么说的,因为在容器之前虚拟机是云平台上最流行的技术。强调容器是进程的目的是区分容器与虚拟机的差别,但是我不认为这个是容器的本质。

    其实无论是namespace (pid namespace)还是cgroups都是在管理进程, 容器中运行是进程,这个是个明显的特征了,但不是本质。

    我们如果换一个角度去思考,如果容器流行先于虚拟机技术, 我们是否还会强调容器是进程了呢?

    2020-11-19 12:17:26

  • daydreamer

    2020-11-28 17:45:09

    思考题:
    kill <pid> 不可以杀掉容器init进程
    kill -9 <pid> 可以
    不同点在于SIGTERM不是内核信号,所以!(force && sig_kernel_only(sig)为True,加上前面两个if也为true,所以忽略;SIGKILL是内核信号 !(force && sig_kernel_only(sig)为False,信号没有办法忽略,所以被杀掉
    作者回复

    @daydreamer
    赞!

    2020-12-20 23:08:54

  • 上邪忘川

    2020-11-21 00:00:27

    从网上找到了不错的关于SigCgt 掩码位的解释,不懂的可以看一下,豁然开朗。https://qastack.cn/unix/85364/how-can-i-check-what-signals-a-process-is-listening-to
    作者回复

    谢谢 @上邪忘川, 很好的补充材料!

    2020-11-21 08:21:28

  • 维c

    2020-12-03 22:38:40

    查了一下资料,貌似sig_kernel_only函数是用了判断信号是不是kill或者stop的,是这两个信号才会返回true,这就意味着force不为0,同时信号是kill或者stop的时候信号是不会被忽略的,这也就解释了为什么宿主机是可以通过kill信号来杀掉容器里的进程,而sigterm由于force的值可能会被忽略,那么force的值又是又什么决定的呢?
    作者回复

    @维c
    很好的分析!force 由发送信号的进程和接受信号进程是否在同一个namespace里决定。你可以再看一下代码。

    2020-12-04 12:41:35

  • 良凯尔

    2020-11-20 09:06:27

    虽然在容器内kill 1号进程行不通,但是我可以在宿主上kill容器的1号进程来达到重启容器的目的,是这样吗?
    作者回复

    在宿主机上kill容器的1号进程是可以的。不过,有时候容器的用户没有宿主机登陆的权限。

    2020-11-20 12:37:56

  • 莫名

    2020-11-19 08:55:16

    man pid_namespace 提到了老师在文中强调的两个细节。

    -----------------------------

    Only signals for which the "init" process has established a signal
    handler can be sent to the "init" process by other members of the PID
    namespace. This restriction applies even to privileged processes,
    and prevents other members of the PID namespace from accidentally
    killing the "init" process.

    Likewise, a process in an ancestor namespace can—subject to the usual
    permission checks described in kill(2)—send signals to the "init"
    process of a child PID namespace only if the "init" process has
    established a handler for that signal. SIGKILL or SIGSTOP are treated exceptionally: these signals are
    forcibly delivered when sent from an
    作者回复

    谢谢 @莫名!
    仔细读文档也是很有用的!

    2020-11-19 10:44:48

  • Action

    2021-01-05 16:26:48

    Host Namespace 向c程序init进程,发送SIGTERM,忽略了,发送SIGKILL杀掉了,是特权信号就给杀掉了对吧? 还有一块不明白,在handler == SIG_DFL这里,SIGTERM,它是可以被捕获的。也就是说如果用户不注册handler,那么这个条件对 SIGTERM 也是满足的,为什么呢?
    作者回复

    至于为什么即使在宿主机机上向容器1号进程发送SIGTERM,在1号进程没有注册handler的情况下,不能被杀死的问题 (思考题), 原因是这样的:

    开始要看内核里的那段代码,“ !(force && sig_kernel_only(sig))”,
    虽然由不同的namespace发送信号, 虽然force是1了,但是sig_kernel_only(sig)对于SIGTERM来说还是0, 这里是个&&, 那么 !(1 && 0) = 1。

    #define sig_kernel_only(sig) siginmask(sig, SIG_KERNEL_ONLY_MASK)
    #define SIG_KERNEL_ONLY_MASK (\
    rt_sigmask(SIGKILL) | rt_sigmask(SIGSTOP))

    2021-01-06 23:14:12

  • Helios

    2020-11-19 09:25:07

    不知道什么要在容器内执行,直接在去宿主机上docker kill不行么。或者直接edit一下编排文件加个环境变量啥的,不就能触发原地升级么。
    比较新的信号应该不止31个了,还增加了31个可靠信号,为了解决以前linux中信号堆积忽略信号的问题。

    作者回复

    > 不知道什么要在容器内执行,直接在去宿主机上docker kill不行么。或者直接edit一下编排文件加个环境变量啥的,不就能触发原地升级么。

    在生产环境中,用户是没有权限登陆宿主机的。 在Pod spec部分,runtime允许修改的只有 cotainer image了。

    > 比较新的信号应该不止31个了,还增加了31个可靠信号,为了解决以前linux中信号堆积忽略信号的问题。

    你说的没错,还有新的 32-64 可靠信号。因为和这一讲的问题没有太大关系,在这里就不展开了。

    2020-11-19 10:38:07

  • 白夜行

    2021-01-20 18:42:41

    老师讲的太棒了! 留言区也能学到很多。

    请教老师一个可能不太相关的问题,golang为什么要默认注册很多信号处理函数,而不是保持C的行为? 
  • 1900

    2020-11-19 10:05:50

    在容器中不能响应SIGKILL 和 SIGSTOP,但是在宿主机中可以响应,因为在宿主机中所看到的“容器1号进程”在宿主机上只是一个普通进程
    作者回复

    @1900, 如果可以的话,你可以动手试一下,看看结果是不是和你分析的一样 :-)

    2020-11-19 22:51:47

  • 朱雯

    2020-11-19 00:03:22

    关于SigCgt bitmap 其实我是有些疑惑的,第一为什么是16位?我最开始是这样猜测的,就是一个位置代表一个信号量,那最多只能说明第一到第十六的信号量。 后面看到加了handle处理的函数是这个样子

    if (signo == SIGTERM) { printf("received SIGTERM\n"); exit(0); }
    然后他的sigcgt bitmap是:0000000000004000 ,如果按照这样的算法,一个数字代表4位,那一共16位,应该有64种信号量,为啥只有31种还是32种,剩下的是没定义?
    作者回复

    @朱雯
    很好的问题!我在文中只是说了31个posix标准里的signal. Linux 里还有编号32-64的real-time signal的扩展定义。因为和这一讲的问题没有太大的关系,我没有具体展开了。

    2020-11-19 12:06:01

  • Geek_71d4ac

    2020-11-18 08:30:52

    关于特权信号的那一段表述,我觉得是有问题的。当任务处于task uninterrupt状态时,是不能接收任何信号的。
    作者回复

    @Geek_71d4ac
    你说的没错, D state (uninterruptible) 进程 还有 Zombie进程都是不能接受任何信号的。我在后面的章节里还有介绍。

    2020-11-18 11:02:19

  • @@fighting

    2021-03-18 19:53:55

    根据go官方文档来看,是因为 go 的 runtime,自动注册了 SIGTERM 信号,https://pkg.go.dev/os/signal#section-directories
    作者回复

    是的!

    2021-03-20 20:40:39

  • 罗峰

    2021-09-06 08:46:48

    好奇,容器内不能用kill stop杀死一号,那么docker退出容器是通过啥方式呢?
  • JianXu

    2020-11-19 21:06:55

    if (unlikely(t->signal->flags & SIGNAL_UNKILLABLE) && handler == SIG_DFL && !(force && sig_kernel_only(sig))) { return true;}

    老师, 我是不是可以这样理解,在容器内部的时候对于没有安装SIGTERM handler的情况下,force=0 并且SIGNAL_UNKILLABLE 也是置位的,所以这个if 语句返回真,所以SIGTERM 被忽略。但是在宿主机上的时候,因为不是同一个namespace 所以force = 1 ,因为不是宿主机上的第一个进程所以 UNKILLABLE 也没有置位(其实在force=1的时候已经不重要了) 所以这个 if 返回false, 而因为不是Kernal sig, 所以接下来第三个 if 也不会返回true, 于是这三个if 都不会起作用,所以从宿主机可以干掉该进程。

    能不能进一步介绍一下为什么在Kernel 里面要放置这三个 if 语句来 ignore signal 呢?
    作者回复

    @JIanxu,
    > 因为不是宿主机上的第一个进程所以 UNKILLABLE 也没有置位

    这个SIGNAL_UNKILLABLE 是目标进程(容器里的1号进程)在创建的时候置位的,所以无论发送信号的进程在容器namespace还是在host namespace, 这个flag都是存在的。

    > 能不能进一步介绍一下为什么在Kernel 里面要放置这三个 if 语句来 ignore signal 呢?

    这里可以看一下这段代码最初check-in的comments. 我的理解是如果1号进程被杀,会是整个系统处于混乱并且难调试的状态,我们要尽量的避免这种情况。

    commit 86989c41b5ea08776c450cb759592532314a4ed6
    Author: Eric W. Biederman <ebiederm@xmission.com>
    Date: Thu Jul 19 19:47:27 2018 -0500

    signal: Always ignore SIGKILL and SIGSTOP sent to the global init

    If the first process started (aka /sbin/init) receives a SIGKILL it
    will panic the system if it is delivered. Making the system unusable
    and undebugable. It isn't much better if the first process started
    receives SIGSTOP.

    So always ignore SIGSTOP and SIGKILL sent to init.

    This is done in a separate clause in sig_task_ignored as force_sig_info
    can clear SIG_UNKILLABLE and this protection should work even then.

    Reviewed-by: Thomas Gleixner <tglx@linutronix.de>
    Signed-off-by: "Eric W. Biederman" <ebiederm@xmission.com>

    2020-11-19 23:18:53

  • 争光 Alan

    2020-11-19 03:37:04

    能不能讲完后,讲下社区的最佳实践,比如docker现在提供了docker run --init参数避免这个问题,内核层面有没有相关的优化跟进
    作者回复

    @争光Alan, 谢谢,很好的建议!
    我在这一章的最后,也拿了tini做了example来做一些简单best practice的说明。

    2020-11-19 10:52:30

  • 朱雯

    2020-11-19 00:09:44

    关于思考题:
    这一讲的最开始,有这样一个 C 语言的 init 进程,它没有注册任何信号的 handler。如果我们从 Host Namespace 向它发送 SIGTERM,会发生什么情况呢?
    啥叫从host namespace向他发送sigterm,这是啥意思,是宿主机对他发送sigterm吗,宿主机发送,那就直接把他杀了,不仅法sigterm会杀,发kill也多半会杀,因为在宿主机,不同的namespace,force不一定为0,所以肯定不会被忽略,我的问题在于SIGNAL_UNKILLABLE 标签还会不会打上,打上以后是对宿主机这个标签也生效吗。
    作者回复

    > 啥叫从host namespace向他发送sigterm,这是啥意思,是宿主机对他发送sigterm吗
    是的,在宿主机上的缺省namespace是host namespace.

    > 不同的namespace,force不一定为0
    是的, signal sender不在同一个namespace的时候, force不为0.

    > 我的问题在于SIGNAL_UNKILLABLE 标签还会不会打上,打上以后是对宿主机这个标签也生效吗。
    进程创建后这个标签是一直有的,只是pid在容器namespace里看到的是1,而在宿主机的namespace里是另外一个pid值

    2020-11-19 21:51:35

  • 寻道客小林

    2021-12-04 18:21:49

    真的很干!这一节专注的学了3个小时,只有自己动手实现才能体会的更深入。感谢程远老师的精彩讲解!就羡慕这种懂技术又会布道的老师!
    关于信号的资料,大家可以参考《深入理解Linux内核》第十一章
  • 撕裂的天堂

    2021-08-16 16:50:50

    那么Java有没有注册handler呢