首頁>技術>

前段時間線上的一個使用 Google Puppeteer 生成圖片的服務炸了,每個 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 可以看到當前執行的所有程序,如下所示。

UID   PID  PPID   C STIME   TTY           TIME CMD  0     1     0   0 六04下午 ??        23:09.18 /sbin/launchd  0    39     1   0 六04下午 ??         0:49.66 /usr/sbin/syslogd  0    40     1   0 六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 命令過程的程序關係如下所示。

  UID   PID  PPID   C STIME   TTY           TIME CMD  501   321     1   0 六04下午 ??        61:01.45 /Applications/iTerm.app/Contents/MacOS/iTerm2 -psn_0_81940  501 97920   321   0  8:02上午 ttys039    0:00.07 /Applications/iTerm.app/Contents/MacOS/iTerm2 --server login -fp arthur    0 97921 97920   0  8:02上午 ttys039    0:00.03 login -fp arthur  501 97922 97921   0  8:02上午 ttys039    0:00.29 -zsh  501 98369 97922   0  8:14上午 ttys039    0: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 block\\n");  } else {    printf("enter else block\\n");  }  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=%d\\n", getpid());  pid_t childPid;  switch (childPid = fork()) {    case -1: {      // fork 失敗      printf("fork error, %d\\n", getpid());      exit(1);    }    case 0: {      // 子程序程式碼進入到這裡      printf("in child process, pid=%d\\n", getpid());      break;    }    default: {      // 父程序程式碼進入到這裡      printf("in parent process, pid=%d, child pid=%d\\n", 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 操作幾乎瞬間可以完成。只有在子程序修改了相應的區域才會進行真正的拷貝。

孤兒程序:不能同年同月同日生,也不會同年同月同日死

接下來問一個問題,父程序掛掉時,子程序會掛掉嗎?

想象現實中的場景,父親不在了,兒子還可以活嗎?答案是肯定的。對應於程序,父程序退出時,子程序會繼續執行,不會一起共赴黃泉。

一個父程序已經終止的程序被稱為孤兒程序(orphan process)。作業系統這個大家長是比較人性化的,沒有人管的孤兒程序會被程序 ID 為 1 的程序接管。這個 PID 為 1 的程序後面還會再講到。

接下來對之前的程式碼稍作修改,讓父程序 fork 子程序以後自殺退出,生成孤兒程序。程式碼如下所示。

#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, child pid=%d\\n", getpid(), childPid);      exit(0); // 父程序退出    }  }  return 0;}

編譯執行上面的程式碼

gcc fork_demo.c -o fork_demo; ./fork_demo

輸出結果如下。

before fork, pid=21629in parent process, pid=21629, child pid=21630in child process, pid=21630

可以看到父程序 id 為 21629, 生成的子程序 id 為 21630。

使用 ps 檢視當前程序資訊,結果如下所示。

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

可以看到此時孤兒子程序 21630 的父 ID 已經變為了頂層的 ID 為 1 的程序。

殭屍程序

父程序負責生,如果不負責養,那就不是一個好父親。子程序掛了,如果父程序不給子程序“收屍”(呼叫 wait/waitpid),那這個子程序小可憐就變成了殭屍程序。

新建一個 make_zombie.c 檔案,內容如下。

#include <stdio.h>#include <stdlib.h>#include <unistd.h>int main() {  printf("pid %d\\n", getpid());  int child_pid = fork();  if (child_pid == 0) {    printf("-----in child process:  %d\\n", getpid());    exit(0);  } else {    sleep(1000000);  }  return 0;}

編譯執行上面的程式碼,就可以生成一個程序號為 22538 的殭屍程序,如下所示。

UID        PID  PPID  C STIME TTY          TIME CMDya       22537 20759  0 19:57 pts/8    00:00:00 ./make_zombieya       22538 22537  0 19:57 pts/8    00:00:00 [make_zombie] <defunct>

CMD 名中的 defunct 表示這是一個殭屍程序。

也使用 ps 命令檢視程序的狀態,顯示為 "Z" 或者 "Z+" 表示這是一個殭屍程序,如下所示。

ps -ho pid,state -p 2253822538 Z

子程序退出後絕大部分資源已經被釋放可供其他進使用,但是核心的程序表中的槽位沒有釋放。

殭屍程序有一個很神奇的特性,使用 kill -9 必殺訊號都沒有辦法殺掉殭屍程序,這樣的設計利弊參半,好的地方是父程序可以總是有機會執行 wait/waitpid 等命令收割子程序,壞的地方是無法強制回收這種殭屍程序。

PID 為 1 的程序

Linux 中核心初始化以後會啟動系統的第一個程序,PID 為 1,也可以稱之為 init 程序或者根(ROOT)程序。在我的 Centos 機器上,這個 init 程序是 systemd,如下所示。

UID        PID  PPID  C STIME TTY          TIME CMDroot         1     0  0 12月12 ?      00:00:54 /usr/lib/systemd/systemd --system --deserialize 21

在我的 Mac 電腦上,這個程序為 launchd,如下所示。

UID   PID  PPID   C STIME   TTY           TIME CMD  0     1     0   0 六04下午 ??        28:40.65 /sbin/launchd

init 程序有下面這幾個功能

如果一個程序的父程序退出了,那麼這個 init 程序便會接管這個孤兒程序。如果一個程序的父程序未執行 wait/waitpid 就退出了,init 程序會接管子程序並自動呼叫 wait 方法,從而保證系統中的殭屍程序可以被移除。傳遞訊號給子程序,這點後面會介紹。為什麼 Node.js 不適合做 Docker 映象中 PID 為 1 的程序

在 Node.js 的官方最佳實踐裡有寫到 "Node.js was not designed to run as PID 1 which leads to unexpected behaviour when running inside of Docker."。下圖來自 github.com/nodejs/dock… 。

接下來會做兩個實驗:第一個實驗是在 Centos 機器上,第二個實驗是在 Docker 映象中

實驗一:在 Centos 上,systemd 作為 PID 為 1 的程序

下面來做一些測試,修改上面的程式碼,將父程序 sleep 的時間改短為 15s,新建一個 make_zombie.c 檔案,如下所示。

#include <stdio.h>#include <stdlib.h>#include <unistd.h>int main() {  printf("pid %d\\n", getpid());  int child_pid = fork();  if (child_pid == 0) {    printf("-----in child process:  %d\\n", getpid());    exit(0);  } else {    sleep(15);    exit(0);  }}

編譯生成可執行檔案 make_zombie。

gcc make_zombie.c -o make_zombie

然後新建一個 run.js 程式碼,內部啟動一個程序執行 make_zombie,如下所示。

const { spawn } = require('child_process');const cmd = spawn('./make_zombie');cmd.stdout.on('data', (data) => {    console.log(`stdout: ${data}`);});cmd.stderr.on('data', (data) => {    console.error(`stderr: ${data}`);});cmd.on('close', (code) => {    console.log(`child process exited with code ${code}`);});setTimeout(function () {    console.log("...");}, 1000000);

執行 node run.js 執行這段 js 程式碼,使用 ps -ef 檢視程序關係如下。

UID        PID  PPID  C STIME TTY          TIME CMDya       19234 19231  0 12月20 ?       00:00:00 sshd: ya@pts/6ya       19235 19234  0 12月20 pts/6   00:00:01 -zshya       29513 19235  3 15:28 pts/6    00:00:00 node run.jsya       29519 29513  0 15:28 pts/6    00:00:00 ./make_zombieya       29520 29519  0 15:28 pts/6    00:00:00 [make_zombie] <defunct>複製程式碼

過 15s 以後,再次執行 ps -ef 查詢當前執行的程序,可以看到 make_zombie 相關程序都不見了。

UID        PID  PPID  C STIME TTY          TIME CMDya       19234 19231  0 12月20 ?       00:00:00 sshd: ya@pts/6ya       19235 19234  0 12月20 pts/6   00:00:01 -zshya       29513 19235  3 15:28 pts/6    00:00:00 node run.js

這是因為 PID 為 29519 的 make_zombie 父程序在 15s 以後退出,殭屍子程序被託管到 init 程序,這個程序會呼叫 wait/waitfor 為這個殭屍收屍。

實驗二:在 Docker 上,node 作為 PID 為 1 的程序

將 make_zombie 可執行檔案和 run.js 打包為 .tar.gz 包,隨後新建一個 Dockerfile,內容如下。

#指定基礎映象FROM  registry.gz.cctv.cn/library/your_node_image:your_tagWORKDIR /#複製包檔案到工作目錄,. 代表當前目錄,也就是工作目錄ADD test.tar.gz .#指定啟動命令CMD ["node", "run.js"]

執行 docker build 命令構建一個映象,在我的電腦上 Image ID 為 ab71925b5154, 執行 docker run ab71925b5154,啟動 docker 映象,使用 docker ps 找到映象 CONTAINER ID,這裡為 e37f7e3c2e39。隨即使用 docker exec 進入到映象終端

docker exec -it e37f7e3c2e39 /bin/bash 

執行 ps 命令檢視當前的程序狀況,如下所示。

UID        PID  PPID  C STIME TTY          TIME CMDroot         1     0  1 07:52 ?        00:00:00 node run.jsroot        12     1  0 07:52 ?        00:00:00 ./make_zombieroot        13    12  0 07:52 ?        00:00:00 [make_zombie] <defunct>

等一段時間(15s),再次執行 ps 檢視當前程序,如下所示。

UID        PID  PPID  C STIME TTY          TIME CMDroot         1     0  0 07:52 ?        00:00:00 node run.jsroot        13     1  0 07:52 ?        00:00:00 [make_zombie] <defunct>

可以看到 PID 為 13 的殭屍程序已經託管到 PID 為 1 的 node 程序,但是沒有被回收。

這是 node 不適合做 init 程序的最主要原因:無法回收殭屍程序。

說到 node,這裡提一下 npm,npm 實際上是使用 npm 程序啟動了一個子程序啟動了 package.json 中 scripts 裡寫的啟動指令碼,示例 package.json 指令碼如下所示。

{  "name": "test-demo",  "version": "1.0.0",  "description": "",  "main": "index.js",  "scripts": {    "test": "echo \\"Error: no test specified\\" && exit 1",    "start": "node run.js"  },  "keywords": [],  "author": "",  "license": "ISC",  "dependencies": {  }}

使用 npm run start 啟動,得到的程序如下所示。

ya       19235 19234  0 12月20 pts/6  00:00:01 -zshya       32252 19235  0 16:32 pts/6    00:00:00 npmya       32262 32252  0 16:32 pts/6    00:00:00 node run.js

與 node 一樣,npm 也不會處理殭屍子程序回收。

線上問題分析

我們線上出問題的情況下使用 npm start 來啟動一個 Puppeteer 專案,每生成一次圖片便會建立 4 個 chrome 相關的程序,如下所示。

.|└── chrome(1)    ├── gpu-process(2)    └── zygote(3)        └── renderer(4)

在圖片生成完成時,chrome 主程序退出,剩下的三個孤兒殭屍程序被託管到頂層 npm 程序下,但是 npm 程序無力回收,所有每生成一次圖片便會新增三個殭屍程序。在成千上萬次圖片生成以後,系統中就充滿了殭屍程序。

解決辦法

為了解決這個問題,不能讓 node/npm 成為 init 程序,讓有能力接管殭屍程序的服務成為 init 程序即可,有兩個解決辦法。

使用 bash 啟動 node 或者 npm增加專門的 init 程序,比如 tini解決方式一:使用 bash 啟動 node

讓 bash 成為頂層程序是比較快的一種方式,bash 程序會負責回收殭屍程序,修改 Dockerfile,如下所示。

ADD test.tar.gz .# CMD ["npm", "run", "start"]CMD ["/bin/bash", "-c", "set -e && npm run start"]

使用這種方式是比較簡單,而且之前線上沒有出問題正是因為一開始是使用這種 bash 方式啟動 node,後面有一個小兄弟為了統一啟動命令將這個命令改為 npm run start,問題才出現的。

但使用 bash 並非完美的方案,它有一個比較嚴重的問題,bash 不會傳遞訊號給它啟動的程序,優雅停機等功能無法實現。

接下來做一個實驗,驗證 bash 不會傳遞訊號給子程序的說法,新建一個 signal_test.c 檔案,它處理 SIGQUIT、SIGTERM、SIGTERM 三個訊號,內容如下。

#include <signal.h>#include <stdio.h>static void signal_handler(int signal_no) {  if (signal_no == SIGQUIT) {    printf("quit signal receive: %d\\n", signal_no);  } else if (signal_no == SIGTERM) {    printf("term signal receive: %d\\n", signal_no);  } else if (signal_no == SIGTERM) {    printf("interrupt signal receive: %d\\n", signal_no);  }}int main() {  printf("in main\\n");  signal(SIGQUIT, signal_handler);  signal(SIGINT, signal_handler);  signal(SIGTERM, signal_handler);  getchar();}

在我 Centos 和 Mac 上執行這個 signal_test 程式時,傳送 kill -2、-3、-15 給這個程式,都會有對應的列印輸出,表示收到了訊號。如下所示。

kill -15 47120term signal receive: 15kill -3 47120quit signal receive: 3kill -2 47120interrupt signal receive: 2

在 Docker 映象中使用 bash 啟動這個程式時,傳送 kill 命令給 bash 以後,bash 並不會將訊號傳遞給 signal_test 程式。在執行 docker stop 以後,docker 會發送 SIGTERM(15) 訊號給 bash,bash 並不會將這個訊號傳遞給啟動的應用程式,只能等一段時間超時,docker 會發送 kill -9 強制殺死這個 docker 程序,無法達到優雅停機的功能。

於是有了下面的第二種解決方案。

解決方式二:使用專門的 init 程序

Node.js 提供了兩種方案,第一種是使用 docker 官方的輕量級 init 系統,如下所示。

docker run -it --init you_docker_image_id

這種啟動方式會以 /sbin/docker-init 為 PID 為 1 的 init 程序,不會把 Dockerfile 中 CMD 作為第一個啟動程序。

以下面的 Dockerfile 內容為例

...CMD ["./signal_test"]...

執行 docker run -it --init image_id 啟動 docker 映象,此時映象內的程序如下所示。

UID        PID  PPID  C STIME TTY          TIME CMDroot         1     0  0 15:30 pts/0    00:00:00 /sbin/docker-init -- /app/node-defaultroot         6     1  0 15:30 pts/0    00:00:00 ./signal_test

可以看到 signal_test 程式作為 docker-init 的子程序啟動了。

在 docker stop 命令傳送 SIGTERM 訊號給映象以後,docker-init 程序會將這個訊號轉給 signal_test,這個應用程序就可以收到 SIGTERM 訊號做自定義的處理,比如優雅停機等。

除了 docker 的官方方案,Node.js 的最佳實踐還推薦了一個 tini 這樣一個 C 語言寫的極小的 init 程序,github.com/krallin/tin… 。它的程式碼較短,很值得一讀,對理解訊號傳遞、處理殭屍程序非常有幫助。

小結

通過這篇文章,希望你可以搞懂殭屍程序、孤兒程序、PID 為 1 的程序是什麼,以及為什麼 node/npm 不適合做 PID 為 1 的程序,bash 作為 PID 為 1 的程序有什麼缺陷。

下面留一個作業題,考考你對程序 fork 函式的理解。如下程式連續呼叫三次 fork() 呼叫後會產生多少新程序?

#include <stdio.h>#include <unistd.h>int main() {  printf("Hello, World!\\n");  fork();  fork();  fork();  sleep(100);  return 0;}

最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 如何為.NETCore安裝漢化包智慧感知