为什么容器中不能kill 1号进程?

网友投稿 317 2022-10-17

为什么容器中不能kill 1号进程?

当发现容器镜像里存在一个bug,但因为网络配置问题,又不想为了重建pod去改变pod IP。然而kubernetes上没有restart pod这个命令。那么就只能让pod做原地重启,首先想到的就是在容器中使用kill pid 1的方式重启容器。

一、理解init进程

在模拟场景之前,先理解什么是init进程。

在使用容器的时候,理想状态就是一个容器只启动一个进程。但是在实际生产环境中有时是做不到的。比如:

在一个容器中除了主进程之外,可能还会启动辅助进程,做监控或者rotate logs。还有就是原来VM运行的程序需要迁移到容器,而原本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/initlrwxrwxrwx 1 root root 20 Feb 5 01:07 /sbin/init -> /lib/systemd/systemd

在 Linux 上有了容器的概念之后,一旦容器建立了自己的 Pid Namespace(进程命名空间),这个 Namespace 里的进程号也是从 1 开始标记的。所以,容器的 init 进程也被称为 1 号进程。

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

二、理解Linux信号

运行 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) SIGUSR111) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR31) SIGSYS

信号(Signal)其实就是 Linux 进程收到的一个通知。产生通知的源头有很多种,通知的类型也有很多种。

典型场景:

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

进程在收到信号后,就会去做相应的处理。怎么处理呢?对于每一个信号,进程对它的处理都有下面三个选择。

忽略(Ignore):对这个信号不做任何处理,但是有两个信号例外,对于SIGKILL和SIGSTOP这两个信号,进程是不能忽略的。因为它们的主要作用是为 Linux kernel 和超级用户提供删除任意进程的特权。捕获(Catch):指让用户进程可以注册自己针对这个信号的handler。SIGKILL 和 SIGSTOP 这两个信号也同样例外,这两个信号不能有用户自己的处理代码,只能执行系统的缺省行为。缺省行为(Default):Linux为每个信号定义了一个缺省的行为,可以在Linux系统中运行​​man 7 signal​​查看每个信号的缺省行为。

对于大部分的信号而言,应用程序不需要注册自己的 handler,使用系统缺省定义行为就可以了。

                                         进程处理信号的三种选择

忽略(Ignore)

对信号不做任何处理,但 SIGKILL 和 SIGSTOP 例外。

捕获(Catch)

让用户进程可以注册自己针对这个信号的handler,但 SIGKILL 和 SIGSTOP 例外。

缺省行为(Default)

Linux 为每个信号都定义了一个缺省行为,对于大部分的信号,应用程序不需要注册自己的handler,使用系统缺省定义行为即可。

SIGTERM(15)和 SIGKILL(9)这两个信号是需要重点掌握。

SIGTERM(15)

SIGTERM(15),这个信号是 Linux 命令 kill 缺省发出的。如:kill 1,就是通过 kill 向 1 号进程发送一个信号,在没有别的参数时,这个信号类型就默认为 SIGTERM。

SIGTERM 这个信号是可以被捕获的,这里的“捕获”指的就是用户进程可以为这个信号注册自己的 handler,它可以处理进程的 graceful-shutdown 问题。

SIGKILL(9)

这个信号是Linux两个特权信号之一。

特权信号就是 Linux 为 kernel 和超级用户去删除任意进程所保留的,不能被忽略也不能被捕获。那么进程一旦收到 SIGKILL,就要退出。

例如:运行​​kill -9 1​​,参数“-9”,就是发送编号为9的这个 SIGKILL 信号给1号进程。

下面模拟个案例场景

三、案例

3.1、用bash作为容器1号进程

1、构建一个​​init.sh​​脚本,作为容器init进程

#!/bin/bashwhile truedo sleep 100done

2、构建并运行容器

docker run --name sig-proc -d shijuliu/sig-proc:v1 /init.sh

3、进入容器中运行​​kill 1​​和​​kill -9 1​​

# docker exec -it sig-proc bash[root@0fba7c390141 /]# ps -efUID PID PPID C STIME TTY TIME CMDroot 1 0 0 08:59 ? 00:00:00 /bin/bash /init.shroot 10 1 0 08:59 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/broot 11 0 2 08:59 pts/0 00:00:00 bashroot 25 11 0 08:59 pts/0 00:00:00 ps -ef[root@0fba7c390141 /]# kill 1[root@0fba7c390141 /]# kill -9 1[root@0fba7c390141 /]# ps -efUID PID PPID C STIME TTY TIME CMDroot 1 0 0 08:59 ? 00:00:00 /bin/bash /init.shroot 10 1 0 08:59 ? 00:00:00 /usr/bin/coreutils --coreutils-prog-shebang=sleep /usr/broot 11 0 0 08:59 pts/0 00:00:00 bashroot 26 11 0 09:00 pts/0 00:00:00 ps -ef

上述操作,可以发现无论运行kill 1(对应 Linux 中的 SIGTERM 信号)还是 kill -9 1(对应 Linux 中的 SIGKILL 信号),都无法让进程终止。

3.2、用C程序作为init进程

​​c-init-nosig.c​​

#include #include int main(int argc, char *argv[]){ printf("Process is sleeping\n"); while (1) { sleep(100); } return 0;}

运行docker容器

# docker stop sig-proc;docker rm sig-proc# docker run --name sig-proc -d shijuliu/sig-proc:v1 /c-init-nosig# docker exec -it sig-proc bash[root@f038e665aad9 /]# ps -ef UID PID PPID C STIME TTY TIME CMDroot 1 0 0 09:08 ? 00:00:00 /c-init-nosigroot 15 0 0 09:09 pts/0 00:00:00 bashroot 31 15 0 09:10 pts/0 00:00:00 ps -ef[root@f038e665aad9 /]# kill 1[root@f038e665aad9 /]# kill -9 1[root@f038e665aad9 /]# ps -efUID PID PPID C STIME TTY TIME CMDroot 1 0 0 09:08 ? 00:00:00 /c-init-nosigroot 15 0 0 09:09 pts/0 00:00:00 bashroot 32 15 0 09:10 pts/0 00:00:00 ps -ef

和bash init进程一样,无论SIGTERM信号还是SIGKILL信号,在容器里都不能杀死1号进程。

3.3、Go程序作为1号进程

​​go-init.go​​

package mainimport ( "fmt" "time")func main() { fmt.Println("Start app\n") time.Sleep(time.Duration(100000) * time.Millisecond)}

运行docker容器

# docker stop sig-proc;docker rm sig-proc# docker run --name sig-proc -d shijuliu/sig-proc:v1 /go-init# docker exec -it sig-proc bash# docker exec -it sig-proc bash[root@e651af23fe35 /]# ps -efUID PID PPID C STIME TTY TIME CMDroot 1 0 0 09:15 ? 00:00:00 /go-initroot 12 0 1 09:15 pts/0 00:00:00 bashroot 27 12 0 09:16 pts/0 00:00:00 ps -ef[root@e651af23fe35 /]# kill -9 1[root@e651af23fe35 /]# kill 1[root@e651af23fe35 /]# root@server:/home/liushiju/handle_sig# docker psCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES

在容器中执行​​​kill -9 1​​​和​​kill 1​​​。可以发现​​kill -9 1​​仍然不能杀死1号进程,也就是说,SIGKILL 信号和之前的两个测试一样不起作用。

但是kill 1之后,SIGTERM这个信号把init进程杀了,容器退出。

此时可能还是有疑问,为什么不同程序,结果不一样呢?kill命令下发后,Linux里发生了什么事?

如图:

在运行kill 1这个命令的时候,希望把SIGTERM信号发送给1号进程(图中虚箭头),在Linux实现里,kill命令调用了​​kill()​​​的这个系统调用(内核调用接口)而进入到内核函数​​sys_kill() ​​(图中实线箭头)。

内核在决定把信号发送给1号进程的时候,会调用​​sig_task_ignored()​​函数来做判断,它会决定内核在哪些情况下会把发送的这个信号给忽略掉。如果信号被忽略了,那么 init 进程就不能收到指令了。

sig_task_ignored() 的这个内核函数的实现。

sig_task_ignored()函数有三个if{}判断,第一和第三个if{}判断,和问题无关。

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

kernel/signal.cstatic 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。对于 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 杀死。

四、验证

4.1、查看1号进程状态中SigCgt Bitmap。

在Golang程序里,很多信号都注册了自己的handler,包括了SIGTERM(15)也就是bit 15。

C程序里,缺省状态下,一个信号handler都没有注册;bash程序里注册了两个handler,bit 2和bit 17。

所以C程序和bash程序里SIGTERM的handler是SIG_DFL(系统缺省行为),那么它们就不能被SIGTERM所杀。

### golang init# cat /proc/1/status | grep -i SigCgtSigCgt: fffffffe7fc1feff### C init# cat /proc/1/status | grep -i SigCgtSigCgt: 0000000000000000### bash init# cat /proc/1/status | grep -i SigCgtSigCgt: 0000000000010002

4.2、给C程序注册SIGTERM handler,捕获SIGTERM。

调用 signal() 系统调用注册 SIGTERM 的 handler,在 handler 里主动退出,再看看容器中 kill 1 的结果。

#include #include #include #include #include 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 shijuliu/sig-proc:v1 /c-init-sig# docker exec -it sig-proc bash[root@c4684683ab3d /]# ps -efUID PID PPID C STIME TTY TIME CMDroot 1 0 0 09:05 ? 00:00:00 /c-init-sigroot 6 0 18 09:06 pts/0 00:00:00 bashroot 19 6 0 09:06 pts/0 00:00:00 ps -ef[root@c4684683ab3d /]# cat /proc/1/status | grep SigCgtSigCgt: 0000000000004000[root@c4684683ab3d /]# kill 1# docker psCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES

可以看到,在进程状态的 SigCgt bitmap 里,bit 15 (SIGTERM) 已经置位了。同时,运行 kill 1 也可以把这个 C 程序的 init 进程给杀死了。

五、结论

1、​​kill -9 1​​ 在容器中是不工作的,内核阻止了 1 号进程对 SIGKILL 特权信号的响应。

2、​​kill 1​​ 分两种情况,如果 1 号进程没有注册 SIGTERM 的 handler,那么对 SIGTERM 信号也不响应,如果注册了 handler,那么就可以响应 SIGTERM 信号。

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:SpringBoot+BootStrap多文件上传到本地实例
下一篇:通过配置文件修改docker容器端口号
相关文章

 发表评论

暂时没有评论,来抢沙发吧~