细节篇(3):为什么容器中的进程被强制杀死了?

1,765 阅读5分钟

在生产环境中,不少应用在退出时需要做一些清理工作,比如清理一些远端的链接,或是清除一些本地的临时数据。

这样的清理工作可以尽可能避免远端或者本地的错误发生,比如减少丢包等问题的出现。而这些退出清理的工作,通常是在 SIGTERM 这个信号用户注册的 handler 里进行的。

但如果进程收到了 SIGKILL,那应用程序就没机会执行清理工作。这就意味着,一旦进程不能 graceful shutdown,就会增加应用的出错率。

接下来重现一下,进程在容器退出时都发生了什么。

场景再现

在容器平台想要停止一个容器,无论是在 K8s 中删除 pod,或者 Docker 停止容器,最后都会用到 Containerd 这个服务。

而 Containerd 在停止容器时会向容器的 init 进程发送一个 SIGTERM 信号。在 init 进程退出后,容器内的其他进程也都立刻退出了。不过不同的是,init 进程收到的是 SIGTERM 信号,而其他进程收到的是 SIGKILL 信号。

在细节篇(1)中,我们提到过 SIGKILL 信号是不能被捕获的(catch)的,即用户不能注册自己的 handler,而 SIGTERM 信号却允许用户注册自己的 handler,这样差别就很大了。

当容器退出时,如何才能让容器中的进程都收到 SIGTERM 信号,而不是 SIGKILL 信号。 延续前面课程中处理问题的思路,我们同样可以运行一个简单的容器,来重现这个问题

image.png

这是在宿主机上容器的init进程(2767)和另一个进程的pid

我们用strace来监控该进程,再docker stop停止这个容器

image.png

可以看到init进程接收的是SIGTERM信号,另一个进程同理

知识详解:信号的两个系统调用

在细节篇(1)中介绍过信号的基本概念,信号就是 Linux 进程收到的一个通知。

进程对信号的处理其实就包括两个问题,一个是进程如何发送信号,另一个是进程收到信号后如何处理。

Linux 中发送信号的系统调用是 kill(),之前很多例子里面用的命令 kill ,它内部的实现就是调用了 kill() 这个函数。 这个函数有两个参数,一个是 sig,代表需要发送哪个信号,比如 15 就是指发送 SIGTERM;另一个参数是 pid,也就是指信号需要发送给哪个进程

再来看 signal() 系统调用,它可以给信号注册 handler。参数 signum 也就是信号的编号,例如数值 15,就是信号 SIGTERM;参数 handler 是一个函数指针参数,用来注册用户的信号 handler

image.png

在细节篇(1)里,我们学过进程对每种信号的处理,包括三个选择:调用系统缺省行为、捕获、忽略。而这里的选择就是程序中如何去调用 signal() 这个系统调用。

第一个选择就是缺省,如果在代码中对某个信号,比如 SIGTERM 信号,不做任何 signal() 相关的系统调用,那么在进程运行时,如果接收到信号 SIGTERM,进程就会执行内核中 SIGTERM 信号的缺省代码。

对于 SIGTERM 这个信号来说,它的缺省行为就是进程退出。

内核中对不同的信号有不同的缺省行为,一般会采用退出,暂停, 忽略这三种行为中的一种。

捕获指的是在代码中为某个信号,调用 signal() 注册自己的 handler。这样进程在运行时,一旦接收到信号,就不会再去执行缺省代码,而是执行通过 signal() 注册的 handler。

比如下面这段代码,为 SIGTERM 信号注册了一个 handler,在 handler 里做了一个打印操作。程序在运行时,如果收到 SIGTERM 信号,它就不会退出了,而是只在屏幕 上显示出"received SIGTERM"。

image.png

如果要让进程“忽略”一个信号,就要通过 signal() 为信号注册一个特殊的 handler,也就是 SIG_IGN 。

比如下面的这段代码,就是为 SIGTERM 这个信号注册SIG_IGN。这样操作的效果,就是在程序运行时,如果收到 SIGTERM 信号,程序不会退出,而是什么反应也没有。

image.png

通过讲解 signal() 系统调用,回顾了信号处理的三个选择:缺省行为、捕获和忽略。

SIGKILL 和 SIGSTOP 信号是两个特权信号,它们不可以被捕获和忽略,这个特点也反映在 signal() 调用上。为特权信号注册handler,运行时会直接返回SIG_ERR

解决问题

回到这一讲最初的问题上,为什么在停止一个容器时,init 进程收到 SIGTERM 信号,而其他进程却会收到 SIGKILL 信号呢?

当 Linux 进程收到 SIGTERM 信号并且使进程退出,内核对处理进程退出的入口点就是 do_exit() 函数,do_exit() 函数中会释放进程的相关资源,比如内存,文件句 柄,信号量等等。

做完这些工作之后,它会调用一个 exit_notify() 函数,用来通知和这个进程相关的父子进程等。

对于容器来说,还要考虑 Pid Namespace 里的其他进程。调用的是 zap_pid_ns_processes() 这个函数,这个函数中,如果是处于退出状态的 init 进程, 它会向 Namespace 中的其他进程都发送一个 SIGKILL 信号。

整个流程如下图所示。

image.png

SIGKILL 是特权信号(特权信号是 Linux 为 kernel 和超级用户去删除任意进程所保留的,不能被忽略也不能被捕获)。

所以进程收到这个信号后,就立刻退出了,没有机会调用一些释放资源的 handler。而 SIGTERM 是可以被捕获的,用户可以注册自己的 handler。因此,容器中的程序在 stop container 时,我们更希望进程收到 SIGTERM 信号而不是 SIGKILL 信号。

那该怎么做才能让容器中的进程收到 SIGTERM 信号呢?

解决的方法就是在容器的 init 进程中对收到的信号做个转发,发送到容器中的其他子进程,这样容器中的所有进程在停止时,都会收到 SIGTERM,而不是 SIGKILL 信号了。