文章目录
- 背景
- 有关僵尸进程
- 什么是僵尸进程
- 僵尸进程代码示例
- 僵尸进程的避免(清除)
- 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操作来移除僵尸进程.
小结:
- 终止了但是没被父进程wait的进程是僵尸进程, 僵尸进程占用很少的资源.
- 因为僵尸进程占用内核进程表项, 所以其数量如果无限增长的话, 会导致系统无法创建出新进程
- 僵尸进程的父进程终止后, 僵尸进程会被init或者 最近的"subreaper"进程收养.
- 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"进程, 主要功能有:
- 转发信号
- 回收僵尸进程, 避免你的程序产生大量的僵尸进程.
甚至连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, ¤t_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, 愿世间无疾苦!
(完)