问题再现
平时容器运行久了之后,运行 ps 命令会看到一些进程,进程名后面加了<defunct>标识。那么这些是什么进程呢?
我们启动一个容器,该容器的功能是 fork 出 1000 个子进程。这些子进程运行结束后进程状态都变为了 Z,即僵尸进程。
那么什么是僵尸进程?它们是怎么产生的?太多会导致什么问题?
知识详解
linux进程状态
无论进程还是线程,在 Linux 内核里都是用 task_struct{}这个结构来表示。它其实就是任务(task),也就是 Linux 里基本的调度单位。
为了方便讲解,暂且称它为进程。
那一个进程从创建(fork)到退出(exit),这个过程中的状态转化还是很简单的。从下图可以看出来,在进程“活着”的时候就只有两个状态:运行态 (TASK_RUNNING)和睡眠态(TASK_INTERRUPTIBLE, TASK_UNINTERRUPTIBLE)
运行态指无论进程是正在运行中(获得 CPU),还是在 run queue 队列里随时可以运行,都处于这个状态。
想要查看进程是不是处于运行态,可以使用 ps 命令,处于这个状态的进程显示的是 R stat。
睡眠态指进程需要等待某个资源而进入的状态,要等待的资源可以是信号量, 或者是磁盘 I/O,这个状态的进程会被放入到 wait queue 队列里。
睡眠态具体还包括两个子状态:一个是可以被打断的(TASK_INTERRUPTIBLE),用 ps 查看显示为 S stat。还有一个是不可被打断的(TASK_UNINTERRUPTIBLE),为 D stat。
除了在活的时候的两个状态,进程在调用 do_exit() 退出时,还有两个状态。
一个是 EXIT_DEAD,即进程在真正结束退出的那一瞬间的状态;第二个是 EXIT_ZOMBIE 状态,进程在 EXIT_DEAD 前的一个状态,而僵尸进程也就是处于这个状态中。
限制容器中进程数目
理解了 Linux 进程状态之后,我们还需要知道在 Linux 中怎么限制进程数目。弄清楚这个问题才能更深入地去理解僵尸进程的危害。
一台 Linux 机器上的进程总数目是有限制的。如果超过最大值,系统就无法创建出新的进程。
这个最大值可以在 /proc/sys/kernel/pid_max 参数中看到。
Linux 内核在初始化系统时,会根据机器 CPU 的数目来设置 pid_max 的值。
比如说机器中 CPU 数目小于等于 32,那么 pid_max 就会被设置为 32768(32K);如果大于 32,那么 pid_max 就被设置为 N*1024 (N 就是 CPU 数目)。
如果容器中的应用创建过多的进程或者出现 bug,就会产生类似 fork bomb 的行为。fork bomb 指在计算机中,通过不断建立新进程来消耗系统中的进程资源,是一种黑客攻击方式。
这样容器中的进程数就会把整个节点的可用进程总数给消耗完。不但会使同一个节点上的其他容器无法工作,还会让宿主机本身也无法工作。所以对于每个容器都需要限制它的最大进程数目,而这个功能由 pids Cgroup 来完成。
在容器建立后,创建容器的服务会在 /sys/fs/cgroup/pids 下建立一个子目录,就是一个控制组,控制组里最关键的一个文件就是 pids.max。这个值就是容器允许的最大进程数目。
解决问题
在前面 Linux 进程状态的介绍里,我们知道僵尸进程是 Linux 进程退出状态的一种。
从内核进程的 do_exit() 函数我们也可以看到,这时候进程 task_struct 里的 mm/shm/sem/files 等文件资源都已经释放了,只留下了一个 stask_struct instance 空壳。
从进程对应的 /proc/ 文件目录下也可以看出来,对应的资源都已经没有了。
并且进程也已经不响应任何的信号了,无论 SIGTERM(15) 还是 SIGKILL(9)。
当多个容器运行在同一个宿主机上时,为了避免一个容器消耗完宿主机进程号资源,我们会配置 pids Cgroup 来限制每个容器的最大进程数目。即进程数目在每个容器中也是有限的。
残留的僵尸进程多了以后,给系统带来最大问题就是它占用了进程号。这就意味着很有可能会导致新的进程不能运转。
这里再次借用开头的那个例子,也就是一个产生了 1000 个僵尸进程的容器。1 个 init 进程 +1000 个僵尸进程 +1 个 bash 进程 ,总共 1002 个进程。 如果 pids Cgroup 限制容器的最大进程号的数量为 1002 的话,可以看到pids.current == pids.max,达到了容器进程号数的上限。
这时如果在容器里想再启动一个进程,例如运行一下 ls,就会看到 Resource temporarily unavailable 的错误消息。已经退出的无用进程,却阻碍了有用进程的启动,显然这样是不合理的。
那么接下来看看僵尸进程到底是怎么产生的。只有理解它的产生机制,才能想明白怎么避免僵尸进程的出现。
先看一下模拟僵尸进程的程序。父进程在创建完子进程之后就不管了,这就是造成子进程变成僵尸进程的原因。
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
int i;
int total;
if (argc < 2) {
total = 1;
} else {
total = atoi(argv[1]);
}
printf("To create %d processes\n", total);
for (i = 0; i < total; i++) {
pid_t pid = fork();
if (pid == 0) {
printf("Child => PPID: %d PID: %d\n", getppid(),
getpid());
sleep(60);
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;
}
子进程变成僵尸进程的原因在于父进程“不负责”,那如何来解决。子进程在容器里“赖着不走”,就需要让父进程出面处理了。
在 Linux 中的进程退出后如果进入僵尸状态,就需要父进程调用 wait()系统调用去回收僵尸进程的最后的那些系统资源,比如进程号资源。
在刚才那段代码里,主进程进入 sleep(100) 之前,加上一段 wait() 函数调用, 就不会出现僵尸进程的残留了。
for (i = 0; i < total; i++){
int status;
wait(&status);
}
而容器中所有进程的最终父进程,就是 init 进程,由它负责生成容器中的所有其他进程。因此 init 进程有责任回收容器中的所有僵尸进程。
但是 wait() 系统调用有一个问题。它是一个阻塞的调用,如果没有子进程是僵尸进程的话,这个调用就一直不会返回,那么整个进程就会被阻塞住。
不过还有另一个方法处理。Linux 还提供了一个类似的系统调用 waitpid(),这个调用的参数更多。其中就有一个参数 WNOHANG,含义是如果在调用时没有僵尸进程,函数就马上返回了,而不会一直等待在那里。
waitpid仅等待直接子进程的状态变化,所以如果init进程创建进程A,A再创建进程B,B执行完毕后成为僵尸进程而A还在运行,即使init进程在调用waitpid依然无法回收