容器中的僵尸进程

news/2024/11/17 15:57:22/

文章目录

  • 背景
  • 有关僵尸进程
    • 什么是僵尸进程
    • 僵尸进程代码示例
    • 僵尸进程的避免(清除)
  • tini库学习
    • 使用tini回收容器内的僵尸进程
    • tini回收僵尸进程的方式
  • 总结

背景

在使用了crond的docker容器中发现了僵尸进程, 想要一探究竟.
复现如下:

Dockerfile

FROM ubuntu:18.04RUN apt-get update \&& apt-get install -y cronCOPY app .
COPY docker-entrypoint.sh .ENTRYPOINT ["/bin/sh", "docker-entrypoint.sh"]

docker-entrypoint.sh:

cron # 启动crond# 注册定时任务
echo 'sleep 10 &' > some-task.sh
echo '* * * * * sh /some-task.sh' | crontabchmod +x app# 这里直接使用./app ..., 会使得app成为bash的子进程, bash不会转发信号(如SIGTERM),  如果业务上依赖信号机制, 不能用./app的形式
# ./app >> app.log 2>&1# 用 exec ./app ...会让app成为1号进程, 这样可以依赖信号机制增加如优雅退出等逻辑
exec ./app >> app.log 2>&1

构建镜像并运行, 查看效果:

$ docker build -t app-exec:1.0 .
$ docker run --rm -d --name app-exec app-exec:1.0
# 进入容器
$ docker exec -it app-exec /bin/bash
$ # 进容器超过一分钟后, ps查看发现僵尸进程
root@adf9380df9af:/# ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 08:53 ?        00:00:00 ./app
root           8       1  0 08:53 ?        00:00:00 cron
root          19       0  0 08:53 pts/0    00:00:00 /bin/bash
root          38       1  0 08:54 ?        00:00:00 [sleep] <defunct>
root          42       1  0 08:55 ?        00:00:00 [sleep] <defunct>
root          48       1  0 08:56 ?        00:00:00 [sleep] <defunct>
root          49      19  0 08:56 pts/0    00:00:00 ps -ef

观察上述ps的输出, 有若干[sleep] <defunct>, 表示产生了僵尸进程, 且其PPID都是1(app进程).
由于我们的app进程没有回收僵尸进程的逻辑, 所以这些僵尸进程将会长驻容器, 除非容器重启).

要知道容器里的进程实际是宿主机里的, 我们回到宿主机看下:

$ pstree -p
systemd(1)-+-NetworkManager(1607)-+-{NetworkManager}(1665)...省略|-containerd-shim(892867)-+-app(892890)-+-cron(892935)|                         |             |-sleep(893323)|                         |             |-sleep(893815)|                         |             |-sleep(894566)|                         |             |-sleep(895133)|                         |             |-sleep(895498)|                         |             |-sleep(896108)|                         |             |-sleep(896669)|                         |             |-sleep(897356)|                         |             |-sleep(897980)|                         |             |-sleep(898539)|                         |             |-sleep(899129)|                         |             |-sleep(899689)|                         |             |-sleep(900276)|                         |             |-{app}(892941)|                         |             |-{app}(892942)|                         |             |-{app}(892943)|                         |             |-{app}(892944)|                         |             |-{app}(892945)|                         |             |-{app}(892946)|                         |             `-{app}(892947)|                         |-bash(893027)...省略

此处如果一直不处理, 僵尸进程将会越来越多, 导致系统无法产生新进程.

有关僵尸进程

什么是僵尸进程

man waitpid中节选这样一段话:

A child that terminates, but has not been waited for becomes a “zombie”. The kernel maintains a minimal set of information about the zombie process (PID, termination status, resource usage information) in order to allow the parent to later perform a wait to obtain information about the child.
As long as a zombie is not removed from the system via a wait, it will consume a slot in the kernel process table, and if this table fills, it will not be possible to create further processes. If a parent process terminates, then its “zombie” children (if any) are adopted by init(1), (or by the nearest “subreaper” process as defined through the use of the prctl(2) PR_SET_CHILD_SUBREAPER operation); init(1) automatically performs a wait to remove the zombies.

简单翻译如下:
一个终止但是没有被等待的子进程会变成"僵尸". 内核会保留有关僵尸进程很少的信息(pid, 终止状态, 资源使用情况)以备父进程后续执行wait操作.
由于僵尸进程没有被从系统中移除, 所以它会占用内核进程表的一个位置, 如果内核进程表满了的话, 系统就再也不能创建新的进程了.
如果父进程终止了, 那它的僵尸子进程(如果有的话)会被init(pid=1)进程 或者 最近的 "subreaper"进程收养. "subreaper"进程是通过调用 prctl(2) PR_SET_CHILD_SUBREAPER 产生的.
init进程会自动执行wait操作来移除僵尸进程.

小结:

  1. 终止了但是没被父进程wait的进程是僵尸进程, 僵尸进程占用很少的资源.
  2. 因为僵尸进程占用内核进程表项, 所以其数量如果无限增长的话, 会导致系统无法创建出新进程
  3. 僵尸进程的父进程终止后, 僵尸进程会被init或者 最近的"subreaper"进程收养.
  4. init进程会自动wait收养的僵尸进程.

僵尸进程代码示例

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>// 模拟僵尸进程产生
int main() {pid_t child_pid;child_pid = fork();if (child_pid < 0) {perror("fork");return 1;} else if (child_pid == 0) { // childprintf("child start. pid:%u\n", getpid());printf("child end. pid:%u\n", getpid());return 10;} else { // parent// 不wait子进程, 产生僵尸进程printf("parent start. pid:%u, child pid:%u\n", getpid(), child_pid);printf("parent end. pid:%u, child pid:%u\n", getpid(), child_pid);sleep(10);/*// 这一段是回收子进程的代码, 启用即可避免僵尸进程int status;// 循环等待子进程结束pid_t child_pid_wait;while(1) {child_pid_wait = waitpid(-1, &status, WNOHANG);if (child_pid_wait == 0) {printf("child process in running, wait\n");sleep(1);} else if (child_pid_wait < 0) {perror("waitpid");return 1;} else {printf("child process exited. child_pid_wait:%u\n", child_pid_wait);break;}}if (WIFEXITED(status)) { // 子进程正常退出(return/exit)printf("child process return : %d\n", WEXITSTATUS(status));}*/pause(); // 防止父进程退出}return 0;
}

编译运行, 发现会产生僵尸进程:

$ gcc -Wall -o zombie-demo zombie-demo.c
$ ./zombie-demo
$ # 新开一个窗口使用ps查看, 发现子进程(pid=859580)是僵尸进程(defunct)
$ ps -ef|grep zombie
root      859579  828642  0 16:05 pts/4    00:00:00 ./zombie-demo
root      859580  859579  0 16:05 pts/4    00:00:00 [zombie-demo] <defunct>
root      859831  859540  0 16:06 pts/0    00:00:00 grep --color=auto zombie

僵尸进程的避免(清除)

首先要知道通过kill -9 僵尸进程id 这种方式是不能清除僵尸进程的, 这个具体原因我并不清楚, 猜测和内核设计等因素有关.

从上述的案例得知, 通过在父进程中wait就可以避免僵尸进程的产生. 但是如果因为某些意外情况(如父进程卡死等无法wait子进程), 应该如何处理呢?

在本文开始的"什么是僵尸进程"一节中, 有这样的结论: "如果僵尸进程的父进程终止了, 那它的"僵尸子进程"会被init收养, init进程可以自动wait收养的僵尸进程, 按这个思路, 我们可以kill掉僵尸进程的父进程, 使得僵尸进程被init进程收养就可以了. 但是实际场景中, 父进程可能是业务主要进程, 不能Kill.

哦, 上面还提到了"subreaper"进程, 这个在僵尸进程的父进程终止时也可以收养僵尸进程呀. 嗯是的, 这也是tini的实现思路.

tini库学习

如果写过Dockerfile, 对tini应该有所了解. tini就是容器里的"init"进程, 主要功能有:

  1. 转发信号
  2. 回收僵尸进程, 避免你的程序产生大量的僵尸进程.

甚至连docker官方都在使用tini程序, 见文档: https://docs.docker.com/engine/reference/run/#specify-an-init-process

使用tini回收容器内的僵尸进程

Dockerfile

FROM ubuntu:18.04# Add Tini  首先要添加tini程序到镜像
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
# 注意这里, 入口使用tini
ENTRYPOINT ["/tini", "-vvv", "--"]RUN apt-get update \&& apt-get install -y cronCOPY app .
COPY docker-entrypoint.sh .# 你的应用程序作为tini的参数(tini会以此fork子进程并运行)
CMD ["/bin/sh", "docker-entrypoint.sh"]

经过上述改造, 进行测试发现不会再产生[sleep] <defunct>

ps查看时, 在some-task.sh运行时, 会发现sleep 10是tini的子进程(注意看ps的输出倒数第二行)

root@673df39c70c2:/# date; ps -ef|grep -v grep
Sat Dec 31 09:38:11 UTC 2022
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 09:19 ?        00:00:00 /tini -vvv -- /bin/sh docker-entrypoint.sh
root           8       1  0 09:19 ?        00:00:00 ./app
root          10       1  0 09:19 ?        00:00:00 cron
root         246       0  0 09:35 pts/0    00:00:00 /bin/bash
root         650       1  0 09:38 ?        00:00:00 sleep 10
root         678     246  0 09:38 pts/0    00:00:00 ps -ef

tini回收僵尸进程的方式

将https://github.com/krallin/tini/blob/master/src/tini.c精简到只有回收僵尸进程的逻辑, 结构如下(耐心读完下面的代码你就明白tini回收僵尸进程的主要原理了):

// 将当前进程注册为subreaper, 之后就可以收养进程了, 需要在linux>=3.4时才能使用
int register_subreaper () {    if (prctl(PR_SET_CHILD_SUBREAPER, 1)) {    if (errno == EINVAL) {    PRINT_FATAL("PR_SET_CHILD_SUBREAPER is unavailable on this platform. Are you using Linux >= 3.4?")    } }...
}    // 创建子进程并执行
int spawn(char* const argv[], int* const child_pid_ptr) {pid = fork();    if (pid == 0) {    // Put the child in a process group and make it the foreground process if there is a tty.    if (isolate_child()) {    return 1;    }execvp(argv[0], argv);    } else {// parent ...}
}// 回收僵尸进程主要逻辑
int reap_zombies(const pid_t child_pid, int* const child_exitcode_ptr) {pid_t current_pid;int current_status;while (1) {// -1表示wait所有子进程, WNOHANG表示非阻塞(调用后直接返回结果)current_pid = waitpid(-1, &current_status, WNOHANG);switch (current_pid) {case -1: // -1表示waitpid出错if (errno == ECHILD) {PRINT_TRACE("No child to wait");break;}PRINT_FATAL("Error while waiting for pids: '%s'", strerror(errno));return 1;case 0: // 没有子进程 或者 子进程状态没有变化(子进程还是running状态)PRINT_TRACE("No child to reap");break;default:/* 看这里的原文注释就好了* A child was reaped. Check whether it's the main one. If it is, then* set the exit_code, which will cause us to exit once we've reaped everyone else.*/PRINT_DEBUG("Reaped child with pid: '%i'", current_pid);if (current_pid == child_pid) { // child_pid就是fork()出来的子进程的pidif (WIFEXITED(current_status)) {/* Our process exited normally. 子进程正常退出 */PRINT_INFO("Main child exited normally (with status '%i')", WEXITSTATUS(current_status));*child_exitcode_ptr = WEXITSTATUS(current_status);} else if (WIFSIGNALED(current_status)) { // 子进程因信号导致退出/* Our process was terminated. Emulate what sh / bash* would do, which is to return 128 + signal number.*/PRINT_INFO("Main child exited with signal (with signal '%s')", strsignal(WTERMSIG(current_status)));*child_exitcode_ptr = 128 + WTERMSIG(current_status);} else {PRINT_FATAL("Main child exited for unknown reason");return 1;}// Be safe, ensure the status code is indeed between 0 and 255.*child_exitcode_ptr = *child_exitcode_ptr % (STATUS_MAX - STATUS_MIN + 1);} else if (warn_on_reap > 0) { // 回收了其他子进程, 如上述例子中的sleepPRINT_WARNING("Reaped zombie process with pid=%i", current_pid);}// Check if other childs have been reaped.continue;}/* If we make it here, that's because we did not continue in the switch case. */break;}return 0;
}int main() {register_subreaper();spawn(); // fork() , execvpwhile (1) { // 不断地回收僵尸进程reap_zombies();}return 0;
}

总结

容器中推荐使用tini, 来转发信号及回收僵尸进程.
事实上使用 exec ./app这种形式已经可以转发信号了, 但是不能回收僵尸进程. 如果说容器里出现三五个僵尸进程, 且数量不会增长, 个人认为不必担心, 毕竟占用很少资源.

以此篇送别2022, 迎接2023, 愿世间无疾苦!
(完)


http://www.ppmy.cn/news/8408.html

相关文章

常用数学公式KaTex输入方式一览

文章目录前言: TeX、LaTeX和KaTeX常见数学公式一览表矩阵希腊字母参考TODO前言: TeX、LaTeX和KaTeX TeX是一个电子排版系统&#xff0c;LaTeX是一套宏集&#xff0c;面向各种终端&#xff0c;KaTeX是面向web的。 不会KaTex的人&#xff0c;无以言。 常见数学公式一览表 序号…

Pyinstaller - 你的“神”队友

哈哈&#xff01;今天是我在2023年发布的第一篇文章呀&#xff01; 这两天&#xff0c;我在做一个爬虫项目。因为我做好后准备给我的朋友看看&#xff0c;但我朋友没有 Python 环境。所以&#xff0c;只好想办法把 .py 打包成 .exe 。 在网上搜了一下&#xff0c;发现目前相对…

Python每日一练 11——流程跳转语句

Python每日一练 11——流程跳转语句 文章目录Python每日一练 11——流程跳转语句一、pass语句二、continue三、实例一&#xff1a;统计平均成绩四、break语句五、实例二&#xff1a;加法训练机六、实例三&#xff1a;自身以外的最大因数七、实例四&#xff1a;百钱买百鸡进阶八…

Netconf协议讲解

目录 什么是Netconf 为什么要提出Netconf 数据的类别 传统网络配置协议 Netconf配置协议 Netconf协议架构 安全传输层 消息层 操作层 内容层 Netconf配置设备流程 通过Python进行Netconf配置 什么是Netconf NETCONF&#xff08;Network Configuration Protocol&…

04 kafka 中一些常用的配置的使用

前言 呵呵 也是最近有一些 搭建 kafka 的环境的需求 然后 从新看了一下 一部分的配置情况, 这里 大致理一下 一些我这里比较关心的配置 那些配置关联了 kafka 服务器绑定服务 绑定 tcp 服务的配置来自于这里, 读取的是 config.dataPlaneListeners config.dataPlaneListen…

实战四十六:基于LightGBM的广告点击预测 代码+数据

配库: 1. 读取原始数据, 将时间信息分解为天和分钟 2. 特征工程 3. 五折交叉验证训练模型 4. 特征重要性 5. 做出最终预测

使用异步ORM SQLAlchemy提升web服务性能

介绍 对于一个web服务&#xff0c;性能的瓶颈最终基本上都会出现在数据库读取的这一步上&#xff0c;如果能够在数据库读取数据的这一段时间自动切换去处理其他请求的话&#xff0c;服务的性能会得到非常显著的提升&#xff0c;因此需要选择一个合适的异步驱动和工具包 SQLAl…

诞生两年+,三翼鸟的“场景”思维有进化吗?

出品 | 何玺 排版 | 叶媛 2020年9月&#xff0c;三翼鸟品牌正式发布。截止2022年12月&#xff0c;三翼鸟已经走过了2年多的历程。诞生两年&#xff0c;三翼鸟有什么样的发展&#xff0c;它倡导的“场景”思维有进化吗&#xff1f;我们一起来看看。 01 从三翼鸟的“全球首个场…