今天我们来讲容器中 init 进程的最后一讲,为什么容器中的进程被强制杀死了。理解了这个问题,能够帮助你更好地管理进程,让容器中的进程可以 graceful shutdown。
我先给你说说,为什么进程管理中做到这点很重要。在实际生产环境中,我们有不少应用在退出的时候需要做一些清理工作,比如清理一些远端的链接,或者是清除一些本地的临时数据。
这样的清理工作,可以尽可能避免远端或者本地的错误发生,比如减少丢包等问题的出现。而这些退出清理的工作,通常是在 SIGTERM 这个信号用户注册的 handler 里进行的。
但是,如果我们的进程收到了 SIGKILL,那应用程序就没机会执行这些清理工作了。这就意味着,一旦进程不能 graceful shutdown,就会增加应用的出错率。
所以接下来,我们来重现一下,进程在容器退出时都发生了什么。
在容器平台上,你想要停止一个容器,无论是在 Kubernetes 中去删除一个 pod,或者用 Docker 停止一个容器,最后都会用到 Containerd 这个服务。
而 Containerd 在停止容器的时候,就会向容器的 init 进程发送一个 SIGTERM 信号。
我们会发现,在 init 进程退出之后,容器内的其他进程也都立刻退出了。不过不同的是,init 进程收到的是 SIGTERM 信号,而其他进程收到的是 SIGKILL 信号。
在理解进程的第一讲中,我们提到过 SIGKILL 信号是不能被捕获的(catch)的,也就是用户不能注册自己的 handler,而 SIGTERM 信号却允许用户注册自己的 handler,这样的话差别就很大了。
那么,我们就一起来看看当容器退出的时候,如何才能让容器中的进程都收到 SIGTERM 信号,而不是 SIGKILL 信号。
延续前面课程中处理问题的思路,我们同样可以运行一个简单的容器,来重现这个问题,用这里的代码执行一下 make image ,然后用 Docker 启动这个容器镜像。
docker run -d --name fwd_sig registry/fwd_sig:v1 /c-init-sig
你会发现,在我们用 docker stop 停止这个容器的时候,如果用 strace 工具来监控,就能看到容器里的 init 进程和另外一个进程收到的信号情况。
在下面的例子里,进程号为 15909 的就是容器里的 init 进程,而进程号为 15959 的是容器里另外一个进程。
在命令输出中我们可以看到,init 进程(15909)收到的是 SIGTERM 信号,而另外一个进程(15959)收到的果然是 SIGKILL 信号。
# ps -ef | grep c-init-sig root 15857 14391 0 06:23 pts/0 00:00:00 docker run -it registry/fwd_sig:v1 /c-init-sig root 15909 15879 0 06:23 pts/0 00:00:00 /c-init-sig root 15959 15909 0 06:23 pts/0 00:00:00 /c-init-sig root 16046 14607 0 06:23 pts/3 00:00:00 grep --color=auto c-init-sig # strace -p 15909 strace: Process 15909 attached restart_syscall(<... resuming interrupted read ...>) = ? ERESTART_RESTARTBLOCK (Interrupted by signal) --- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0} --- write(1, "received SIGTERM\n", 17) = 17 exit_group(0) = ? +++ exited with 0 +++ # strace -p 15959 strace: Process 15959 attached restart_syscall(<... resuming interrupted read ...>) = ? +++ killed by SIGKILL +++
我们想要理解刚才的例子,就需要搞懂信号背后的两个系统调用,它们分别是 kill() 系统调用和 signal() 系统调用。
这里呢,我们可以结合前面讲过的信号来理解这两个系统调用。在容器 init 进程的第一讲里,我们介绍过信号的基本概念了,信号就是 Linux 进程收到的一个通知。
等你学完如何使用这两个系统调用之后,就会更清楚 Linux 信号是怎么一回事,遇到容器里信号相关的问题,你就能更好地理清思路了。
我还会再给你举个使用函数的例子,帮助你进一步理解进程是如何实现 graceful shutdown 的。
进程对信号的处理其实就包括两个问题,一个是进程如何发送信号,另一个是进程收到信号后如何处理。
我们在 Linux 中发送信号的系统调用是 kill(),之前很多例子里面我们用的命令 kill ,它内部的实现就是调用了 kill() 这个函数。
下面是 Linux Programmer’s Manual 里对 kill() 函数的定义。
这个函数有两个参数,一个是 sig,代表需要发送哪个信号,比如 sig 的值是 15 的话,就是指发送 SIGTERM;另一个参数是 pid,也就是指信号需要发送给哪个进程,比如值是 1 的话,就是指发送给进程号是 1 的进程。
NAME kill - send signal to a process SYNOPSIS #include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig);
我们知道了发送信号的系统调用之后,再来看另一个系统调用,也就是 signal() 系统调用这个函数,它可以给信号注册 handler。
下面是 signal() 在 Linux Programmer’s Manual 里的定义,参数 signum 也就是信号的编号,例如数值 15,就是信号 SIGTERM;参数 handler 是一个函数指针参数,用来注册用户的信号 handler。
NAME signal - ANSI C signal handling SYNOPSIS #include <signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);
在容器 init 进程的第一讲里,我们学过进程对每种信号的处理,包括三个选择:调用系统缺省行为、捕获、忽略。而这里的选择,其实就是程序中如何去调用 signal() 这个系统调用。
第一个选择就是缺省,如果我们在代码中对某个信号,比如 SIGTERM 信号,不做任何 signal() 相关的系统调用,那么在进程运行的时候,如果接收到信号 SIGTERM,进程就会执行内核中 SIGTERM 信号的缺省代码。
对于 SIGTERM 这个信号来说,它的缺省行为就是进程退出(terminate)。
内核中对不同的信号有不同的缺省行为,一般会采用退出(terminate),暂停(stop),忽略(ignore)这三种行为中的一种。
那第二个选择捕获又是什么意思呢?
捕获指的就是我们在代码中为某个信号,调用 signal() 注册自己的 handler。这样进程在运行的时候,一旦接收到信号,就不会再去执行内核中的缺省代码,而是会执行通过 signal() 注册的 handler。
比如下面这段代码,我们为 SIGTERM 这个信号注册了一个 handler,在 handler 里只是做了一个打印操作。
那么这个程序在运行的时候,如果收到 SIGTERM 信号,它就不会退出了,而是只在屏幕上显示出"received SIGTERM"。
void sig_handler(int signo) { if (signo == SIGTERM) { printf("received SIGTERM\n"); } } int main(int argc, char *argv[]) { ... signal(SIGTERM, sig_handler); ... }
我们再来看看第三个选择,如果要让进程“忽略”一个信号,我们就要通过 signal() 这个系统调用,为这个信号注册一个特殊的 handler,也就是 SIG_IGN 。
比如下面的这段代码,就是为 SIGTERM 这个信号注册SIG_IGN。
这样操作的效果,就是在程序运行的时候,如果收到 SIGTERM 信号,程序既不会退出,也不会在屏幕上输出 log,而是什么反应也没有,就像完全没有收到这个信号一样。
int main(int argc, char *argv[]) { ... signal(SIGTERM, SIG_IGN); ... }
好了,我们通过讲解 signal() 这个系统调用,帮助你回顾了信号处理的三个选择:缺省行为、捕获和忽略。