04 理解进程(3):为什么我在容器中的进程被强制杀死了?【init进程转发】

2024-06-08 20:18

本文主要是介绍04 理解进程(3):为什么我在容器中的进程被强制杀死了?【init进程转发】,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

今天我们来讲容器中 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.sh ,然后用 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 的。

进程对信号的处理其实就包括两个问题:

  • 【发送】一个是进程如何发送信号
  • 【接收】另一个是进程收到信号后如何处理

2.1 Kill()

我们在 Linux 中发送信号的系统调用是 kill(),之前很多例子里面我们用的命令 kill ,它内部的实现就是调用了 kill() 这个函数。

下面是 Linux Programmer’s Manual 里对 kill() 函数的定义。

这个 kill() 函数有两个参数:

  • 一个是 sig信号,代表需要发送哪个信号,比如 sig 的值是 15 的话,就是指发送 SIGTERM;
  • 另一个参数是 pid进程,也就是指信号需要发送给哪个进程,比如值是 1 的话,就是指发送给进程号是 1 的进程。
NAMEkill - send signal to a processSYNOPSIS#include <sys/types.h>#include <signal.h>int kill(`pid_t pid, int sig`);

我们知道了发送信号的系统调用之后,再来看另一个系统调用,也就是 signal() 系统调用这个函数,它可以给信号注册handler。

2.2 Signal()

下面是 signal() 在 Linux Programmer’s Manual 里的定义,参数 signum 也就是信号的编号,例如数值 15,就是信号 SIGTERM;参数 handler 是一个函数指针参数,用来注册用户的信号 handler。

NAMEsignal - ANSI C signal handlingSYNOPSIS#include <signal.h>typedef void (*sighandler_t)(int);sighandler_t signal(int signum, sighandler_t handler);

在容器 init 进程的第一讲里,我们学过进程对每种信号的处理,包括三个选择:调用系统缺省行为捕获忽略。而这里的选择,其实就是程序中如何去调用 signal() 这个系统调用。

1)缺省

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

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

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

2)捕获

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

捕获Signal() ——> 注册自己的handler ——> 不执行缺省代码(如不执行sigterm的退出) ——> 执行捕获后自己想做的事情

比如下面这段代码,我们为 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);
...
}
3)忽略

如果要让进程“忽略”一个信号,我们就要通过 signal() 这个系统调用,为这个信号注册一个特殊的 handler,也就是 SIG_IGN

比如下面的这段代码,就是为 SIGTERM 这个信号注册SIG_IGN

这样操作的效果,就是在程序运行的时候,如果收到 SIGTERM 信号,程序既不会退出,也不会在屏幕上输出 log,而是什么反应也没有,就像完全没有收到这个信号一样。

int main(int argc, char *argv[])
{
...signal(SIGTERM, SIG_IGN);
...
}

好了,我们通过讲解 signal() 这个系统调用,帮助你回顾了信号处理的三个选择:缺省行为捕获忽略

这里我还想要提醒你一点, SIGKILLSIGSTOP 信号是两个特权信号,它们不可以被捕获和忽略,这个特点也反映在 signal() 调用上。

我们可以运行下面的这段代码,如果我们用 signal() 为 SIGKILL 注册 handler,那么它就会返回 SIG_ERR,不允许我们做捕获操作。

# cat reg_sigkill.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>typedef void (*sighandler_t)(int);void sig_handler(int signo)
{if (signo == SIGKILL) {printf("received SIGKILL\n");exit(0);}
}int main(int argc, char *argv[])
{sighandler_t h_ret;h_ret = signal(SIGKILL, sig_handler);if (h_ret == SIG_ERR) {perror("SIG_ERR");}return 0;
}# ./reg_sigkill
SIG_ERR: Invalid argument

最后,我用下面这段代码来做个小结。

这段代码里,我们用 signal()SIGTERM 这个信号做了忽略捕获以及恢复它的缺省行为,并且每一次都用 kill() 系统调用向进程自己发送 SIGTERM 信号,这样做可以确认进程对 SIGTERM 信号的选择。


#include <stdio.h>
#include <signal.h>typedef void (*sighandler_t)(int);void sig_handler(int signo)
{if (signo == SIGTERM) {printf("received SIGTERM\n\n");// Set SIGTERM handler to defaultsignal(SIGTERM, SIG_DFL);}
}int main(int argc, char *argv[])
{//Ignore SIGTERM, and send SIGTERM// to process itself.signal(SIGTERM, SIG_IGN);printf("Ignore SIGTERM\n\n");kill(0, SIGTERM);//Catch SIGERM, and send SIGTERM// to process itself.signal(SIGTERM, sig_handler);printf("Catch SIGTERM\n");kill(0, SIGTERM);//Default SIGTERM. In sig_handler, it sets//SIGTERM handler back to default one.printf("Default SIGTERM\n");kill(0, SIGTERM);return 0;
}

我们一起来总结一下刚才讲的两个系统调用:

  • kill() 这个系统调用,它其实很简单,输入两个参数:进程号信号,就把特定的信号发送给指定的进程了。
  • signal() 这个调用,它决定了进程收到特定的信号如何来处理,SIG_DFL 参数把对应信号恢复为缺省 handler,也可以用自定义的函数作为 handler,或者用 SIG_IGN 参数让进程忽略信号。

对于 SIGKILL 信号,如果调用 signal() 函数,为它注册自定义的 handler,系统就会拒绝。


三、解决问题


我们在学习了 kill() 和 signal() 这个两个信号相关的系统调用之后,再回到这一讲最初的问题上

为什么在停止一个容器的时候,容器 init 进程收到的 SIGTERM 信号,而容器中其他进程却会收到 SIGKILL 信号呢?

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

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

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

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

整个流程如下图所示。

你还可以看一下,内核代码是这样的。

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

说到这里,我们也就明白为什么容器 init 进程收到的 SIGTERM 信号,而容器中其他进程却会收到 SIGKILL 信号了。

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

所以进程收到这个信号后,就立刻退出了,没有机会调用一些释放资源的 handler 之后,再做退出动作。

而 SIGTERM 是可以被捕获的,用户是可以注册自己的 handler 的。因此,容器中的程序在 stop container 的时候,

我们更希望进程收到 SIGTERM 信号而不是 SIGKILL 信号。

那在容器被停止的时候,我们该怎么做,才能让容器中的进程收到 SIGTERM 信号呢?

你可能已经想到了,就是让容器 init 进程来转发 SIGTERM 信号。的确是这样,比如 Docker Container 里使用的 tini 作为 init 进程,tini 的代码中就会调用 sigtimedwait() 这个函数来查看自己收到的信号,然后调用 kill() 把信号发给子进程。

我给你举个具体的例子说明,从下面的这段代码中,我们可以看到除了 SIGCHLD 这个信号外,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) {}} 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 进程中对收到的信号做个转发,发送到容器中的其他子进程,这样容器中的所有进程在停止时,都会收到 SIGTERM,而不是 SIGKILL 信号了。

这篇关于04 理解进程(3):为什么我在容器中的进程被强制杀死了?【init进程转发】的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1043202

相关文章

MySQL 强制使用特定索引的操作

《MySQL强制使用特定索引的操作》MySQL可通过FORCEINDEX、USEINDEX等语法强制查询使用特定索引,但优化器可能不采纳,需结合EXPLAIN分析执行计划,避免性能下降,注意版本差异... 目录1. 使用FORCE INDEX语法2. 使用USE INDEX语法3. 使用IGNORE IND

Java Spring的依赖注入理解及@Autowired用法示例详解

《JavaSpring的依赖注入理解及@Autowired用法示例详解》文章介绍了Spring依赖注入(DI)的概念、三种实现方式(构造器、Setter、字段注入),区分了@Autowired(注入... 目录一、什么是依赖注入(DI)?1. 定义2. 举个例子二、依赖注入的几种方式1. 构造器注入(Con

一文解密Python进行监控进程的黑科技

《一文解密Python进行监控进程的黑科技》在计算机系统管理和应用性能优化中,监控进程的CPU、内存和IO使用率是非常重要的任务,下面我们就来讲讲如何Python写一个简单使用的监控进程的工具吧... 目录准备工作监控CPU使用率监控内存使用率监控IO使用率小工具代码整合在计算机系统管理和应用性能优化中,监

Linux进程CPU绑定优化与实践过程

《Linux进程CPU绑定优化与实践过程》Linux支持进程绑定至特定CPU核心,通过sched_setaffinity系统调用和taskset工具实现,优化缓存效率与上下文切换,提升多核计算性能,适... 目录1. 多核处理器及并行计算概念1.1 多核处理器架构概述1.2 并行计算的含义及重要性1.3 并

Linux下进程的CPU配置与线程绑定过程

《Linux下进程的CPU配置与线程绑定过程》本文介绍Linux系统中基于进程和线程的CPU配置方法,通过taskset命令和pthread库调整亲和力,将进程/线程绑定到特定CPU核心以优化资源分配... 目录1 基于进程的CPU配置1.1 对CPU亲和力的配置1.2 绑定进程到指定CPU核上运行2 基于

SpringBoot结合Docker进行容器化处理指南

《SpringBoot结合Docker进行容器化处理指南》在当今快速发展的软件工程领域,SpringBoot和Docker已经成为现代Java开发者的必备工具,本文将深入讲解如何将一个SpringBo... 目录前言一、为什么选择 Spring Bootjavascript + docker1. 快速部署与

深入理解Go语言中二维切片的使用

《深入理解Go语言中二维切片的使用》本文深入讲解了Go语言中二维切片的概念与应用,用于表示矩阵、表格等二维数据结构,文中通过示例代码介绍的非常详细,需要的朋友们下面随着小编来一起学习学习吧... 目录引言二维切片的基本概念定义创建二维切片二维切片的操作访问元素修改元素遍历二维切片二维切片的动态调整追加行动态

Javaee多线程之进程和线程之间的区别和联系(最新整理)

《Javaee多线程之进程和线程之间的区别和联系(最新整理)》进程是资源分配单位,线程是调度执行单位,共享资源更高效,创建线程五种方式:继承Thread、Runnable接口、匿名类、lambda,r... 目录进程和线程进程线程进程和线程的区别创建线程的五种写法继承Thread,重写run实现Runnab

Spring IoC 容器的使用详解(最新整理)

《SpringIoC容器的使用详解(最新整理)》文章介绍了Spring框架中的应用分层思想与IoC容器原理,通过分层解耦业务逻辑、数据访问等模块,IoC容器利用@Component注解管理Bean... 目录1. 应用分层2. IoC 的介绍3. IoC 容器的使用3.1. bean 的存储3.2. 方法注

怎样通过分析GC日志来定位Java进程的内存问题

《怎样通过分析GC日志来定位Java进程的内存问题》:本文主要介绍怎样通过分析GC日志来定位Java进程的内存问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录一、GC 日志基础配置1. 启用详细 GC 日志2. 不同收集器的日志格式二、关键指标与分析维度1.