一次 Docker 容器内大量僵尸进程排查分析

前段时间线上的一个使用 google Puppeteer 生成图片的服务炸了 , 每个 Docker 容器内都有几千个孤儿僵死进程没有回收 , 如下图所示 。

一次 Docker 容器内大量僵尸进程排查分析

文章插图
 
这篇文章比较长 , 主要就讲了下面这几个问题 。
  • 什么情况下会出现僵尸进程、孤儿进程
  • Puppeteer 工作过程启动的进程与线上事故分析
  • PID 为 1 的进程有什么特殊的地方
  • 为什么 node/npm 不应该作为镜像中 PID 为 1 的进程
  • 为什么 Bash 可以作为 PID 为 1 的进程 , 以及它做 PID 为 1 的进程有什么缺陷
  • 镜像中比较推荐的 init 进程的做法是什么
Puppeteer 是一个 node 库 , 是 Chrome 官方提供的无界面 chrome 工具(headless chrome) , 它提供了操作 Chrome API 的方式 , 允许开发者在程序中启动 chrome 进程 , 调用 JS 的 API 实现页面加载、数据爬取、web 自动化测试等功能 。
本案例中使用的场景是使用 Puppeteer 加载 html , 随后截图生成一张分销海报的图片 。文章分析了这个问题背后的原因 , 接下来开始正式的内容 。
进程每个进程都有一个唯一的标识 , 称为 pid , pid 是一个非负的整数值 , 使用 ps 命令可以查看 , 在我的 mac 电脑上执行 ps -ef 可以看到当前运行的所有进程 , 如下所示 。
UIDPIDPPIDC STIMETTYTIME CMD0100 六04下午 ??23:09.18 /sbin/launchd03910 六04下午 ??0:49.66 /usr/sbin/syslogd04010 六04下午 ??0:13.00 /usr/libexec/UserEventAgent (System)其中 PID 是表示进程号 。
系统中每个进程都有对应的父进程 , 上面 ps 输出中的 PPID 就表示进程的父进程号 。最顶层的进程的 PID 为 1 , PPID 为 0 。
打开 iTerm , 在终端中执行一个命令 , 比如 "ls" , 实际上系统会创建新的 iTerm 子进程 , 这个 iTerm 进程又创建了 zsh 子进程 。在 zsh 中输入的 ls 命令 , 则是 zsh 进程又启动了一个 ls 子进程 。在 iTerm 中输入 ls 命令过程的进程关系如下所示 。
UIDPIDPPIDC STIMETTYTIME CMD50132110 六04下午 ??61:01.45 /Applications/iTerm.app/Contents/MacOS/iTerm2 -psn_0_81940501 9792032108:02上午 ttys0390:00.07 /Applications/iTerm.app/Contents/MacOS/iTerm2 --server login -fp arthur0 97921 9792008:02上午 ttys0390:00.03 login -fp arthur501 97922 9792108:02上午 ttys0390:00.29 -zsh501 98369 9792208:14上午 ttys0390:00.00 ./a.out进程与 fork前面提到的父进程“创建”子进程 , 更严谨的描述是 fork(孵化、衍生) 。下面来看一个实际的例子 , 新建一个 fork_demo.c 文件 。
#include <unistd.h>#include <stdio.h>int main() {int ret = fork();if (ret) {printf("enter if blockn");} else {printf("enter else blockn");}return 0;}执行上的代码 , 会输出如下的语句 。
enter if blockenter else block可以看到 if、else 语句都被执行了 。
fork 调用fork 是一个系统调用 , 它的方法声明如下所示 。
pid_t fork(void);fork 调用完成后会生成一个新的子进程 , 且父子进程都从 fork 返回处继续执行 。这里需要特别注意的是 fork 的返回值的含义 , 在父进程和新的子进程中 , 它们的含义不一样 。
  • 在父进程中 fork 的返回值是新创建的子进程 id
  • 在创建的子进程中 fork 的返回值始终等于 0
因此可以通过 fork 的返回值区分父子进程 , 在运行过程中可以使用 getpid 方法获取当前的进程 id 。fork 典型的使用方式如下所示 。
#include <unistd.h>#include <stdio.h>#include <stdlib.h>int main() {printf("before fork, pid=%dn", getpid());pid_t childPid;switch (childPid = fork()) {case -1: {// fork 失败printf("fork error, %dn", getpid());exit(1);}case 0: {// 子进程代码进入到这里printf("in child process, pid=%dn", getpid());break;}default: {// 父进程代码进入到这里printf("in parent process, pid=%d, child pid=%dn", getpid(), childPid);break;}}return 0;}执行上面的代码 , 输出结果如下所示 。
before fork, pid=26070in parent process, pid=26070, child pid=26071in child process, pid=26071子进程是父进程的副本 , 子进程拥有父进程数据空间、堆、栈的复制副本  , fork 采用了 copy-on-write 技术 , fork 操作几乎瞬间可以完成 。只有在子进程修改了相应的区域才会进行真正的拷贝 。


推荐阅读