一個(gè) Node 進(jìn)程的死亡與善后
本文轉(zhuǎn)載自微信公眾號(hào)「全棧成長(zhǎng)之路」,作者山月行。轉(zhuǎn)載本文請(qǐng)聯(lián)系全棧成長(zhǎng)之路公眾號(hào)。
嗯,這是山月好久沒(méi)有更新的原創(chuàng),正文從下開(kāi)始。
人固有一死,一個(gè) Node 進(jìn)程亦是如此,總有萬(wàn)般不愿也無(wú)法避免。從本篇文章我們看看一個(gè)進(jìn)程滅亡時(shí)如何從容離去。
一個(gè) Node 進(jìn)程,除了提供 HTTP 服務(wù)外,也絕少不了跑腳本的身影。跑一個(gè)腳本拉取配置、處理數(shù)據(jù)以及定時(shí)任務(wù)更是家常便飯。在一些重要流程中能夠看到腳本的身影:
- CI,用以測(cè)試、質(zhì)量保障及部署等
- Cron,用以定時(shí)任務(wù)
- Docker,用以構(gòu)建鏡像
如果在這些重要流程中腳本出錯(cuò)無(wú)法及時(shí)發(fā)現(xiàn)問(wèn)題,將有可能引發(fā)更加隱蔽的問(wèn)題。如果在 HTTP 服務(wù)出現(xiàn)問(wèn)題時(shí),無(wú)法捕獲,服務(wù)異常是不可忍受的。
最近觀察項(xiàng)目鏡像構(gòu)建,會(huì)偶爾發(fā)現(xiàn)一兩個(gè)鏡像雖然構(gòu)建成功,但容器卻跑不起來(lái)的情況究其原因,是因?yàn)?一個(gè) Node 進(jìn)程滅亡卻未曾感知到的問(wèn)題。
Exit Code
什么是 exit code?
exit code 代表一個(gè)進(jìn)程的返回碼,通過(guò)系統(tǒng)調(diào)用 exit_group 來(lái)觸發(fā)。
在 POSIX 中,0 代表正常的返回碼,1-255 代表異常返回碼,在業(yè)務(wù)實(shí)踐中,一般主動(dòng)拋出的錯(cuò)誤碼都是 1。在 Node 應(yīng)用中調(diào)用 API process.exitCode = 1 來(lái)代表進(jìn)程因期望外的異常而中斷退出。
這里有一張關(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)中隨處可見(jiàn),以下是一個(gè)關(guān)于 cat 進(jìn)程的異常以及它的 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 追蹤進(jìn)程顯示的最后一行可以看出,該進(jìn)程的 exit code 是 1,并把錯(cuò)誤信息輸出到 stderr (stderr 的 fd 為 2) 中
如何查看 exit code
從 strace 中可以來(lái)判斷進(jìn)程的 exit code,但是不夠方便過(guò)于冗余,更無(wú)法第一時(shí)間來(lái)定位到異常碼。
有一種更為簡(jiǎn)單的方法,通過(guò) echo $? 來(lái)確認(rèn)返回碼
- $ cat a
- cat: a: No such file or directory
- $ echo $?
- 1
- $ node -e "preocess.exit(52)"
- $ echo $?
- 52
未曾感知的痛苦何在: throw new Error 與 Promise.reject 區(qū)別
以下是兩段代碼,第一段拋出一個(gè)異常,第二段 Promise.reject,兩段代碼都會(huì)如下打印出一段異常信息,那么兩者有什么區(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)
在對(duì)上述兩個(gè)測(cè)試用例使用 echo $? 查看 exit code,我們會(huì)發(fā)現(xiàn) throw new Error() 的 exit code 為 1,而 Promise.reject() 的為 0。
從操作系統(tǒng)的角度來(lái)講,exit code 為 0 代表進(jìn)程成功運(yùn)行并退出,然而此時(shí)即使有 Promise.reject,操作系統(tǒng)也會(huì)視為它執(zhí)行成功。
這在 Dockerfile 與 CI 中執(zhí)行腳本時(shí)將留有安全隱患。
Dockerfile 在 Node 鏡像構(gòu)建時(shí)的隱患
當(dāng)使用 Dockerfile 構(gòu)建鏡像或者 CI 時(shí),如果進(jìn)程返回非 0 返回碼,構(gòu)建就會(huì)失敗。
這是一個(gè)淺顯易懂的含有 Promise.reject() 問(wèn)題的鏡像,我們從這個(gè)鏡像來(lái)看出問(wèn)題所在。
- FROM node:12-alpine
- RUN node -e "Promise.reject('hello, world')"
構(gòu)建鏡像過(guò)程如下,最后兩行提示鏡像構(gòu)建成功:即使在構(gòu)建過(guò)程打印出了 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),鏡像會(huì)構(gòu)建失敗,至于原因以下再說(shuō)。
- 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 腳本解決方案
能在編譯時(shí)能發(fā)現(xiàn)的問(wèn)題,絕不要放在運(yùn)行時(shí)。所以,構(gòu)建鏡像或 CI 中需要執(zhí)行 node 腳本時(shí),對(duì)異常處理需要手動(dòng)指定 process.exitCode = 1 來(lái)提前暴露問(wèn)題
- runScript().catch(() => {
- process.exitCode = 1
- })
在構(gòu)建鏡像時(shí),Node 也有關(guān)于異常解決方案的建議:
- runScript().catch(() => {
- process.exitCode = 1
- })
根據(jù)提示,--unhandled-rejections=strict 將會(huì)把 Promise.reject 的退出碼設(shè)置為 1,并在將來(lái)的 node 版本中修正 Promise 異常退出碼。
而下一個(gè)版本 Node 15.0 已把 unhandled-rejections 視為異常并返回非 0 退出碼。
- $ node --unhandled-rejections=strict error.js
Signal
在外部,如何殺死一個(gè)進(jìn)程?答:kill $pid
而更為準(zhǔn)確的來(lái)說(shuō),一個(gè) kill 命令用以向一個(gè)進(jìn)程發(fā)送 signal,而非殺死進(jìn)程。大概是殺進(jìn)程的人多了,就變成了 kill。
The kill utility sends a signal to the processes specified by the pid operands.
每一個(gè) 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
這些信號(hào)中與終端進(jìn)程接觸最多的為以下幾個(gè),其中 SIGTERM 為 kill 默認(rèn)發(fā)送信號(hào),SIGKILL 為強(qiáng)制殺進(jìn)程信號(hào)
信號(hào) | 數(shù)字 | 是否可捕獲 | 描述 |
---|---|---|---|
SIGINT | 2 | 可捕獲 | Ctrl+C 中斷進(jìn)程 |
SIGQUIT | 3 | 可捕獲 | Ctrl+D 中斷進(jìn)程 |
SIGKILL | 9 | 不可捕獲 | 強(qiáng)制中斷進(jìn)程(無(wú)法阻塞) |
SIGTERM | 15 | 可捕獲 | 優(yōu)雅終止進(jìn)程(默認(rèn)信號(hào)) |
SIGSTOP | 19 | 不可捕獲 | 優(yōu)雅終止進(jìn)程中 |
在 Node 中,process.on 可以監(jiān)聽(tīng)到可捕獲的退出信號(hào)而不退出。以下示例監(jiān)聽(tīng)到 SIGINT 與 SIGTERM 信號(hào),SIGKILL 無(wú)法被監(jiān)聽(tīng),setTimeout 保證程序不會(huì)退出
- 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)
運(yùn)行腳本,啟動(dòng)進(jìn)程,可以看到該進(jìn)程的 pid,使用 kill -2 97864 發(fā)送信號(hào),進(jìn)程接收到信號(hào)并未退出
- $ node signal.js
- Pid: 97864
- Received: SIGTERM
- Received: SIGTERM
- Received: SIGTERM
- Received: SIGINT
- Received: SIGINT
- Received: SIGINT
容器中退出時(shí)的優(yōu)雅處理
當(dāng)在 k8s 容器服務(wù)升級(jí)時(shí)需要關(guān)閉過(guò)期 Pod 時(shí),會(huì)向容器的主進(jìn)程(PID 1)發(fā)送一個(gè) SIGTERM 的信號(hào),并預(yù)留 30s 善后。如果容器在 30s 后還沒(méi)有退出,那么 k8s 會(huì)繼續(xù)發(fā)送一個(gè) SIGKILL 信號(hào)。如果古時(shí)皇帝白綾賜死,教你體面。
其實(shí)不僅僅是容器,CI 中腳本也要優(yōu)雅處理進(jìn)程的退出。
當(dāng)接收到 SIGTERM/SIGINT 信號(hào)時(shí),預(yù)留一分鐘時(shí)間做未做完的事情。
- async function gracefulClose(signal) {
- await new Promise(resolve => {
- setTimout(resolve, 60000)
- })
- process.exit()
- }
- process.on('SIGINT', gracefulClose)
- process.on('SIGTERM', gracefulClose)
這個(gè)給腳本預(yù)留時(shí)間是比較正確的做法,但是如果是一個(gè)服務(wù)有源源不斷的請(qǐng)求過(guò)來(lái)呢?那就由服務(wù)主動(dòng)關(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)進(jìn)程結(jié)束的 exit code 為非 0 時(shí),系統(tǒng)會(huì)認(rèn)為該進(jìn)程執(zhí)行失敗
- 通過(guò) echo $? 可查看終端上一進(jìn)程的 exit code
- Node 中 Promise.reject 時(shí) exit code 為 0
- Node 中可以通過(guò) process.exitCode = 1 顯式設(shè)置 exit code
- 在 Node12+ 中可以通過(guò) node --unhandled-rejections=strict error.js 執(zhí)行腳本,視 Promise.reject 的 exit code 為 1,在 Node15 中修復(fù)了這一個(gè)問(wèn)題
- Node 進(jìn)程退出時(shí)需要優(yōu)雅退出
- k8s 關(guān)閉 POD 時(shí)先發(fā)一個(gè) SIGTERM 信號(hào),留 30s 時(shí)間處理未完成的事,如若 POD 沒(méi)有正常退出,30s 過(guò)后發(fā)送 SIGKILL 信號(hào)
參考資料
[1]
Appendix E. Exit Codes With Special Meanings: http://www.tldp.org/LDP/abs/html/exitcodes.html