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 任务就变得麻烦了,所以,最好也不要加 & 符号。

僵尸进程的一些处理方法

1. 僵尸进程及其危害

进程执行结束后, 一般会返回一个返回值, 例如: 程序正常退出 main() 函数返回零值, 非正常退出 main() 函数返回非零值. 父进程通过 wait() 系统调用获取子进程的返回值. 在子进程执行结束后, 子进程的返回值被父进程获取前的这段时间内的子进程就成为僵尸进程(Z 状态,defunct 进程). 子进程的返回值被读取后, 系统会回收该进程的 PCB(Process Control Block) 结构体, 并从进程列表中删除该进程.

如果父进程一直没有读取子进程的返回值, 子进程就会一直处于僵尸状态,它的 PCB 就一直无法释放. PCB 本身是一个庞大的结构体, 包括页表索引、进程打开的文件、进程打开的 socket 等等, 再加上系统为进程分配的内存页表,内存占用相当可观. 如果系统存在大量的且持续增长的僵尸进程, 最终可能耗尽系统内存, 导致系统和运行于系统上的服务都无法正常工作.

2. 处理僵尸进程的一些思路

  1. 僵尸进程是杀不掉的, 尝试 kill 它没用

    它都已经执行结束了,就等着释放资源.

  2. 僵尸进程不是进程僵死

    一定要把僵尸进程和进程僵死两种情况区别开来. 进程僵死是进程尚未执行完毕, 但是因为死锁或者其他原因卡住了. 僵尸进程则是进程已经执行结束, 但是资源没有释放.

  3. 处理僵尸进程,首先要关注的可能并不是僵尸进程本身

    例如,发现大量 salt 相关的僵尸进程,并不一定就是 salt 进程本身的问题.

  4. 处理僵尸进程,重点应该关注僵尸进程的父进程

    父进程执行 wait() 系统调用收集子进程的返回值,然后僵尸进程就可以释放资源彻底退出了. 所以,分析僵尸进程最重要的是分析僵尸进程的父进程: 为什么没有执行 wait() 系统调用.

    1. 找到僵尸进程的父进程, 观察父进程的状态

       $ ps -p ${ppid} -o state | tail -n 1
      
    2. 如果父进程处于 T (stopped) 状态, 那基本可以确定是父进程的问题, 恢复父进程的执行应该就能解决问题

       $ kill -SIGCONT ${ppid}
      

      然后, 下一步要做的是查清楚谁让父进程变成了 T(stopped) 状态, 这才是问题的根源.

    3. 如果父进程处于正常状态, 可以执行 strace 观察它的行为

       $ strace -p ${ppid}
      

      strace 跟踪进程执行的的系统调用, 根据进程的系统调用情况某种程度可以推断当前进程在做什么, 比如, 是否发生了死锁等等. 需要注意的是 strace 会唤醒暂停状态的进程,所以应该先观测进程的状态再尝试执行 strace.

  5. init 进程和孤儿进程

    init 进程是 Linux 启动后运行的第一个进程, init 进程会进一步 fork 出所有用户进程. 当某进程先于它的子进程退出后,其子进程就会变成孤儿进程,这时候 init 进程会收养该孤儿进程成为它的父进程. init 进程会定时收集其子进程的返回值, 从而使子进程释放资源.

    早期的 Linux 内核版本允许用户给他发送信号. 一个以 root 身份运行的脚本或程序, 如果因为实现的 bug 或其他原因向 init 进程发送了 SIGSTOP 信号, init 进程会就此处于暂停运行状态. 处于暂停状态的 init 进程会导致其子进程退出后变成僵尸进程, 堆积的僵尸进程可能会最终导致服务器宕机. 对于此种情况在机器上执行了 $ kill -SIGCONT 1 后就可以让 init 进程恢复运行.

    需要特别指出以下几点重要的信息:

    1. 高版本 Linux 内核的 init 进程是不会受 SIGSTOP 信号影响的

      SIGSTOP 信号应该是被直接忽略了, 同时你也不能再对 init 进程执行 strace 了, 因为对 init 进程执行 ptrace() 被禁止了, root 也不行.

    2. 父进程先于子进程退出, 程序这样做不是 BUG

      Unix/Linux 本来就是特地把 init 进程设计成可以收养孤儿进程, 从而支持用户这么干的, 这不是 BUG.

  6. 僵尸进程的存在并不一定意味着系统或服务有问题

    比如父进程忙于其他事务, 过一段时间后才执行 wait() 系统调用获取子进程返回值, 这种情况可能不是什么大问题.

  7. 僵尸进程有可能是程序设计不周导致, 比如

    父进程一直在运行, 但父进程的代码逻辑中根本不考虑收集子进程的返回值, 这个程序的设计就是有问题了, 这会导致子进程变成僵尸进程. 但是, 还是要和父进程先退出, init 进程收养子进程的这种情况区别开来, 切记, 这不是 BUG.

以上是处理僵尸进程时可以考虑的一些切入点, 具体问题, 还需要根据具体的场景来分析.

SSL/TLS 协议简介

SSL/TLS 是一套提供网络安全通信的协议, 它一般运行在 TCP 层上面. 概括而言, SSL/TLS 协议通过非对称加密算法来验证身份和交换对称加密算法的秘钥, 然后通过对称加密算法来加密和解密通信数据. 非对称加密算法的特点是加密的私钥可解密的公钥是不同的, 所以如果别人能用公钥解密你发送的加密数据, 那就说明你拥有对应的私钥, 这就可以证明你的身份了. 公钥在通信的时候要发送给客户端, 为了证明这个公钥是合法的, 它还必须经过第三方权威机构签名, 否则任何人都可以自己生成一对公钥/私钥来伪造身份. 权威机构的签名用的也是非对称加密算法, 不同的是, 权威机构的公钥一般会预装到客户端操作系统上. 这样用户就可以通过预装的 CA 的公钥来验证服务器发送过来的私钥.

cert 证书文件

cert 证书文件中包含着公钥密码, 它必须经过 Root CA 或者 intermediate CA 签名后才是有效的. cert 证书文件包含着给他签名的 CA 的信息. 客户端可以通过系统上预装的 CA 的证书来验证该证书文件的有效性. 如果找不到对应的 CA 那这就不是一个可信的证书文件.

csr 文件

csr 文件即证书签名请求文件(Certificate Signing Request), csr 文件一般包含着组织名称, 域名, 私钥密码等信息, 当你要请求 CA 机构给你的证书签名是便要向 CA 机构提交 csr 文件.

root CA

权威证书签名机构. 用户的证书文件经过 root CA 的私钥签名后就为一个可信证书. root CA 的证书一般会预装在操作系统或者其他设备上. 假设某服务器的证书文件是经过某 root CA 签名, 该 root CA 的证书被预装到用户浏览器的系统上, 当用户浏览器需要和这台服务器建立安全连接是, 系统上的预装的 root CA 的证书便可以用来验证服务器发过来的证书.

intermediate CA 和证书链

很多时候, 服务器端没法直接获得 root CA 的证书签名(太贵了?). 这个时候便可以通过 intermediate CA 来获得证书签名. intermediate CA 他们的拥有 root CA 或者其他 intermediate CA 的证书签名. 对于 intermediate CA, 往上一直追溯他们的签名机构, 最顶端的一定是一个 root CA. 这就构成了一个证书签名链. 客户端的机器上一般只装有少数的 root CA 证书, 所以如果一个服务器证书是通过 intermediate CA 签名的, 那么在 ssl 握手时必须把自己的签名证书和证书链一起发送给客户端.