课程原文: 李程远. 理解进程(3):为什么我在容器中的进程被强制杀死了?
本文实践环境:
Operating System: Ubuntu 20.04.2 LTS
Kernel: Linux 5.8.0-59-generic
Architecture: x86-64
Docker Client/Server Version: 20.10.7
在程序结束之前,一般都要执行一些清理逻辑,比如关闭远程连接、清理本地缓存、提交(或回滚)数据库事务等。这样做的目的是为了尽可能的避免一些本地或远端的错误发生,从而提高程序的健壮性,这种方式也被称为:graceful shutdown。
当要停止一个进程时,通常我们都会使用 kill <pid>
,也就是给进程发送 SIGTERM
信号。当这个命令没有响应时,我们才会使用 kill -9 <pid>
,即给进程发送 SIGKILL
信号,进行强杀。
为啥默认优先使用 SIGTERM
信号?在前面的文章中,笔者介绍了信号的相关基础内容。基于此,我们知道 SIGKILL
是一个特权信号,不能为其注册信号处理函数,而 SIGTERM
则可以。所以,我们可以将应用退出时的相关清理工作放在 SIGTERM
信号处理的注册函数中。
但是,当我们停止一个容器时,容器内的 init
进程收到的是 SIGTERM
信号,而容器内的其他进程收到的却是 SIGKILL
信号,这是为啥呢?
Question:从停止容器说起
当我们执行 docker stop <containerId>
后,会用到 containerd
这个服务, containerd
会向容器内的 init
进程发送一个 SIGTERM
信号。
LXC
(LinuX Containers):随 Linux Kernel 2.6.24 内核开始提供cgroups
能力的同一时间发布的系统级虚拟化功能。
libcontainer
: 一个越过LXC
直接操作namespaces
和cgroups
的核心模块。
runC
: 为了符合 OCI 标准,Docker 推动自身的架构继续向前演进,将libcontainer
独立出来,封装重构成runC 项目,并捐献给了 Linux 基金会管理。
containerd
:负责管理容器执行、分发、监控、网络、构建、日志等功能的核心模块,内部会为每个容器运行时创建一个containerd-shim
适配进程,与runC
搭配工作。
没有启动容器时,查询
containerd
相关进程,只有两个,当启动一个容器后,会有三个:# 未启动任何容器时 demonlee@demonlee-ubuntu:~$ ps aux|grep -v grep|grep containerd root 727 0.0 0.7 1120812 47080 ? Ssl 22:32 0:01 /usr/bin/containerd root 830 0.0 1.5 2421920 91704 ? Ssl 22:32 0:00 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock # 启动一个容器后 demonlee@demonlee-ubuntu:~$ ps aux|grep -v grep|grep container root 727 0.0 0.7 1120812 47080 ? Ssl 22:40 0:02 /usr/bin/containerd root 830 0.0 1.5 2421920 92304 ? Ssl 22:40 0:01 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock root 3073 0.0 0.1 113252 7028 ? Sl 23:49 0:00 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 772b0f1757cee0d9ae243ab634967023266f3cfa17ca494afe3382cd2f8a3742 -address /run/containerd/containerd.sock demonlee@demonlee-ubuntu:~$
这里我们通过一个课程原文中的示例,重现一下问题。
demonlee@demonlee-ubuntu:fwd_sig$ ll
total 40
drwxrwxr-x 2 demonlee demonlee 4096 6月 21 22:50 ./
drwxrwxr-x 6 demonlee demonlee 4096 4月 10 23:18 ../
-rwxrwxr-x 1 demonlee demonlee 17080 6月 21 22:50 c-init-sig*
-rw-rw-r-- 1 demonlee demonlee 939 4月 10 23:18 c-init-sig.c
-rw-rw-r-- 1 demonlee demonlee 43 4月 10 23:18 Dockerfile
-rw-rw-r-- 1 demonlee demonlee 199 4月 10 23:18 Makefile
demonlee@demonlee-ubuntu:fwd_sig$ cat c-init-sig.c
#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[])
{
int i;
int total;
if (argc < 2) {
total = 1;
} else {
total = atoi(argv[1]);
}
signal(SIGTERM, sig_handler);
printf("To create %d processes\n", total);
for (i = 0; i < total; i++) {
pid_t pid = fork();
if (pid == 0) {
pid_t m_pid, p_pid;
m_pid = getpid();
p_pid = getppid();
printf("Child => PPID: %d PID: %d\n", p_pid, m_pid);
while (1) {
sleep(100);
}
printf("Child process eixts\n");
exit(EXIT_SUCCESS);
} else if (pid > 0) {
printf("Parent created child %d\n", i);
} else {
printf("Unable to create child process. %d\n", i);
break;
}
}
printf("Paraent is sleeping\n");
while (1) {
sleep(100);
}
return EXIT_SUCCESS;
}
demonlee@demonlee-ubuntu:fwd_sig$ cat Dockerfile
FROM centos:8.1.1911
COPY ./c-init-sig /
demonlee@demonlee-ubuntu:fwd_sig$ cat Makefile
all: c-init-sig image
c-init-sig: c-init-sig.c
gcc -o c-init-sig c-init-sig.c
image: c-init-sig
docker build -t registry/fwd_sig:v1 .
clean:
rm -f *.o c-init-sig
docker rmi registry/fwd_sig:v1
demonlee@demonlee-ubuntu:fwd_sig$ docker run -d --name forward_signal_test registry/fwd_sig:v1 /c-init-sig
772b0f1757ce
demonlee@demonlee-ubuntu:fwd_sig$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
772b0f1757ce registry/fwd_sig:v1 "/c-init-sig" 5 days ago Up 14 seconds forward_signal_test
demonlee@demonlee-ubuntu:fwd_sig$ ps -ef|grep -v grep|grep c-init-sig
root 4632 4611 0 15:37 ? 00:00:00 /c-init-sig
root 4669 4632 0 15:37 ? 00:00:00 /c-init-sig
demonlee@demonlee-ubuntu:fwd_sig$
从程序输出可以看到,容器内的 1 号 init
进程对应的 pid 为 4632,对应的子进程为 4669。现在我们通过 docker stop
将容器停止,在另外两个 terminal 中使用 strace
命令跟踪进程的系统调用:
# terminal-1
demonlee@demonlee-ubuntu:~$ sudo strace -p 4632
[sudo] password for demonlee:
strace: Process 4632 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, "To create 1 processes\nParent cre"..., 82) = 82
exit_group(0) = ?
+++ exited with 0 +++
demonlee@demonlee-ubuntu:~$
# terminal-2
demonlee@demonlee-ubuntu:~$ sudo strace -p 4669
[sudo] password for demonlee:
strace: Process 4669 attached
restart_syscall(<... resuming interrupted read ...>) = ?
+++ killed by SIGKILL +++
demonlee@demonlee-ubuntu:~$ docker logs forward_signal_test
To create 1 processes
Parent created child 0
Paraent is sleeping
received SIGTERM
demonlee@demonlee-ubuntu:~$
从 strace
命令跟踪的结果(以及容器日志)可以看到,init
进程收到了 SIGTERM
信号,而另外一个子进程收到的则是 SIGKILL
信号。
是什么原因,导致两种不同信号的产生?为何不能让容器内所有进程都收到 SIGTERM
信号?
下面我们就从这个问题出发,试着去寻找答案。
Basic-knowledge:kill 系统调用
信号可以理解为 Linux 进程收到的一个通知。既然是通知,那么就涉及到发送方和接收方:发送方关注的是如何发送信号,而接收方关注的是收到信号后如何处理。我们可以将其理解为内核层面的生产-消费者模式。
在前面的这篇学习笔记中,笔者已经对 signal()
系统调用进行了梳理。简单来说,Linux 进程对信号的处理有三种策略:调用系统缺省行为、捕获和忽略,其中的捕获就是留给应用程序进行自定义处理的。如果我们需要对某种信号进行自定义操作,比如简单的日志打印等,就可以为这个信号注册一个 handler()
函数。
SIGKILL
和SIGSTOP
两个信号是特权信号,不可以捕获和忽略。若对它们进行捕获,运行时将会出错。验证程序如下:
demonlee@demonlee-ubuntu:fwd_sig$ cat privilege-signal-register.c #include <unistd.h> #include "stdio.h" #include "signal.h" #include "stdlib.h" #include "errno.h" typedef void (*sighandler_t)(int); void register_privilege_signal_handler(int signo) { if (signo == SIGKILL) { printf("received SIGKILL signal\n"); exit(0); } if (signo == SIGSTOP) { printf("received SIGSTOP signal\n"); exit(0); } if (signo == SIGTERM) { printf("received SIGTERM signal\n"); exit(0); } } void test_privilege_signal_register(int flag) { printf("flag: [%d]\n", flag); sighandler_t h_ret; switch (flag) { case 0: h_ret = signal(SIGSTOP, register_privilege_signal_handler); break; case 1: h_ret = signal(SIGKILL, register_privilege_signal_handler); break; case 2: h_ret = signal(SIGTERM, register_privilege_signal_handler); break; default: break; } if (h_ret == SIG_ERR) { perror("register signal handler SIG_ERR"); } } int main(int argc, char *args[]) { setbuf(stdout, NULL); test_privilege_signal_register(0); sleep(1); test_privilege_signal_register(1); sleep(1); test_privilege_signal_register(2); } demonlee@demonlee-ubuntu:fwd_sig$ gcc -o test-privilege-signal-register privilege-signal-register.c demonlee@demonlee-ubuntu:fwd_sig$ ./test-privilege-signal-register flag: [0] register signal handler SIG_ERR: Invalid argument flag: [1] register signal handler SIG_ERR: Invalid argument flag: [2] demonlee@demonlee-ubuntu:fwd_sig$
另外一个,就是发送信号的系统调用: kill()
,通过 man 2 kill
查看文档:
NAME
kill - send signal to a process
SYNOPSIS
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
Feature Test Macro Requirements for glibc (see feature_test_macros(7)):
kill(): _POSIX_C_SOURCE
DESCRIPTION
The kill() system call can be used to send any signal to any process group or process.
If pid is positive, then signal sig is sent to the process with the ID specified by pid.
If pid equals 0, then sig is sent to every process in the process group of the calling process.
If pid equals -1, then sig is sent to every process for which the calling process has permission to send signals, except for process 1 (init),but see below.
If pid is less than -1, then sig is sent to every process in the process group whose ID is -pid.
If sig is 0, then no signal is sent, but existence and permission checks are still performed; this can be used to check for the existence of a process ID or process group ID that the caller is permitted to signal.
For a process to have permission to send a signal, it must either be privileged (under Linux: have the CAP_KILL capability in the user name‐space of the target process), or the real or effective user ID of the sending process must equal the real or saved set-user-ID of the target process. In the case of SIGCONT, it suffices when the sending and receiving processes belong to the same session. (Historically, the rules were different; see NOTES.)
RETURN VALUE
On success (at least one signal was sent), zero is returned. On error, -1 is returned, and errno is set appropriately.
...
...
从 Linux Programmer's Manaual 文档中,我们可以看到,kill()
系统调用主要就两个参数,一个是进程号 pid,一个是信号对应的序号(通过 kill -l
可以查阅对应的信号名称和其序号):
- 如果 pid>0,则将信号发送给这个指定的进程;
- 如果 pid=-1,信号将发送给当前进程拥有发送信号权限的那些进程,但不包含 1 号
init
进程; - 如果 pid<-1,信号将发送给当前进程组中的所有进程;
- 如果 sig=0, 不发送信号,但仍会进行存在性和权限检查。这可以用来检查是否存在调用者允许发出信号的进程ID或进程组ID。
下面结合 kill()
和 signal()
两个函数,通过一个程序来加深理解。
// kill-signal-test.c
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void sig_handler(int signo)
{
if (signo == SIGTERM) {
printf("received SIGTERM and register default handler...\n");
signal(SIGTERM, SIG_DFL);
}
}
int main(int argc, char *argv[])
{
setbuf(stdout, NULL);
// 忽略 SIGTERM 信号
signal(SIGTERM, SIG_IGN);
printf("ignore SIGTERM...\n\n");
// 给当前进程(即自己)发送 SIGTERM 信号
kill(0, SIGTERM);
// 给 SIGTERM 信号注册自定义处理 handler
signal(SIGTERM, sig_handler);
printf("catch SIGTERM...\n\n");
kill(0, SIGTERM);
// 使用 SIGTERM 信号默认处理,即 sig_handler 函数中设置的 SIG_DFL
printf("default SIGTERM...\n\n");
kill(0, SIGTERM);
printf("it won't go to here...\n\n");
return EXIT_SUCCESS;
}
编译运行,输出结果如下:
demonlee@demonlee-ubuntu:signal-test$ gcc -o kill-signal-test kill-signal-test.c
demonlee@demonlee-ubuntu:signal-test$ ./kill-signal-test
ignore SIGTERM...
catch SIGTERM...
received SIGTERM and register default handler...
default SIGTERM...
Terminated
demonlee@demonlee-ubuntu:signal-test$
Analysis:进程退出流程
让我们再回到文章开头的问题,为何容器中的 init
进程收到的是 SIGTERM
信号,而其他子进程收到的却是 SIGKILL
信号?
查阅资料,并结合内核源码,我们来梳理一下 Linux 进程退出的调度流程:
- 当 Linux 进程收到
SIGTERM
信号后,进程会退出,而 Linux 内核处理进程退出的入口点是do_exit()
函数(路径:/linux-5.8/kernel/exit.c),在该函数中会释放进程的相关资源,比如内存、文件句柄、信号量等。 - 接着,会调用
exit_notify()
函数(路径:/linux-5.8/kernel/exit.c),用来通知与当前进程相关的亲属(即父子进程等)。 - 再接着,会调用
forget_original_parent()
和find_child_reaper()
函数 (路径:/linux-5.8/kernel/exit.c),并进入到zap_pid_ns_processes()
函数(路径:/linux-5.8/kernel/pid_namespace.c)中,在这里将会轮询当前 namespace 中所有的进程,并给它们发送SIGKILL
信号,部分源码如下:
void zap_pid_ns_processes(struct pid_namespace *pid_ns)
{
// ...
/*
* The last thread in the cgroup-init thread group is terminating.
* Find remaining pid_ts in the namespace, signal and wait for them
* to exit.
*
* Note: This signals each threads in the namespace - even those that
* belong to the same thread group, To avoid this, we would have
* to walk the entire tasklist looking a processes in this
* namespace, but that could be unnecessarily expensive if the
* pid namespace has just a few processes. Or we need to
* maintain a tasklist for each pid namespace.
*
*/
rcu_read_lock();
read_lock(&tasklist_lock);
nr = 2;
idr_for_each_entry_continue(&pid_ns->idr, pid, nr) {
task = pid_task(pid, PIDTYPE_PID);
if (task && !__fatal_signal_pending(task))
group_send_sig_info(SIGKILL, SEND_SIG_PRIV, task, PIDTYPE_MAX); // 【这里发送的 SIGKILL 信号】
}
read_unlock(&tasklist_lock);
rcu_read_unlock();
// ...
}
整个调度流程可以简单总结为:... --> do_exit --> exit_notify --> forget_original_parent --> find_child_reaper --> zap_pid_ns_processes --> ...
Solution:信号转发
既然是内核搞的鬼,那我们如何让容器中所有的进程都能收到 SIGTERM
信号呢?
答案是:信号转发,让 init
进程在收到信号后,给所有子进程转发一次。除非有子进程对 SIGTERM
信号进行了自定义处理,比如忽略,否则不会劳烦内核来强杀。
在 Docker Container 中使用的 tini 就是这么干的,官方对这个项目的描述是:
All Tini does is spawn a single child (Tini is meant to be run in a container), and wait for it to exit all the while reaping zombies and performing signal forwarding.
查看其源码,会发现其内部使用了 sigtimedwait()
函数来获取自己收到的信号(使用该函数后,会拦截所有信号,不会因为收到 SIGTERM
信号而导致自己退出),除了 SIGCHLD
(Child stopped or terminated) 这个信号外,tini 会把所有其他信号都转发给子进程。
int wait_and_forward_signal(sigset_t const* const parent_sigset_ptr, pid_t const child_pid) {
siginfo_t sig;
if (sigtimedwait(parent_sigset_ptr, &sig, &ts) == -1) {
switch (errno) {
case EAGAIN:
break;
case EINTR:
break;
default:
PRINT_FATAL("Unexpected error in sigtimedwait: '%s'", strerror(errno));
return 1;
}
} else {
/* There is a signal to handle here */
switch (sig.si_signo) {
case SIGCHLD:
/* Special-cased, as we don't forward SIGCHLD. Instead, we'll
* fallthrough to reaping processes.
*/
PRINT_DEBUG("Received SIGCHLD");
break;
default:
PRINT_DEBUG("Passing signal: '%s'", strsignal(sig.si_signo));
/* Forward anything else */
if (kill(kill_process_group ? -child_pid : child_pid, sig.si_signo)) {
if (errno == ESRCH) {
PRINT_WARNING("Child was dead when forwarding signal");
} else {
PRINT_FATAL("Unexpected error when forwarding signal: '%s'", strerror(errno));
return 1;
}
}
break;
}
}
return 0;
}
那么,这种信号转发,在实际工作中,我们如何落地呢?
- 在自己的
init
进程中,实现信号转发; - 直接使用 tini 项目作为容器的
init
进程。
下面笔者就用文章开头容器镜像的例子,运用 tini 项目重新执行一遍。
结合官方文档,在容器中使用 tini 有两种方式:
(1)在 Docker 1.13 及更高版本中,使用
docker run
命令启动时,加上--init
参数,该参数的含义是:Run an init inside the container that forwards signals and reaps processes。
(2)制作容器镜像时,使用官方指定的 base-image 或修改 Dockerfile,将 tini 程序加入其中。
使用第 1 种方案,测试过程如下:
# terminal-0
demonlee@demonlee-ubuntu:signal-test$ docker run --init -d --name forward_signal_test_tini registry/fwd_sig:v1 /c-init-sig
0a63499f2b15113d289e0d0ddfc160321b7a2279dc1d3a19aac0b32e91e39ead
demonlee@demonlee-ubuntu:signal-test$ docker exec -it forward_signal_test_tini ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.1 0.0 1112 4 ? Ss 01:06 0:00 /sbin/docker-init -- /c-init-sig
root 7 0.0 0.0 4340 708 ? S 01:06 0:00 /c-init-sig
root 8 0.0 0.0 4340 84 ? S 01:06 0:00 /c-init-sig
root 9 0.0 0.0 43964 3380 pts/0 Rs+ 01:06 0:00 ps aux
demonlee@demonlee-ubuntu:signal-test$ ps -ef|grep -v grep|grep c-init-sig
root 4357 4332 0 09:06 ? 00:00:00 /sbin/docker-init -- /c-init-sig
root 4392 4357 0 09:06 ? 00:00:00 /c-init-sig
root 4393 4392 0 09:06 ? 00:00:00 /c-init-sig
demonlee@demonlee-ubuntu:signal-test$ docker stop forward_signal_test_tini
forward_signal_test_tini
demonlee@demonlee-ubuntu:signal-test$
# terminal-1
demonlee@demonlee-ubuntu:signal-test$ sudo strace -p 4357
strace: Process 4357 attached
wait4(-1, 0x7ffe012f0c6c, WNOHANG, NULL) = 0
rt_sigtimedwait(~[ILL TRAP ABRT BUS FPE SEGV TTIN TTOU SYS RTMIN RT_1], 0x7ffe012f0c10, {tv_sec=1, tv_nsec=0}, 8) = -1 EAGAIN (Resource temporarily unavailable)
wait4(-1, 0x7ffe012f0c6c, WNOHANG, NULL) = 0
rt_sigtimedwait(~[ILL TRAP ABRT BUS FPE SEGV TTIN TTOU SYS RTMIN RT_1], 0x7ffe012f0c10, {tv_sec=1, tv_nsec=0}, 8) = -1 EAGAIN (Resource temporarily unavailable)
...
wait4(-1, 0x7ffe012f0c6c, WNOHANG, NULL) = 0
rt_sigtimedwait(~[ILL TRAP ABRT BUS FPE SEGV TTIN TTOU SYS RTMIN RT_1], {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0}, {tv_sec=1, tv_nsec=0}, 8) = 15 (SIGTERM)
kill(7, SIGTERM) = 0 #【给容器内的 7 号进程转发了 SIGTERM 信号】
wait4(-1, 0x7ffe012f0c6c, WNOHANG, NULL) = 0
rt_sigtimedwait(~[ILL TRAP ABRT BUS FPE SEGV TTIN TTOU SYS RTMIN RT_1], {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=7, si_uid=0, si_status=0, si_utime=0, si_stime=0}, {tv_sec=1, tv_nsec=0}, 8) = 17 (SIGCHLD)
wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WNOHANG, NULL) = 7
wait4(-1, 0x7ffe012f0c6c, WNOHANG, NULL) = 0
exit_group(0) = ?
+++ exited with 0 +++
demonlee@demonlee-ubuntu:signal-test$
# terminal-2
demonlee@demonlee-ubuntu:signal-test$ sudo strace -p 4392
strace: Process 4392 attached
restart_syscall(<... resuming interrupted read ...>) = 0
nanosleep({tv_sec=100, tv_nsec=0}, {tv_sec=71, tv_nsec=260755796}) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=1, si_uid=0} ---
write(1, "To create 1 processes\nParent cre"..., 82) = 82
exit_group(0) = ?
+++ exited with 0 +++
demonlee@demonlee-ubuntu:signal-test$
# terminal-3
demonlee@demonlee-ubuntu:signal-test$ sudo strace -p 4393
[sudo] password for demonlee:
strace: Process 4393 attached
restart_syscall(<... resuming interrupted read ...>) = ?
+++ killed by SIGKILL +++ #【这个子进程还是收到的 SIGKILL 信号】
demonlee@demonlee-ubuntu:signal-test$
可以看到,tini 实现了信号转发,但并没有完全达到笔者想要的效果。也就是说,在默认情况下,tini 项目只能将信号转发给它的儿子,无法转发给它的孙子。
okay,我们继续使用第 2 种方案进行验证,过程如下:
# terminal-0
demonlee@demonlee-ubuntu:signal-test$ ls
c-init-sig c-init-sig.c Dockerfile_v1 Dockerfile_v2 kill-signal-test kill-signal-test.c makefile
demonlee@demonlee-ubuntu:signal-test$ cat Dockerfile_v2
FROM centos:8.1.1911
COPY ./c-init-sig /
# Add Tini
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--"]
demonlee@demonlee-ubuntu:signal-test$ docker build -t registry/fwd_sig:v2 -f Dockerfile_v2 .
Sending build context to Docker daemon 43.01kB
Step 1/6 : FROM centos:8.1.1911
---> 470671670cac
Step 2/6 : COPY ./c-init-sig /
---> Using cache
---> 0d38b2f2cf0b
Step 3/6 : ENV TINI_VERSION v0.19.0
---> Using cache
---> ee64cf5c5774
Step 4/6 : ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
Downloading [==================================================>] 24.06kB/24.06kB
---> d7ca5e766a56
Step 5/6 : RUN chmod +x /tini
---> Running in 4f784f24a4a7
Removing intermediate container 4f784f24a4a7
---> 69d52a15dee8
Step 6/6 : ENTRYPOINT ["/tini", "--"]
---> Running in d59dc2c1fc6e
Removing intermediate container d59dc2c1fc6e
---> f35ffd48e6d0
Successfully built f35ffd48e6d0
Successfully tagged registry/fwd_sig:v2
demonlee@demonlee-ubuntu:signal-test$ docker run -d --name forward_signal_test_tini2 registry/fwd_sig:v2 /c-init-sig
b15edabfae6ca908616be61c350ddd9352fea7e7b0c4cb007654ca1ae3b20122
demonlee@demonlee-ubuntu:signal-test$ docker exec -it forward_signal_test_tini2 ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.3 0.0 4344 800 ? Ss 01:38 0:00 /tini -- /c-init-sig
root 9 0.0 0.0 4340 692 ? S 01:38 0:00 /c-init-sig
root 10 0.0 0.0 4340 84 ? S 01:38 0:00 /c-init-sig
root 11 0.0 0.0 43964 3416 pts/0 Rs+ 01:38 0:00 ps aux
demonlee@demonlee-ubuntu:signal-test$ ps -ef|grep -v grep|grep c-init-sig
root 5281 5259 0 09:38 ? 00:00:00 /tini -- /c-init-sig
root 5313 5281 0 09:38 ? 00:00:00 /c-init-sig
root 5314 5313 0 09:38 ? 00:00:00 /c-init-sig
demonlee@demonlee-ubuntu:signal-test$ docker stop forward_signal_test_tini2
forward_signal_test_tini2
demonlee@demonlee-ubuntu:signal-test$
# terminal-1
demonlee@demonlee-ubuntu:signal-test$ sudo strace -p 5281
[sudo] password for demonlee:
strace: Process 5281 attached
wait4(-1, 0x7ffc609702cc, WNOHANG, NULL) = 0
rt_sigtimedwait(~[ILL TRAP ABRT BUS FPE SEGV TTIN TTOU SYS RTMIN RT_1], 0x7ffc60970260, {tv_sec=1, tv_nsec=0}, 8) = -1 EAGAIN (Resource temporarily unavailable)
wait4(-1, 0x7ffc609702cc, WNOHANG, NULL) = 0
rt_sigtimedwait(~[ILL TRAP ABRT BUS FPE SEGV TTIN TTOU SYS RTMIN RT_1], 0x7ffc60970260, {tv_sec=1, tv_nsec=0}, 8) = -1 EAGAIN (Resource temporarily unavailable)
...
...
wait4(-1, 0x7ffc609702cc, WNOHANG, NULL) = 0
rt_sigtimedwait(~[ILL TRAP ABRT BUS FPE SEGV TTIN TTOU SYS RTMIN RT_1], {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0}, {tv_sec=1, tv_nsec=0}, 8) = 15 (SIGTERM)
kill(9, SIGTERM) = 0
wait4(-1, 0x7ffc609702cc, WNOHANG, NULL) = 0
rt_sigtimedwait(~[ILL TRAP ABRT BUS FPE SEGV TTIN TTOU SYS RTMIN RT_1], {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=9, si_uid=0, si_status=0, si_utime=0, si_stime=0}, {tv_sec=1, tv_nsec=0}, 8) = 17 (SIGCHLD)
wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WNOHANG, NULL) = 9
wait4(-1, 0x7ffc609702cc, WNOHANG, NULL) = 0
exit_group(0) = ?
+++ exited with 0 +++
# terminal-2
demonlee@demonlee-ubuntu:signal-test$ sudo strace -p 5313
[sudo] password for demonlee:
strace: Process 5313 attached
restart_syscall(<... resuming interrupted read ...>) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=1, si_uid=0} ---
write(1, "To create 1 processes\nParent cre"..., 82) = 82
exit_group(0) = ?
+++ exited with 0 +++
demonlee@demonlee-ubuntu:signal-test$
# terminal-3
demonlee@demonlee-ubuntu:signal-test$ sudo strace -p 5314
[sudo] password for demonlee:
strace: Process 5314 attached
restart_syscall(<... resuming interrupted read ...>) = ?
+++ killed by SIGKILL +++
demonlee@demonlee-ubuntu:signal-test$
从结果看,与第 1 种方案没有差异。
难道在 tini 项目中,真的就没有考虑到容器中存在孙子进程的情况吗?
让我们认真分析一下前面的源码:wait_and_forward_signal()
函数。
// ...
PRINT_DEBUG("Passing signal: '%s'", strsignal(sig.si_signo));
/* Forward anything else */
if (kill(kill_process_group ? -child_pid : child_pid, sig.si_signo)) {
// ...
}
// ...
很明显,如果变量 kill_process_group
为真,则给进程 -child_pid
发送信号,如果为假,则给进程 child_pid
发送信号。假设,现在的子进程的 pid 为 10,那么就是 kill(-10, signo)
与 kill(10, signo)
之间的区别了。
结合前面对 kill()
系统调用的介绍,如果 pid<-1,信号将发送给当前进程组中的所有进程。所以,只要 kill_process_group
变量为真,理论上就可以得到我们想要的结果了。
好,让我们继续分析这个变量的取值:
// tini.c
// ...
#define KILL_PROCESS_GROUP_GROUP_ENV_VAR "TINI_KILL_PROCESS_GROUP" // 【定义的环境变量名称 TINI_KILL_PROCESS_GROUP 】
static unsigned int kill_process_group = 0; // 【默认配置为 0】
// ...
int parse_env() {
#if HAS_SUBREAPER
if (getenv(SUBREAPER_ENV_VAR) != NULL) {
subreaper++;
}
#endif
if (getenv(KILL_PROCESS_GROUP_GROUP_ENV_VAR) != NULL) { // 【如果定义了环境变量 KILL_PROCESS_GROUP_GROUP_ENV_VAR,则 kill_process_group 的值递增】
kill_process_group++;
}
char* env_verbosity = getenv(VERBOSITY_ENV_VAR);
if (env_verbosity != NULL) {
verbosity = atoi(env_verbosity);
}
return 0;
}
// ...
int parse_args(const int argc, char* const argv[], char* (**child_args_ptr_ptr)[], int* const parse_fail_exitcode_ptr) {
// ...
#ifndef TINI_MINIMAL
int c;
while ((c = getopt(argc, argv, OPT_STRING)) != -1) {
switch (c) {
// ...
case 'v':
verbosity++;
break;
case 'w':
warn_on_reap++;
break;
case 'g': // 【如果程序启动的入参中有 g 的话,则 kill_process_group 的值递增】
kill_process_group++;
break;
// ...
case '?':
print_usage(name, stderr);
return 1;
default:
/* Should never happen */
return 1;
}
}
#endif
// ...
return 0;
}
// ...
通过上面的注释,我们现在知道:默认情况下,变量 kill_process_group
的值为 0,所以 init
进程不会将信号发送给子进程所在进程组的所有进程,只会发给子进程自己。
如果想要改变这种情况,有两种方案:一是程序启动的入参含有字符 g
,二是定义环境变量 TINI_KILL_PROCESS_GROUP
。
这里笔者就以环境变量的方案为例,在 docker run
命令中配置上 TINI_KILL_PROCESS_GROUP=true
,验证一下是否可行:
# terminal-0
demonlee@demonlee-ubuntu:signal-test$ docker run -d --name forward_signal_test_tini2 --env TINI_KILL_PROCESS_GROUP=true registry/fwd_sig:v2 /c-init-sig
d106278d1cb3b37bd3cb446078b92fe405145a5afc7cbb257f4dbd6a0cd9ab4c
demonlee@demonlee-ubuntu:signal-test$
demonlee@demonlee-ubuntu:signal-test$ docker exec -it forward_signal_test_tini2 ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.1 0.0 4344 720 ? Ss 02:17 0:00 /tini -- /c-init-sig
root 7 0.0 0.0 4340 828 ? S 02:17 0:00 /c-init-sig
root 8 0.0 0.0 4340 80 ? S 02:17 0:00 /c-init-sig
root 9 0.0 0.0 43964 3356 pts/0 Rs+ 02:17 0:00 ps aux
demonlee@demonlee-ubuntu:signal-test$ ps -ef|grep -v grep|grep c-init-sig
root 5727 5705 0 10:17 ? 00:00:00 /tini -- /c-init-sig
root 5760 5727 0 10:17 ? 00:00:00 /c-init-sig
root 5761 5760 0 10:17 ? 00:00:00 /c-init-sig
demonlee@demonlee-ubuntu:signal-test$ docker exec -it forward_signal_test_tini2 env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=d106278d1cb3
TERM=xterm
TINI_KILL_PROCESS_GROUP=true
TINI_VERSION=v0.19.0
HOME=/root
demonlee@demonlee-ubuntu:signal-test$ docker stop forward_signal_test_tini2
forward_signal_test_tini2
demonlee@demonlee-ubuntu:signal-test$
# terminal-1
demonlee@demonlee-ubuntu:signal-test$ sudo strace -p 5727
strace: Process 5727 attached
wait4(-1, 0x7ffda2c8532c, WNOHANG, NULL) = 0
rt_sigtimedwait(~[ILL TRAP ABRT BUS FPE SEGV TTIN TTOU SYS RTMIN RT_1], 0x7ffda2c852c0, {tv_sec=1, tv_nsec=0}, 8) = -1 EAGAIN (Resource temporarily unavailable)
...
wait4(-1, 0x7ffda2c8532c, WNOHANG, NULL) = 0
rt_sigtimedwait(~[ILL TRAP ABRT BUS FPE SEGV TTIN TTOU SYS RTMIN RT_1], {si_signo=SIGTERM, si_code=SI_USER, si_pid=0, si_uid=0}, {tv_sec=1, tv_nsec=0}, 8) = 15 (SIGTERM)
kill(-7, SIGTERM) = 0 # 【这里 pid 参数是 -7】
wait4(-1, 0x7ffda2c8532c, WNOHANG, NULL) = 0
rt_sigtimedwait(~[ILL TRAP ABRT BUS FPE SEGV TTIN TTOU SYS RTMIN RT_1], {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=8, si_uid=0, si_status=0, si_utime=0, si_stime=0}, {tv_sec=1, tv_nsec=0}, 8) = 17 (SIGCHLD)
wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WNOHANG, NULL) = 7
wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WNOHANG, NULL) = 8
wait4(-1, 0x7ffda2c8532c, WNOHANG, NULL) = -1 ECHILD (No child processes)
exit_group(0) = ?
+++ exited with 0 +++
demonlee@demonlee-ubuntu:signal-test$
# terminal-2
demonlee@demonlee-ubuntu:signal-test$ sudo strace -p 5760
[sudo] password for demonlee:
strace: Process 5760 attached
restart_syscall(<... resuming interrupted read ...>) = 0
nanosleep({tv_sec=100, tv_nsec=0}, {tv_sec=77, tv_nsec=702657702}) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=1, si_uid=0} ---
write(1, "To create 1 processes\nParent cre"..., 82) = 82
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=8, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
exit_group(0) = ?
+++ exited with 0 +++
demonlee@demonlee-ubuntu:signal-test$
# terminal-3
demonlee@demonlee-ubuntu:signal-test$ sudo strace -p 5761
[sudo] password for demonlee:
strace: Process 5761 attached
restart_syscall(<... resuming interrupted read ...>) = 0
nanosleep({tv_sec=100, tv_nsec=0}, {tv_sec=77, tv_nsec=702191733}) = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=1, si_uid=0} ---
write(1, "To create 1 processes\nChild => P"..., 63) = 63
exit_group(0) = ?
+++ exited with 0 +++
demonlee@demonlee-ubuntu:signal-test$
从结果来看,这一次如我们预想的那样,容器内的子进程都收到了 SIGTERM
信号,从而达到了优雅停机的目的。
总结
-
内核为什么必须要给容器中的子进程发
SIGKILL
信号,而不是直接发送SIGTERM
信号?这样不就不用转发了吗?因为
SIGTERM
信号可以被捕获,如果容器内有进程忽略该信号,那么关闭容器后,就有进程残留或僵尸进程。从这点来说,使用SIGKILL
进行强杀,也是无奈之举。 -
容器中一个标准的
init
进程应该具备哪些能力?(1)至少可以转发
SIGTERM
信号给容器里的其他进程。
(2)能够接收外部的SIGTERM
信号而退出(在 tini 中,init
进程将所有SIGTERM
信号转发给子进程后,后面还有回收僵尸进程的流程,在这里init
进程发现所有子进程都退出了,就会让自己也退出)。
(3)具备清理僵尸进程的能力。 -
尽量不要使用胖容器,最佳实践是一个容器一个进程,这样也就不用折腾所谓的信号转发了。
参考文献
- 凤凰架构,by 周志明