一次Node_Docker使用Puppeteer大量僵司进程排查

Dec 21, 2021

缘由

原因是之前上线一个由Nodejs,使用原镜像使用debian打包,添加nodejs/puppeteer作为业务需求的服务。该服务使用量不是很大,但是经常会出现cpu特别大,并且内存一直无法释放问题。之前的操作就是时不时重启下。

一直没有时间专门处理这个业务bug,这会想起,给他解决掉,同时也发现该非常好的Blog。想着一次解决,能免于用户的不满回馈。

错误展现

也正出现如参考Blog出现的,$ps -ef 会展示:

1
2
3
root xx 1 0 ${time} [chrome] <defunct>
root xx 1 0 ${time} [chrome] <defunct>
root xx 1 0 ${time} [chrome] <defunct>

<defunct>正是表明该子进程挂了,但是父进程没有为子进程做回收动作。子进程退出后绝大部分资源已经被释放可供其他进程使用,但是内核的进程表中没有槽位释放。

进程与fork

fork()(孵化、衍生),作用是生成一个新的进程,父子进程都从fork处继续执行。

子进程是父进程的副本,子进程拥有父进程数据空间、堆、栈的复制副本,fork采用了copy-on-write技术,fork操作几乎瞬间可以完成。只有在子进程修改了相应的区域才会进行真正的拷贝。

孤儿进程

一个父进程已经终止,开启的子进程还活着的进程被称为孤儿进程(orphan process)。没人管的孤儿进程会被进程PID为1的进程接管。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
printf("before fork, pid=%d\n", getpid());
pid_t childPid;
switch (childPid = fork()) {
case -1: {
printf("fork error, %d\n", getpid());
exit(1);
}
case 0: {
printf("in child process, pid=%d\n", getpid());
sleep(100000); 子进程sleep 不退出
break;
}
default: {
printf("in parent process, pid=%d\n child pid=%d\n", getpid(), childPid);
exit(0);
}
}
return 0;
}

编译运行上面代码: $gcc fork_demo.c -o fork_demo; ./fork_demo

输出结果如下:

1
2
3
before fork, pid=21629
in parent process, pid=21629, child pid=21630
in child process, pid=21630

使用ps -ef | grep "216"查看进程信息:

1
2
3
UID        PID  PPID  C STIME TTY          TIME CMD
root 1 0 0 12月12 ? 00:00:53 /usr/lib/systemd/systemd --system --deserialize 21
ya 21630 1 0 19:26 pts/8 00:00:00 ./fork_demo

可以看到此时孤儿子进程21630的父PPID已经变为了顶层的PID为1的进程。

以上正是我当前遇到的,我就到此说明完整问题了。此处重点,会由PID 为1的进程接管。

PID 为 1 的进程

Linux中内核初始化以后会启动系统的第一个进程,PID为1,也可以称之为init进程或者根(ROOT)进程。

init进程有下面这几个功能:

  • 如果一个进程的父进程退出来,那么这个init进程便会接管这个孤儿进程。
  • 如果一个进程的父进程未执行wait/waitpid就退出了,init进程会接管子进程并自动调用wait方法,从而保证系统中的僵司进程可以被移除。
  • 传递信号给子进程

为什么Nodejs不适合做Docker镜像中PID为1的进程

在Nodejs的官方最佳实践里有写到”Node.js was not designed to run as PID 1 which leads to unexpected behaviour when running inside of Docker.”

p0

解决方式

解决方式二: 使用专门的init进程

直接使用解决方式二

Nodejs提供了两种方案,第一种是使用docker官方的轻量级init系统,如下所示

docker run -it --init your_docker_image_id

或者docker-compose.yml version: “2.2” (使用2.2版本,其支持init)

1
2
3
services:
web:
init: true

即可正确支持。

最终效果:

p1

另一种是使用tini程序启动,由tini进程去fork出新进程。

在Dockerfile中增加替换Entry入口:

1
2
3
4
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--"]

仅是入口修改,tini 进程会接管启动传入的程序,这样顶层Pid 1 就不会为Nodejs

这样就如: /tini -- node app.js,首先生成tini 为pid=1的启动项,tini 运行后面的入参为fork 后的程序执行,比如可以调用execvp("node", "node app.js")

(debian中便捷方案:

1
2
apt-get update && apt-get install -y tini
ENTRYPOINT ["/usr/bin/tini", "--"]

), 效果一样

结论

pid为1的不能为Nodejs、网络Java、网络Go等。

为了使io不被打断去支撑更多的服务链接,如果有fork动作,上述进程则直接跳过不等待。

自己不接管子程序的回收(没有wait/waitpid),那就找pid为1的进程帮助回收,那要是pid也为自己怎么办?于是pid为1的进程不能为上述进程(包括其他语言的相关框架哈)。

例如: Nodejs没有去wait/waitpid 管理子进程状态.那么在Docker启动中,使用init进程接管pid,由init去handle没人管理的僵司进程,这时便能解决本次问题。


上面解释、注解90%为原Blog内容,本机自己实操基本雷同,但是不如Blog老师的文章,请看到的直接前往Blog老师地址:

完全参考Blog - 一次 Docker 容器内大量僵尸进程排查分析