(轉載)原文連接:http://www.javashuo.com/article/p-fwaiuoiv-s.htmlhtml
前段時間線上的一個使用 Google Puppeteer 生成圖片的服務炸了,每一個 docker 容器內都有幾千個孤兒僵死進程沒有回收,以下圖所示。node
這篇文章比較長,主要就講了下面這幾個問題。git
什麼狀況下會出現殭屍進程、孤兒進程github
Puppeteer 是一個 node 庫,是 Chrome 官方提供的***面 chrome 工具(headless chrome),它提供了操做 Chrome API 的方式,容許開發者在程序中啓動 chrome 進程,調用 JS 的 API 實現頁面加載、數據爬取、web 自動化測試等功能。web
本案例中使用的場景是使用 Puppeteer 加載 html,隨後截圖生成一張分銷海報的圖片。文章分析了這個問題背後的緣由,接下來開始正式的內容。面試
每一個進程都有一個惟一的標識,稱爲 pid,pid 是一個非負的整數值,使用 ps 命令能夠查看,在個人 Mac 電腦上執行 ps -ef 能夠看到當前運行的全部進程,以下所示。chrome
UID PID PPID C STIME TTY TIME CMD 1 0 0 六04下午 ?? 23:09.18 /sbin/launchd 39 1 0 六04下午 ?? 0:49.66 /usr/sbin/syslogd 0 40 1 0 六04下午 ?? 0:13.00 /usr/libexec/UserEventAgent (System)
其中 PID 是表示進程號。docker
系統中每一個進程都有對應的父進程,上面 ps 輸出中的 PPID 就表示進程的父進程號。最頂層的進程的 PID 爲 1,PPID 爲 0。npm
打開 iTerm,在終端中執行一個命令,好比 "ls",實際上系統會建立新的 iTerm 子進程,這個 iTerm 進程又建立了 zsh 子進程。在 zsh 中輸入的 ls 命令,則是 zsh 進程又啓動了一個 ls 子進程。在 iTerm 中輸入 ls 命令過程的進程關係以下所示。json
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_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 block enter else block
能夠看到 if、else 語句都被執行了。
fork 調用
fork 是一個系統調用,它的方法聲明以下所示。
pid_t fork(void);
fork 調用完成後會生成一個新的子進程,且父子進程都從 fork 返回處繼續執行。這裏須要特別注意的是 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=26070 in parent process, pid=26070, child pid=26071 in 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=21629 in parent process, pid=21629, child pid=21630 in child process, pid=21630
能夠看到父進程 id 爲 21629, 生成的子進程 id 爲 21630。
使用 ps 查看當前進程信息,結果以下所示。
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 的父 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 CMD ya 22537 20759 0 19:57 pts/8 00:00:00 ./make_zombie ya 22538 22537 0 19:57 pts/8 00:00:00 [make_zombie] <defunct>
CMD 名中的 defunct 表示這是一個殭屍進程。
也使用 ps 命令查看進程的狀態,顯示爲 "Z" 或者 "Z+" 表示這是一個殭屍進程,以下所示。
ps -ho pid,state -p 22538 22538 Z
子進程退出後絕大部分資源已經被釋放可供其餘進使用,可是內核的進程表中的槽位沒有釋放。
殭屍進程有一個很神奇的特性,使用 kill -9 必殺信號都沒有辦法殺掉殭屍進程,這樣的設計利弊參半,好的地方是父進程能夠老是有機會執行 wait/waitpid 等命令收割子進程,壞的地方是沒法強制回收這種殭屍進程。
Linux 中內核初始化之後會啓動系統的第一個進程,PID 爲 1,也能夠稱之爲 init 進程或者根(ROOT)進程。在個人 Centos 機器上,這個 init 進程是 systemd,以下所示。
UID PID PPID C STIME TTY TIME CMD root 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 進程有下面這幾個功能
在 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 CMD ya 19234 19231 0 12月20 ? 00:00:00 sshd: ya@pts/6 ya 19235 19234 0 12月20 pts/6 00:00:01 -zsh ya 29513 19235 3 15:28 pts/6 00:00:00 node run.js ya 29519 29513 0 15:28 pts/6 00:00:00 ./make_zombie ya 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 CMD ya 19234 19231 0 12月20 ? 00:00:00 sshd: ya@pts/6 ya 19235 19234 0 12月20 pts/6 00:00:01 -zsh ya 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_tag WORKDIR / #複製包文件到工做目錄,. 表明當前目錄,也就是工做目錄 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 CMD root 1 0 1 07:52 ? 00:00:00 node run.js root 12 1 0 07:52 ? 00:00:00 ./make_zombie root 13 12 0 07:52 ? 00:00:00 [make_zombie] <defunct>
等一段時間(15s),再次執行 ps 查看當前進程,以下所示。
UID PID PPID C STIME TTY TIME CMD root 1 0 0 07:52 ? 00:00:00 node run.js root 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 -zsh ya 32252 19235 0 16:32 pts/6 00:00:00 npm ya 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 成爲頂層進程是比較快的一種方式,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 -二、-三、-15 給這個程序,都會有對應的打印輸出,表示收到了信號。以下所示。
kill -15 47120 term signal receive: 15 kill -3 47120 quit signal receive: 3 kill -2 47120 interrupt signal receive: 2
在 Docker 鏡像中使用 bash 啓動這個程序時,發送 kill 命令給 bash 之後,bash 並不會將信號傳遞給 signal_test 程序。在執行 docker stop 之後,docker 會發送 SIGTERM(15) 信號給 bash,bash 並不會將這個信號傳遞給啓動的應用程序,只能等一段時間超時,docker 會發送 kill -9 強制殺死這個 docker 進程,沒法達到優雅停機的功能。
因而有了下面的第二種解決方案。
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 CMD root 1 0 0 15:30 pts/0 00:00:00 /sbin/docker-init -- /app/node-default root 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; }
(轉載)原文連接:http://www.javashuo.com/article/p-fwaiuoiv-s.html
超值推薦:
阿里雲雙12已開啓,雲產品冰點價,新用戶專享1折起,1核2G雲服務器僅需89元/年,229元/3年。買了對於提高技術或者在服務器上搭建自由站點,都是很不錯的,若是本身有實際操做,面試+工做中確定是加分項。(老用戶能夠用家人或朋友的帳號購買,真心便宜&划算)
可「掃碼」或者「點擊購買 "