Kikimo's blog

An occasional blog on programming

一个 cron 僵尸进程问题的分析

前段时间遇到一个比较诡异的问题, 公司一台服务器上突然出现了许多僵尸进程。 多数时候,这些僵尸进程出现在早上,大概到了下午又自动消失。 第二天又重复这样的情况。 检查发现这些僵尸进程的父进程都是挂在 cron 下的 bash 进程。

僵尸进程

这大概能解释为什么每天早上总能发现僵尸进程的问题。 但是为什么会出现僵尸进程,以及,为什么这些僵尸进程到了下午又自己消失了?

僵尸进程意味着该进程已经执行结束,但是父进程没有调用 wait()函数 获取它的返回值。 所以首先检查下这些僵尸进程的父进程也就是 cron 进程在干什么。

父进程状态

可以看到 2584 这个僵尸进程的父进程 2582 处于睡眠状态。 strace 输出显示 2582 进程在等待在 read() 系统调用上。 read() 函数读取的文件描述符是 6,利用 lsof 来查看这个文件的更详细信息。

描述符 6 的详细信息

可以看到这是个管道文件, 对应 i 节点是 13865。 进程 2582 处于管道读的一端,根据这个 i 节点信息,我们可以利用 lsof 进一步找出管道写的一端关联的进程。

管道写端的进程

根据 lsof 的输出,大致可以断定 2586 和 18826 这两个进程把标准输出和错误输入都重定向到管道 6 写的一端了。 经过检查发现 2586 进程执行的 sleep.sh 脚本就是 cron 下的定时任务之一。 但是 ps 指令却显示,2586 任务进程的父进程是 init 进程。

任务进程的父进程

根据当前的返现, 可以猜测:bash 僵尸进程是因为父进程 cron 进程等待在任务进程上。 但是新的疑问是为什么任务进程的父进程会变成 1。 我们很可能会猜想:

bash 僵尸进程应该是任务进程的父进程;
任务进程还在执行,bash 父进程不应该变成僵尸进程;

到这里要继续分析下去只能去看 cron 的代码了, 检查 cron 的版本:

cron 版本

找到这个版本 cron 的代码,以 pipe 为关键字搜索代码:

cron pipe

可以看到 do_command.c 这个源码文件中有大量管道相关的操作,所以重点来分析这个文件。 do_command.c 文件总共 571 行代码,一共就三个函数。 其中重要的两个函数是

void do_command(entry * e, user * u);
static void child_process(entry * e, user * u);

分析这两个函数代码的,可以看到 do_command() 函数先从主 cron 进程 fork() 出一个子 cron 进程,然后执行 child_process() 函数。 在 child_process() 函数中 子 cron 进程通过显式的 wait() 调用来回收它子进程的返回值,这个可以从 93 - 98 行处的代码看出来:

 93         /* our parent is watching for our death by catching SIGCHLD.  we
 94          * do not care to watch for our children's deaths this way -- we
 95          * use wait() explicitly.  so we have to reset the signal (which
 96          * was inherited from the parent).
 97          */
 98         (void) signal(SIGCHLD, SIG_DFL);

子 cron 进程会进一步 fork() 出一个任务进程,这个任务进程通过 exec() 调用来执行任务程序。 子 cron 进程在 fork() 任务进程之前,一共建立了两条管道。 其中一条管道用于读取任务进程的输出结果,任务进程将标准输出和错误输出重定向到这条管道写的一端。 另一条管道用于向任务进程提供输入。 子 cron 进程会另外 fork() 出一个 stdin_writer 进程,这个进程往管道的一端写入数据, 任务进程将标准输入重定向到这条管道读的一端,直接读取 stdin_writer 给它写入的数据。 子 cron 进程和任务进程之间的通信关系可以用下图来描述:

子 cron 进程和任务进程间的通信

到这里,我们可以完全确定为什么会出现 bash 僵尸进程了:子 cron 进程卡在和任务进程通信的管道上,而没法调用 wait() 函数, 直到子进程执行结束关闭管道,子 cron 进程才 wait() 函数,bash 僵尸进程才消失。 但是另外一个问题还没解决,任务进程的父进程为什么会变成 init 进程?

检查 cron 中的定时任务指令:

/root/sleep.sh &

怀疑跟 & 符号有关,& 符号会使相关指令进入后台执行,查看 bash 的文档,看到以下关于 & 符号描述:

If a command is terminated by the control operator &, the shell executes the command in the background in a subshell. The shell does not wait for the command to finish, and the return status is 0.

& 符号会让相关的指令在 subshell 中以后台的方式运行,而更重要的一点是,shell 不会等待这个 subshell 执行结束。 这就导致一方面 shell 进程先退出执行,但是因为子 cron 进程没有调用 wait() 而变成僵尸进程, 另一方面 subshell 中执行的任务进程因为父进程变成僵尸进程,所以 init 进程就收养了它,它的父进程 id 变成 1。

写个简单的 demo 来测试一下,exec.c:

#include <stdio.h>
#include <unistd.h>

int main()
{
        int pid;

        if ((pid = fork()) == 0) {  // child process
                execle("/bin/bash", "/bin/bash", "-c", "/root/sleep.sh&", (char *) 0, NULL);
        }

        while (1) {
                sleep(1);
        }
}

编译执行以上代码:

bash zombie test

可以看看到,exec.c 的代码成功的复现出 bash 僵尸进程产生的场景了。

到现在,cron 任务僵尸进程的原因算是分析得比较清楚了,那要如何避免这个问题呢。 解决方法就是 cron 任务指令末尾不要加 & 符号,这样做完全没必要,而且是产生僵尸进程的直接原因。 高版本的 cron 不再使用管道和任务进程通信,& 符号不会导致僵尸进程的产生, 但是 & 符号会使得任务进程的父进程变成 init 进程,要检查当前的 cron 任务就变得麻烦了,所以,最好也不要加 & 符号。

comments powered by HyperComments