一個 Node 進程的死亡與善后
本文轉(zhuǎn)載自微信公眾號「全棧成長之路」,作者山月行。轉(zhuǎn)載本文請聯(lián)系全棧成長之路公眾號。
嗯,這是山月好久沒有更新的原創(chuàng),正文從下開始。
人固有一死,一個 Node 進程亦是如此,總有萬般不愿也無法避免。從本篇文章我們看看一個進程滅亡時如何從容離去。
一個 Node 進程,除了提供 HTTP 服務(wù)外,也絕少不了跑腳本的身影。跑一個腳本拉取配置、處理數(shù)據(jù)以及定時任務(wù)更是家常便飯。在一些重要流程中能夠看到腳本的身影:
- CI,用以測試、質(zhì)量保障及部署等
- Cron,用以定時任務(wù)
- Docker,用以構(gòu)建鏡像
如果在這些重要流程中腳本出錯無法及時發(fā)現(xiàn)問題,將有可能引發(fā)更加隱蔽的問題。如果在 HTTP 服務(wù)出現(xiàn)問題時,無法捕獲,服務(wù)異常是不可忍受的。
最近觀察項目鏡像構(gòu)建,會偶爾發(fā)現(xiàn)一兩個鏡像雖然構(gòu)建成功,但容器卻跑不起來的情況究其原因,是因為 一個 Node 進程滅亡卻未曾感知到的問題。
Exit Code
什么是 exit code?
exit code 代表一個進程的返回碼,通過系統(tǒng)調(diào)用 exit_group 來觸發(fā)。
在 POSIX 中,0 代表正常的返回碼,1-255 代表異常返回碼,在業(yè)務(wù)實踐中,一般主動拋出的錯誤碼都是 1。在 Node 應(yīng)用中調(diào)用 API process.exitCode = 1 來代表進程因期望外的異常而中斷退出。
這里有一張關(guān)于異常碼的附表 Appendix E. Exit Codes With Special Meanings[1]。
Exit Code Number | Meaning | Example | Comments |
---|---|---|---|
1 | Catchall for general errors | let "var1 = 1/0" | Miscellaneous errors, such as "divide by zero" and other impermissible operations |
2 | Misuse of shell builtins (according to Bash documentation) | empty_function() {} | Missing keyword or command, or permission problem (and diff return code on a failed binary file comparison). |
126 | Command invoked cannot execute | /dev/null | Permission problem or command is not an executable |
127 | "command not found" | illegal_command | Possible problem with $PATH or a typo |
128 | Invalid argument to exit | exit 3.14159 | exit takes only integer args in the range 0 - 255 (see first footnote) |
128+n | Fatal error signal "n" | kill -9 $PPID of script | $? returns 137 (128 + 9) |
130 | Script terminated by Control-C | Ctl-C | Control-C is fatal error signal 2, (130 = 128 + 2, see above) |
255* | Exit status out of range | exit -1 | exit takes only integer args in the range 0 - 255 |
異常碼在操作系統(tǒng)中隨處可見,以下是一個關(guān)于 cat 進程的異常以及它的 exit code,并使用 strace 追蹤系統(tǒng)調(diào)用。
- $ cat a
- cat: a: No such file or directory
- # 使用 strace 查看 cat 的系統(tǒng)調(diào)用
- # -e 只顯示 write 與 exit_group 的系統(tǒng)調(diào)用
- $ strace -e write,exit_group cat a
- write(2, "cat: ", 5cat: ) = 5
- write(2, "a", 1a) = 1
- write(2, ": No such file or directory", 27: No such file or directory) = 27
- write(2, "\n", 1
- ) = 1
- exit_group(1) = ?
- +++ exited with 1 +++
從 strace 追蹤進程顯示的最后一行可以看出,該進程的 exit code 是 1,并把錯誤信息輸出到 stderr (stderr 的 fd 為 2) 中
如何查看 exit code
從 strace 中可以來判斷進程的 exit code,但是不夠方便過于冗余,更無法第一時間來定位到異常碼。
有一種更為簡單的方法,通過 echo $? 來確認返回碼
- $ cat a
- cat: a: No such file or directory
- $ echo $?
- 1
- $ node -e "preocess.exit(52)"
- $ echo $?
- 52
未曾感知的痛苦何在: throw new Error 與 Promise.reject 區(qū)別
以下是兩段代碼,第一段拋出一個異常,第二段 Promise.reject,兩段代碼都會如下打印出一段異常信息,那么兩者有什么區(qū)別?
- function error () {
- throw new Error('hello, error')
- }
- error()
- // Output:
- // /Users/shanyue/Documents/note/demo.js:2
- // throw new Error('hello, world')
- // ^
- //
- // Error: hello, world
- // at error (/Users/shanyue/Documents/note/demo.js:2:9)
- async function error () {
- return new Error('hello, error')
- }
- error()
- // Output:
- // (node:60356) UnhandledPromiseRejectionWarning: Error: hello, world
- // at error (/Users/shanyue/Documents/note/demo.js:2:9)
- // at Object.<anonymous> (/Users/shanyue/Documents/note/demo.js:5:1)
- // at Module._compile (internal/modules/cjs/loader.js:701:30)
- // at Object.Module._extensions..js (internal/modules/cjs/loader.js:712:10)
在對上述兩個測試用例使用 echo $? 查看 exit code,我們會發(fā)現(xiàn) throw new Error() 的 exit code 為 1,而 Promise.reject() 的為 0。
從操作系統(tǒng)的角度來講,exit code 為 0 代表進程成功運行并退出,然而此時即使有 Promise.reject,操作系統(tǒng)也會視為它執(zhí)行成功。
這在 Dockerfile 與 CI 中執(zhí)行腳本時將留有安全隱患。
Dockerfile 在 Node 鏡像構(gòu)建時的隱患
當(dāng)使用 Dockerfile 構(gòu)建鏡像或者 CI 時,如果進程返回非 0 返回碼,構(gòu)建就會失敗。
這是一個淺顯易懂的含有 Promise.reject() 問題的鏡像,我們從這個鏡像來看出問題所在。
- FROM node:12-alpine
- RUN node -e "Promise.reject('hello, world')"
構(gòu)建鏡像過程如下,最后兩行提示鏡像構(gòu)建成功:即使在構(gòu)建過程打印出了 unhandledPromiseRejection 信息,但是鏡像仍然構(gòu)建成功。
- $ docker build -t demo .
- Sending build context to Docker daemon 33.28kB
- Step 1/2 : FROM node:12-alpine
- ---> 18f4bc975732
- Step 2/2 : RUN node -e "Promise.reject('hello, world')"
- ---> Running in 79a6d53c5aa6
- (node:1) UnhandledPromiseRejectionWarning: hello, world
- (node:1) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
- (node:1) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
- Removing intermediate container 79a6d53c5aa6
- ---> 09f07eb993fe
- Successfully built 09f07eb993fe
- Successfully tagged demo:latest
但如果是在 node 15 鏡像內(nèi),鏡像會構(gòu)建失敗,至于原因以下再說。
- FROM node:15-alpine
- RUN node -e "Promise.reject('hello, world')"
- $ docker build -t demo .
- Sending build context to Docker daemon 2.048kB
- Step 1/2 : FROM node:15-alpine
- ---> 8bf655e9f9b2
- Step 2/2 : RUN node -e "Promise.reject('hello, world')"
- ---> Running in 4573ed5d5b08
- node:internal/process/promises:245
- triggerUncaughtException(err, true /* fromPromise */);
- ^
- [UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "hello, world".] {
- code: 'ERR_UNHANDLED_REJECTION'
- }
- The command '/bin/sh -c node -e "Promise.reject('hello, world')"' returned a non-zero code: 1
Promise.reject 腳本解決方案
能在編譯時能發(fā)現(xiàn)的問題,絕不要放在運行時。所以,構(gòu)建鏡像或 CI 中需要執(zhí)行 node 腳本時,對異常處理需要手動指定 process.exitCode = 1 來提前暴露問題
- runScript().catch(() => {
- process.exitCode = 1
- })
在構(gòu)建鏡像時,Node 也有關(guān)于異常解決方案的建議:
- runScript().catch(() => {
- process.exitCode = 1
- })
根據(jù)提示,--unhandled-rejections=strict 將會把 Promise.reject 的退出碼設(shè)置為 1,并在將來的 node 版本中修正 Promise 異常退出碼。
而下一個版本 Node 15.0 已把 unhandled-rejections 視為異常并返回非 0 退出碼。
- $ node --unhandled-rejections=strict error.js
Signal
在外部,如何殺死一個進程?答:kill $pid
而更為準確的來說,一個 kill 命令用以向一個進程發(fā)送 signal,而非殺死進程。大概是殺進程的人多了,就變成了 kill。
The kill utility sends a signal to the processes specified by the pid operands.
每一個 signal 由數(shù)字表示,signal 列表可由 kill -l 打印
- # 列出所有的 signal
- $ kill -l
- 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
- 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
- 11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
- 16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
- 21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
- 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
- 31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
- 38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
- 43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
- 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
- 53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
- 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
- 63) SIGRTMAX-1 64) SIGRTMAX
這些信號中與終端進程接觸最多的為以下幾個,其中 SIGTERM 為 kill 默認發(fā)送信號,SIGKILL 為強制殺進程信號
信號 | 數(shù)字 | 是否可捕獲 | 描述 |
---|---|---|---|
SIGINT | 2 | 可捕獲 | Ctrl+C 中斷進程 |
SIGQUIT | 3 | 可捕獲 | Ctrl+D 中斷進程 |
SIGKILL | 9 | 不可捕獲 | 強制中斷進程(無法阻塞) |
SIGTERM | 15 | 可捕獲 | 優(yōu)雅終止進程(默認信號) |
SIGSTOP | 19 | 不可捕獲 | 優(yōu)雅終止進程中 |
在 Node 中,process.on 可以監(jiān)聽到可捕獲的退出信號而不退出。以下示例監(jiān)聽到 SIGINT 與 SIGTERM 信號,SIGKILL 無法被監(jiān)聽,setTimeout 保證程序不會退出
- console.log(`Pid: ${process.pid}`)
- process.on('SIGINT', () => console.log('Received: SIGINT'))
- // process.on('SIGKILL', () => console.log('Received: SIGKILL'))
- process.on('SIGTERM', () => console.log('Received: SIGTERM'))
- setTimeout(() => {}, 1000000)
運行腳本,啟動進程,可以看到該進程的 pid,使用 kill -2 97864 發(fā)送信號,進程接收到信號并未退出
- $ node signal.js
- Pid: 97864
- Received: SIGTERM
- Received: SIGTERM
- Received: SIGTERM
- Received: SIGINT
- Received: SIGINT
- Received: SIGINT
容器中退出時的優(yōu)雅處理
當(dāng)在 k8s 容器服務(wù)升級時需要關(guān)閉過期 Pod 時,會向容器的主進程(PID 1)發(fā)送一個 SIGTERM 的信號,并預(yù)留 30s 善后。如果容器在 30s 后還沒有退出,那么 k8s 會繼續(xù)發(fā)送一個 SIGKILL 信號。如果古時皇帝白綾賜死,教你體面。
其實不僅僅是容器,CI 中腳本也要優(yōu)雅處理進程的退出。
當(dāng)接收到 SIGTERM/SIGINT 信號時,預(yù)留一分鐘時間做未做完的事情。
- async function gracefulClose(signal) {
- await new Promise(resolve => {
- setTimout(resolve, 60000)
- })
- process.exit()
- }
- process.on('SIGINT', gracefulClose)
- process.on('SIGTERM', gracefulClose)
這個給腳本預(yù)留時間是比較正確的做法,但是如果是一個服務(wù)有源源不斷的請求過來呢?那就由服務(wù)主動關(guān)閉吧,調(diào)用 server.close() 結(jié)束服務(wù)
- const server = http.createServer(handler)
- function gracefulClose(signal) {
- server.close(() => {
- process.exit()
- })
- }
- process.on('SIGINT', gracefulClose)
- process.on('SIGTERM', gracefulClose)
總結(jié)
- 當(dāng)進程結(jié)束的 exit code 為非 0 時,系統(tǒng)會認為該進程執(zhí)行失敗
- 通過 echo $? 可查看終端上一進程的 exit code
- Node 中 Promise.reject 時 exit code 為 0
- Node 中可以通過 process.exitCode = 1 顯式設(shè)置 exit code
- 在 Node12+ 中可以通過 node --unhandled-rejections=strict error.js 執(zhí)行腳本,視 Promise.reject 的 exit code 為 1,在 Node15 中修復(fù)了這一個問題
- Node 進程退出時需要優(yōu)雅退出
- k8s 關(guān)閉 POD 時先發(fā)一個 SIGTERM 信號,留 30s 時間處理未完成的事,如若 POD 沒有正常退出,30s 過后發(fā)送 SIGKILL 信號
參考資料
[1]
Appendix E. Exit Codes With Special Meanings: http://www.tldp.org/LDP/abs/html/exitcodes.html