【容器实战高手课-学习-2】容器中kill 1号进程的坑

Demon.Lee 2021年05月18日 2,293次浏览

课程原文: 李程远. 理解进程(1):为什么我在容器中不能kill 1号进程?

本文实践环境:
Operating System: Ubuntu 20.04.2 LTS
Kernel: Linux 5.8.0-50-generic
Architecture: x86-64

Docker Client/Server Version: 20.10.5

问题重现

为了直观的说明问题,这里将使用几个示例进行演示。在容器中,通过kill 1命令将1号进程杀掉,即停止容器。

Shell 程序

demonlee@demonlee-ubuntu:process$ ll
total 16
drwxrwxr-x 2 demonlee demonlee 4096 5月  12 23:31 ./
drwxrwxr-x 7 demonlee demonlee 4096 5月  12 23:09 ../
-rw-rw-r-- 1 demonlee demonlee   71 5月  12 23:31 Dockerfile_sh
-rw-rw-r-- 1 demonlee demonlee  104 5月  12 23:21 test-kill.sh
demonlee@demonlee-ubuntu:process$ cat test-kill.sh 
#!/bin/bash

while true
do
    sleep 2
    time=`date '+%Y/%m/%d %H:%M:%S'`
    echo "time: $time"
done
demonlee@demonlee-ubuntu:process$ cat Dockerfile_sh 
FROM centos:8.1.1911
WORKDIR /home/proc
COPY ./test-kill.sh /home/proc
demonlee@demonlee-ubuntu:process$ docker build -t registry/proc-sh:v1 -f Dockerfile_sh .
Sending build context to Docker daemon  3.072kB
Step 1/3 : FROM centos:8.1.1911
 ---> 470671670cac
Step 2/3 : WORKDIR /home/proc
 ---> Running in fe358915b776
Removing intermediate container fe358915b776
 ---> f7f393c8d161
Step 3/3 : COPY ./test-kill.sh /home/proc
 ---> 6e01fa9348f0
Successfully built 6e01fa9348f0
Successfully tagged registry/proc-sh:v1
emonlee@demonlee-ubuntu:process$ docker run -d --name sh-kill-demo registry/proc-sh:v1 sh ./test-kill.sh
b1378b6545098fb160cf5118141b5196a3d225b001566565509bf7d1f722ebc9
demonlee@demonlee-ubuntu:process$ docker ps
CONTAINER ID   IMAGE                 COMMAND               CREATED          STATUS         PORTS     NAMES
b1378b654509   registry/proc-sh:v1   "sh ./test-kill.sh"   10 seconds ago   Up 9 seconds             sh-kill-demo
demonlee@demonlee-ubuntu:process$ docker logs -f sh-kill-demo 
time: 2021/05/12 15:32:28
time: 2021/05/12 15:32:30
time: 2021/05/12 15:32:32
...
demonlee@demonlee-ubuntu:process$ docker exec -it sh-kill-demo bash
[root@b1378b654509 proc]# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0  11896  2840 ?        Ss   15:32   0:00 sh ./test-kill.sh
root          47  1.0  0.0  12028  3336 pts/0    Ss   15:33   0:00 bash
root          65  0.0  0.0  23032  1400 ?        S    15:33   0:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 2
root          66  0.0  0.0  43964  3284 pts/0    R+   15:33   0:00 ps aux
[root@b1378b654509 proc]# 
[root@b1378b654509 proc]# kill 1
[root@b1378b654509 proc]# kill -9 1
[root@b1378b654509 proc]# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0  11896  2840 ?        Ss   15:32   0:00 sh ./test-kill.sh
root          47  0.2  0.0  12028  3336 pts/0    Ss   15:33   0:00 bash
root          76  0.0  0.0  23032  1392 ?        S    15:33   0:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/bin/sleep 2
root          77  0.0  0.0  43964  3268 pts/0    R+   15:33   0:00 ps aux
[root@b1378b654509 proc]#

ok,从上面的演示过程(后面其他的演示程序比较类似,就只贴出核心内容),可以看到shell程序在容器中运行后,通过kill 1 和 kill -9 1都没能将其杀掉。

Java语言程序

demonlee@demonlee-ubuntu:process$ cat ProcKill.java 
import java.time.LocalDateTime;

public class ProcKill {

    public static void main(String[] args) throws InterruptedException {
        while (true) {
            Thread.sleep(2000L);
            System.out.println("time: " + LocalDateTime.now());
        }
    }
}

demonlee@demonlee-ubuntu:process$ javac ProcKill.java 
demonlee@demonlee-ubuntu:process$ cat Dockerfile_java 
FROM centos:8.1.1911
RUN yum install java-11-openjdk-devel -y
RUN yum install java-11-openjdk -y
WORKDIR /home/proc
COPY ./ProcKill.class /home/proc
CMD ["java","ProcKill"]
demonlee@demonlee-ubuntu:process$ docker run -d --name java-kill-demo registry/proc-java:v1
c3a5a13079b4
demonlee@demonlee-ubuntu:process$ docker exec -it java-kill-demo /bin/bash
[root@c3a5a13079b4 proc]# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.4  0.6 4204684 38128 ?       Ssl  14:50   0:00 java ProcKill
root          25  3.0  0.0  12028  3320 pts/0    Ss   14:51   0:00 /bin/bash
root          40  0.0  0.0  43964  3324 pts/0    R+   14:51   0:00 ps aux
[root@c3a5a13079b4 proc]# kill -9 1
[root@c3a5a13079b4 proc]# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.3  0.6 4204684 38128 ?       Ssl  14:50   0:00 java ProcKill
root          25  0.3  0.0  12028  3320 pts/0    Ss   14:51   0:00 /bin/bash
root          41  0.0  0.0  43964  3288 pts/0    R+   14:51   0:00 ps aux
[root@c3a5a13079b4 proc]# kill 1
[root@c3a5a13079b4 proc]# demonlee@demonlee-ubuntu:process$

java程序 kill -9 1 不起作用,但kill 1可以将容器杀掉。

C程序

demonlee@demonlee-ubuntu:process$ cat ProcKill.c
#include <stdio.h>
#include <unistd.h>
#include<time.h>

int main(int argc, char *argv[]) {
	time_t t;
	while (1) {
		sleep(2);
		time(&t);
		printf("current time is : %s",ctime(&t));
	}

	return 0;
}
demonlee@demonlee-ubuntu:process$ cat Dockerfile_c
FROM centos:8.1.1911
WORKDIR /home/proc
COPY ./ProcKill /home/proc
CMD ["./ProcKill"]
demonlee@demonlee-ubuntu:process$ docker exec -it c-kill-demo /bin/bash
[root@7b3b70801f39 proc]# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0   4340   796 ?        Ss   15:08   0:00 ./ProcKill
root           8  1.5  0.0  12028  3280 pts/0    Ss   15:08   0:00 /bin/bash
root          23  0.0  0.0  43964  3356 pts/0    R+   15:08   0:00 ps aux
[root@7b3b70801f39 proc]# kill -9 1
[root@7b3b70801f39 proc]# kill 1
[root@7b3b70801f39 proc]# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0   4340   796 ?        Ss   15:08   0:00 ./ProcKill
root           8  0.1  0.0  12028  3280 pts/0    Ss   15:08   0:00 /bin/bash
root          24  0.0  0.0  43964  3332 pts/0    R+   15:09   0:00 ps aux
[root@7b3b70801f39 proc]#

可以看到,这次跟shell脚本程序一样,无法杀掉容器中的1号进程。

到底是怎么回事?为什么有的可以,有的不行。

为了找到原因,我们需要先弄明白两个东西:1号进程和kill操作的本质。

Linux init 进程

一个Linux系统打开电源,执行BIOS/boot-loader之后,就会由boot-loader负责加载Linux内核。

内核初始化的过程中,会先后初始化0号、1号、2号进程。

  • 0号进程:系统创建的第一个进程,这是唯一一个没有没有通过fork或者kernel_thread产生的进程,是进程列表的第一个。

    进程0执行的是cpu_idle函数,该函数仅有一条hlt汇编指令,就是系统闲置时用来降低能耗省电的。同时进程0的PCB叫作init_task,在很多链表中起了表头的作用。当就绪队列中没有其他进程时,进程0就会被调度程序选中,以此来省电,减少热量的产生,如果就绪队列中有其他进程,那么0号进程就不会运行了。

  • 1号进程:内核启动过程中会调用kernel_thread(kernel_init, NULL, CLONE_FS)创建第2个进程,这个就是1号进程,即init进程。内核调用1号进程的代码时,会从内核态切换到用户态。所以1号进程是一个用户态进程(即用户态所有进程的祖先),其基本功能就是创建出系统中所有的用户态进程,并管理它们。

    1号进程运行的是一个可执行文件,会首先尝试运行ramdisk中的"/init"文件,或者普通文件系统上的"/sbin/init",“/etc/init”,“/bin/init”, “/bin/sh”,不同版本的Linux会选择不同的文件启动,但只要有一个起来了就行。目前主流的Linux发行版,都会把/sbin/init作为符号链接指向Systemd,Systemd是目前最流行的Linux init进程。

    (这里的ramdisk是系统初始化时基于内存的根文件系统,目的是解决"/init"程序的存储问题。如果从普通存储设备上获取"/init"可执行文件,那就必须要有各种磁盘驱动才能读到我们需要的文件,这样一来,内核就复杂了。而内核访问内存是不需要驱动的,所以实现了一个基于内存的根文件系统,并将"/init"文件放进去。)

    demonlee@demonlee-ubuntu:process$ ll /sbin/init
    lrwxrwxrwx 1 root root 20 4月  10 14:01 /sbin/init -> /lib/systemd/systemd*
    demonlee@demonlee-ubuntu:process$ 
    

    内核源码如下:

    // init/main.c
    static int __ref kernel_init(void *unused) {
    	int ret;
    
    	kernel_init_freeable();
    	/* need to finish all async __init code before freeing the memory */
    	async_synchronize_full();
    	ftrace_free_init_mem();
    	free_initmem();
    	mark_readonly();
    
    	/*
    	 * Kernel mappings are now finalized - update the userspace page-table
    	 * to finalize PTI.
    	 */
    	pti_finalize();
    
    	system_state = SYSTEM_RUNNING;
    	numa_default_policy();
    
    	rcu_end_inkernel_boot();
    
    	do_sysctl_args();
    
    	if (ramdisk_execute_command) {
    		ret = run_init_process(ramdisk_execute_command);
    		if (!ret)
    			return 0;
    		pr_err("Failed to execute %s (error %d)\n",
    		       ramdisk_execute_command, ret);
    	}
    
    	/*
    	 * 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.");
    }
    
  • 2号进程:用户态所有进程的祖先是1号进程,而内核态所有进程的祖先是2号进程。内核会通过kernel_thread(kthreadd, NULL, CLONE_FS|CLONE_FILES)创建第3个进程,这个就是2号进程,负责所有内核态的线程调度和管理。

通过在Ubuntu系统上运行ps -ef,对应的第2,3列分别表示进程的id(PID)和父id(PPID),从数字上我们可以进一步理解各个进程之间的关系。

demonlee@demonlee-ubuntu:process$ ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 11:20 ?        00:00:04 /lib/systemd/systemd --system --deserialize 12
root           2       0  0 11:20 ?        00:00:00 [kthreadd]
root           3       2  0 11:20 ?        00:00:00 [rcu_gp]
root           4       2  0 11:20 ?        00:00:00 [rcu_par_gp]
root           6       2  0 11:20 ?        00:00:00 [kworker/0:0H-kblockd]
root           9       2  0 11:20 ?        00:00:00 [mm_percpu_wq]
root          10       2  0 11:20 ?        00:00:00 [ksoftirqd/0]
...
root         333       2  0 11:20 ?        00:00:00 [iprt-VBoxWQueue]
root         349       2  0 11:20 ?        00:00:00 [cryptd]
systemd+     579       1  0 11:20 ?        00:00:00 /lib/systemd/systemd-resolved
systemd+     581       1  0 11:20 ?        00:00:00 /lib/systemd/systemd-timesyncd
...
demonlee    1644       1  0 11:21 ?        00:00:01 /lib/systemd/systemd --user
demonlee    1645    1644  0 11:21 ?        00:00:00 (sd-pam)
demonlee    1650    1644  0 11:21 ?        00:00:00 /usr/bin/pulseaudio --daemonize=no --log-target=journal
demonlee    1652    1644  0 11:21 ?        00:00:00 /usr/libexec/tracker-miner-fs
...
demonlee    1726    1644  0 11:21 ?        00:00:00 /usr/libexec/gvfs-mtp-volume-monitor
demonlee    1757    1718  0 11:21 tty2     00:00:00 /usr/libexec/gnome-session-binary --systemd --systemd --session=ubuntu
demonlee    1828       1  0 11:21 ?        00:00:00 /usr/bin/VBoxClient --clipboard
demonlee    1830    1828  0 11:21 ?        00:00:00 /usr/bin/VBoxClient --clipboard
...

善于思考的小伙伴可以看到,这里ps -ef的结果里面没有0号进程,其实上面已经提到了:0号进程此时没有运行。

有了上面对1号进程的介绍,以此类推,容器内有独立的Pid Namespace,其1号进程就是该容器内的init进程,容器内其他进程都由init进程创建。

Linux 信号

信号(Signal)就是Linux进程收到的一个通知,它一般会从1开始编号,通过kill -l 命令可以查看当前系统中所有信号,并通过man 7 signal 可以阅读说明文档。

demonlee@demonlee-ubuntu:process$ 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	
demonlee@demonlee-ubuntu:process$ man 7 signal
  ...
  DESCRIPTION
       Linux supports both POSIX reliable signals (hereinafter "standard signals") and POSIX real-time signals.

   Signal dispositions
       Each signal has a current disposition, which determines how the process behaves when it is delivered the signal.

       The entries in the "Action" column of the table below specify the default disposition for each signal, as follows:
       Term   Default action is to terminate the process.
       Ign    Default action is to ignore the signal.
       Core   Default action is to terminate the process and dump core (see core(5)).
       Stop   Default action is to stop the process.
       Cont   Default action is to continue the process if it is currently stopped.
  ...
  Standard signals
       Linux supports the standard signals listed below.  The second column of the table indicates which  standard  (if  any)  specified  the  signal:
       "P1990"  indicates that the signal is described in the original POSIX.1-1990 standard; "P2001" indicates that the signal was added in SUSv2 and
       POSIX.1-2001.

       Signal      Standard   Action   Comment
       ────────────────────────────────────────────────────────────────────────
       SIGABRT      P1990      Core    Abort signal from abort(3)
       SIGALRM      P1990      Term    Timer signal from alarm(2)
       SIGBUS       P2001      Core    Bus error (bad memory access)
       SIGCHLD      P1990      Ign     Child stopped or terminated
       SIGCLD         -        Ign     A synonym for SIGCHLD
       SIGCONT      P1990      Cont    Continue if stopped
       SIGEMT         -        Term    Emulator trap
       SIGFPE       P1990      Core    Floating-point exception
       SIGHUP       P1990      Term    Hangup detected on controlling terminal
                                       or death of controlling process
       SIGILL       P1990      Core    Illegal Instruction
       SIGINFO        -                A synonym for SIGPWR
       SIGINT       P1990      Term    Interrupt from keyboard
       SIGIO          -        Term    I/O now possible (4.2BSD)
       SIGIOT         -        Core    IOT trap. A synonym for SIGABRT
       SIGKILL      P1990      Term    Kill signal
       SIGLOST        -        Term    File lock lost (unused)
       SIGPIPE      P1990      Term    Broken pipe: write to pipe with no
                                       readers; see pipe(7)
       SIGPOLL      P2001      Term    Pollable event (Sys V).
                                       Synonym for SIGIO
       SIGPROF      P2001      Term    Profiling timer expired
       SIGPWR         -        Term    Power failure (System V)
       SIGQUIT      P1990      Core    Quit from keyboard
       SIGSEGV      P1990      Core    Invalid memory reference
       SIGSTKFLT      -        Term    Stack fault on coprocessor (unused)

       SIGSTOP      P1990      Stop    Stop process
       SIGTSTP      P1990      Stop    Stop typed at terminal
       SIGSYS       P2001      Core    Bad system call (SVr4);
                                       see also seccomp(2)
       SIGTERM      P1990      Term    Termination signal
       SIGTRAP      P2001      Core    Trace/breakpoint trap
       SIGTTIN      P1990      Stop    Terminal input for background process
       SIGTTOU      P1990      Stop    Terminal output for background process
       SIGUNUSED      -        Core    Synonymous with SIGSYS
       SIGURG       P2001      Ign     Urgent condition on socket (4.2BSD)
       SIGUSR1      P1990      Term    User-defined signal 1
       SIGUSR2      P1990      Term    User-defined signal 2
       SIGVTALRM    P2001      Term    Virtual alarm clock (4.2BSD)
       SIGXCPU      P2001      Core    CPU time limit exceeded (4.2BSD);
                                       see setrlimit(2)
       SIGXFSZ      P2001      Core    File size limit exceeded (4.2BSD);
                                       see setrlimit(2)
       SIGWINCH       -        Ign     Window resize signal (4.3BSD, Sun)

       The signals SIGKILL and SIGSTOP cannot be caught, blocked, or ignored.
			...
			...

为了便于理解,这里用前面的C程序ProcKill进行说明。当我们在宿主机上直接运行该程序,终端上会打印出对应的日志,此时我们通过键盘按下"Ctrl-C" ,就会看到进程退出了。而这背后其实就是ProcKill进程收到了编号为2的 SIGINT信号:Interrupt from keyboard。

demonlee@demonlee-ubuntu:process$ ./ProcKill
current time is : Sun May 16 00:43:51 2021
current time is : Sun May 16 00:43:53 2021
current time is : Sun May 16 00:43:55 2021
current time is : Sun May 16 00:43:57 2021
current time is : Sun May 16 00:43:59 2021
current time is : Sun May 16 00:44:01 2021
current time is : Sun May 16 00:44:03 2021
current time is : Sun May 16 00:44:05 2021
current time is : Sun May 16 00:44:07 2021
current time is : Sun May 16 00:44:09 2021
^C
demonlee@demonlee-ubuntu:process$ 

除了通过键盘输入"Ctrl-C",我们还可以通过kill 来给进程直接发送信号,比如上面的例子,我们可以在另外一个终端中输入kill -2 来达到同样的效果。怎么确定进程收到的是哪个信号呢?可以使用strace工具进行跟踪(启动一个新的终端),相关操作如下:

demonlee@demonlee-ubuntu:process$ sudo strace -p 37088
strace: Process 37088 attached
restart_syscall(<... resuming interrupted read ...>) = 0
stat("/etc/localtime", {st_mode=S_IFREG|0644, st_size=573, ...}) = 0
write(1, "current time is : Sun May 16 00:"..., 43) = 43
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=2, tv_nsec=0}, 0x7ffeef5397e0) = 0
stat("/etc/localtime", {st_mode=S_IFREG|0644, st_size=573, ...}) = 0
write(1, "current time is : Sun May 16 00:"..., 43) = 43
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=2, tv_nsec=0}, 0x7ffeef5397e0) = 0
stat("/etc/localtime", {st_mode=S_IFREG|0644, st_size=573, ...}) = 0
write(1, "current time is : Sun May 16 00:"..., 43) = 43
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=2, tv_nsec=0}, 0x7ffeef5397e0) = 0
stat("/etc/localtime", {st_mode=S_IFREG|0644, st_size=573, ...}) = 0
write(1, "current time is : Sun May 16 00:"..., 43) = 43
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=2, tv_nsec=0}, 0x7ffeef5397e0) = 0
stat("/etc/localtime", {st_mode=S_IFREG|0644, st_size=573, ...}) = 0
write(1, "current time is : Sun May 16 00:"..., 43) = 43
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=2, tv_nsec=0}, {tv_sec=0, tv_nsec=921355614}) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
--- SIGINT {si_signo=SIGINT, si_code=SI_USER, si_pid=37082, si_uid=1000} ---
+++ killed by SIGINT +++
demonlee@demonlee-ubuntu:process$ 

看到最后几行,果然是SIGINT信号干的。

那收到信号后,进程如何响应呢?在Linux下,对于每一个信号,进程的响应方式有3种选择:

Option Description
Ignore(忽略) 对信号不做任何处理,但SIGKILL和SIGSTOP例外
Catch(捕获) 用户进程自己注册针对某个信号的handler,进程收到该信号后,会调用该handler进行处理,但SIGKILL和SIGSTOP例外
Default(缺省) Linux给每个信号都定义了缺省行为,对于大部分信号,用户进程不需要注册自己的handler,使用系统缺省行为即可

对于表格中的SIGKILL (9) 和SIGSTOP (19),它们是Linux里面的两个特权信号。

特权信号指的是Linux为kernel和超级用户去删除任意进程所保留的,不能忽略,也不能被捕获, 也就是强制执行。

我们经常通过kill 去杀掉一个进程,其实发送的是SIGTERM (15),意思是让进程可以graceful shutdown,但此时如果进程没有反应,我们会通过 kill -9 来强杀。

那SIGKILL、SIGTERM和SIGSTOP三者之间有啥区别呢,这里有一个提问可以看看。

以SIGINT信号为例,我们在程序中并未Catch它,那它的Default行为是什么呢?从前面的man 7 signal中可以看到:

SIGINT       P1990      Term    Interrupt from keyboard

而Term这个Action的默认行为是:

Term   Default action is to terminate the process.

SIGINT的Default行为是终止进程,这也就解释了上面的结果。那如果我们让进程Catch SIGINT信号,调用自定义行为,比如只打印一些日志,此时再按下"Ctrl-C",理论上进程是不会退出的。接下来,就调整代码来验证一下我们的猜想是否对。

// ProcKillSignal.c
#include <stdio.h>
#include <unistd.h>
#include <time.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

void sig_handler(int signo){
	if (signo == SIGINT) {
		printf("received SIGINT: %d\n", signo);
		exit(0);
	}
}

int main(int argc, char *argv[]) {
	signal(SIGINT, sig_handler);
	time_t t;
	while (1) {
		sleep(2);
		time(&t);
		printf("current time is : %s",ctime(&t));
	}

	return 0;
}

编译测试,中间按了两次"Ctrl-C",即下面日志中的"^Creceived SIGINT: 2, ignore…",而进程并没有退出,依旧继续运行。

demonlee@demonlee-ubuntu:process$ gcc -o ProcKillSignal ProcKillSignal.c 
demonlee@demonlee-ubuntu:process$ ./ProcKillSignal 
current time is : Sun May 16 22:49:23 2021
current time is : Sun May 16 22:49:25 2021
^Creceived SIGINT: 2, ignore...
current time is : Sun May 16 22:49:26 2021
current time is : Sun May 16 22:49:28 2021
^Creceived SIGINT: 2, ignore...
current time is : Sun May 16 22:49:28 2021
current time is : Sun May 16 22:49:30 2021
...

原因分析

有了上面Linux init进程及信号处理相关知识的铺垫,现在再回到主题:为何容器中kill 1,有的可以,有的不行,而kill -9 1 都不行,难道内核根据什么条件判断,将SIGTERM和SIGKILL信号给忽略了?

如何验证我们的猜想呢?还得从kill 命令说起,该命令会调用kill()这个系统调用(即内核接口),从而进入到内核函数sys_kill(),经过层层调用,最终会到达kernel/signal.c这个文件中的sig_task_ignored()函数。

kernel/signal.c

由于内核代码非常多,很复杂(笔者还在初步学习),这里结合上面截图中第2个if判断(第1、3两个if与当前问题没有太大关系,先忽略)进行简单分析:

1)kill的是1号进程,所以第1个子条件满足;

在哪里给1号进程设置的SIGNAL_UNKILLABLE,搜索了一下,如下图所示:

kernel/fork.c

其中is_child_reaper(pid)函数的代码如下,从注释就可以看到,如果是init进程,则返回true。

/*
 * 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;
}

2)容器内执行kill操作,是同一个Namespace中发出的信号,所以force值为0,则第3个子条件也满足;

3)如果信号没有注册自己的Handler,则第2个子条件也满足,所以关键就在于:handler==SIG_DFL。换句话说,第2个if条件整体的意思是:Linux内核针对每个Namespace中的init进程,把只有default handler的信号都忽略掉了。这也就解释了,为什么kill -9 1永远都无效。

那么,如何验证我们的程序到底有没有给SIGTERM信号注册handler呢?答案是查看进程状态中的SigCgt Bitmap。

在Linux下,找到进程Pid,然后查看/proc/$Pid/status,其中包含了哪些信号被阻止(SigBlk),被忽略(SigIgn)或被捕获(SigCgt),更多请参考这里

# cat /proc/1/status
...
SigBlk:	7be3c0fe28014a03
SigIgn:	0000000000001000
SigCgt:	00000001800004ec
...

通过运行命令,我们拿到容器内对应init进程的SigCgt的值,如下所示:

demonlee@demonlee-ubuntu:process$ docker exec c-kill-demo cat /proc/1/status|grep -i SigCgt
SigCgt:	0000000000000000
demonlee@demonlee-ubuntu:process$ docker exec java-kill-demo cat /proc/1/status|grep -i SigCgt
SigCgt:	0000000181005ccf
demonlee@demonlee-ubuntu:process$ docker exec sh-kill-demo cat /proc/1/status|grep -i SigCgt
SigCgt:	0000000000010002
demonlee@demonlee-ubuntu:process$

我们将其从十六进制转换为二进制,由于C程序都是0,所以肯定是没有Catch任何信号,另外两个分析如下(由于数字太长,省略了前面很多0):

0000000181005ccf --> …0001 1000 0001 0000 0000 0101 1100 1100 1111 【第15位为1,表示捕获了SIGTERM信号】

0000000000010002 --> …0000 0000 0000 0000 0001 0000 0000 0000 0010 【第15位为0,表示没有捕获SIGTERM信号】

到这里,就明白了为啥只有java程序使用kill 1生效了,如果要使c程序也能支持呢?很简单,我们为它注册SIGTERM的handler。

// ProcKill.c
#include <stdio.h>
#include <unistd.h>
#include <time.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

void sig_handler(int signo){
	if (signo == SIGTERM) {
		printf("received SIGTERM: %d\n", signo);
		exit(0);
	}
}

int main(int argc, char *argv[]) {
	signal(SIGTERM, sig_handler);
	time_t t;
	while (1) {
		sleep(2);
		time(&t);
		printf("current time is : %s",ctime(&t));
	}
	return 0;
}

编译打包重新测试,如下所示,此时执行kill 1,容器进程退出了。

demonlee@demonlee-ubuntu:process$ docker exec -it c-kill-demo-signal /bin/bash
[root@ffd5915b1557 proc]# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.1  0.0   4340   800 ?        Ss   16:54   0:00 ./ProcKill
root           8  1.0  0.0  12028  3288 pts/0    Ss   16:54   0:00 /bin/bash
root          22  0.0  0.0  43964  3292 pts/0    R+   16:54   0:00 ps aux
[root@ffd5915b1557 proc]# 
[root@ffd5915b1557 proc]# 
[root@ffd5915b1557 proc]# 
[root@ffd5915b1557 proc]# kill 1
[root@ffd5915b1557 proc]# demonlee@demonlee-ubuntu:process$ 
demonlee@demonlee-ubuntu:process$ 

如果还是最开始的C程序(即没有注册SIGTERM信号的handler),从宿主机上执行kill 和 kill -9 会是什么结果呢?

依旧分析上面的代码,此时force的值为1了,那么判断的关键就是sig_kernel_only(sig) 这个条件的值,其代码如下:

// include/linux/signal.h
...
#define SIG_KERNEL_ONLY_MASK (\
	rt_sigmask(SIGKILL)   |  rt_sigmask(SIGSTOP)
...
#define sig_kernel_only(sig)		siginmask(sig, SIG_KERNEL_ONLY_MASK)
...

从代码可知,如果是SIGKILL和SIGSTOP,则sig_kernel_only(sig)返回true,否则返回false。所以,如果宿主机上kill 是不能杀掉容器内1号进程的,而kill -9 可以。

总结

  1. 在容器中,1号init进程不会响应SIGKILL和SIGSTOP两个特权信号;【此处有疑虑,笔者在容器中使用SIGSTOP,1号进程可以响应,待进一步分析】
  2. 对于其他信号,如果用户进程注册了对应的handler,则1号进程可以响应;
  3. 使用strace工具跟踪进程的系统调用非常有用。

参考资料

[1] linux工具:strace 跟踪进程中的系统调用

[2] 刘超. 内核初始化:生意做大了就得成立公司

[3] 如何检查进程正在监听的信号