本篇文章聊聊Node的進程退出,介紹各種可能導致 Node.js 進程退出的情況,希望對大家有所幫助!
在我們的服務發(fā)布后,難免會被運行環(huán)境(如容器、pm2 等)調度、升級服務導致重啟、各種異常導致進程崩潰;一般情況下,運行環(huán)境都有對服務進程的健康監(jiān)測,在進程異常時,會重新拉起進程,在升級時,也有滾動升級的策略。但運行環(huán)境的調度策略是把我們服務的進程當成黑盒來處理的,不會管服務進程內部的運行情況,因此需要我們的服務進程主動感知運行環(huán)境的調度動作,然后做一些退出的清理動作。
因此我們今天就是梳理各種可能導致 Node.js 進程退出的情況,以及我們可以通過監(jiān)聽這些進程退出事件做哪些事情。
原理
一個進程要退出,無非就是兩種情況,一是進程自己主動退出,另外就是收到系統(tǒng)信號,要求進程退出。
系統(tǒng)信號通知退出
在 Node.js 官方文檔 中列出了常見的系統(tǒng)信號,我們主要關注幾個:
- SIGHUP:不通過 ctrl+c 停止進程,而是直接關閉命令行終端,會觸發(fā)該信號
- SIGINT:按下 ctrl+c 停止進程時觸發(fā);pm2 重啟或者停止子進程時,也會向子進程發(fā)送該信號
- SIGTERM:一般用于通知進程優(yōu)雅退出,如 k8s 刪除 pod 時,就會向 pod 發(fā)送 SIGTERM 信號,pod 可以在超時時間內(默認 30s)做一些退出清理動作
- SIGBREAK:在 window 系統(tǒng)上,按下 ctrl+break 會觸發(fā)該信號
- SIGKILL:強制退出進程,進程無法做任何清理動作,執(zhí)行命令 kill -9 pid,進程會收到該信號。k8s 刪除 pod 時,如果超過 30s,pod 還沒退出,k8s 會向 pod 發(fā)送 SIGKILL 信號,立即退出 pod 進程;pm2 在重啟或者停止進程時,如果超過 1.6s,進程還沒退出,也會發(fā)送 SIGKILL 信號
在收到非強制退出信號時,Node.js 進程可以監(jiān)聽退出信號,做一些自定義的退出邏輯。比如我們寫了一個 cli 工具,需要比較長的時間執(zhí)行任務,如果用戶在任務執(zhí)行完成前想要通過 ctrl+c 退出進程時,可以提示用戶再等等:
const readline = require('readline'); process.on('SIGINT', () => { // 我們通過 readline 來簡單地實現(xiàn)命令行里面的交互 const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question('任務還沒執(zhí)行完,確定要退出嗎?', answer => { if (answer === 'yes') { console.log('任務執(zhí)行中斷,退出進程'); process.exit(0); } else { console.log('任務繼續(xù)執(zhí)行...'); } rl.close(); }); }); // 模擬一個需要執(zhí)行 1 分鐘的任務 const longTimeTask = () => { console.log('task start...'); setTimeout(() => { console.log('task end'); }, 1000 * 60); }; longTimeTask();
實現(xiàn)效果如下,每次按下 ctrl + c 都會提示用戶:
進程主動退出
Node.js 進程主動退出,主要包含下面幾種情況:
- 代碼執(zhí)行過程中觸發(fā)了未捕獲的錯誤,可以通過 process.on('uncaughtException') 監(jiān)聽這種情況
- 代碼執(zhí)行過程中觸發(fā)了未處理的 promise rejection(Node.js v16 開始會導致進程退出),可以通過 process.on('unhandledRejection') 監(jiān)聽這種情況
- EventEmitter 觸發(fā)了未監(jiān)聽的 error 事件
- 代碼中主動調用 process.exit 函數(shù)退出進程,可以通過 process.on('exit') 監(jiān)聽
- Node.js 的事件隊列為空,可簡單認為沒有需要執(zhí)行的代碼了,可以通過 process.on('exit') 監(jiān)聽
我們知道 pm2 有守護進程的效果,在你的進程發(fā)生錯誤退出時,pm2 會重啟你的進程,我們也在 Node.js 的 cluster 模式下,實現(xiàn)一個守護子進程的效果(實際上 pm2 也是類似的邏輯):
const cluster = require('cluster'); const http = require('http'); const numCPUs = require('os').cpus().length; const process = require('process'); // 主進程代碼 if (cluster.isMaster) { console.log(`啟動主進程: ${process.pid}`); // 根據 cpu 核數(shù),創(chuàng)建工作進程 for (let i = 0; i < numCPUs; i++) { cluster.fork(); } // 監(jiān)聽工作進程退出事件 cluster.on('exit', (worker, code, signal) => { console.log(`工作進程 ${worker.process.pid} 退出,錯誤碼: ${code || signal}, 重啟中...`); // 重啟子進程 cluster.fork(); }); } // 工作進程代碼 if (cluster.isWorker) { // 監(jiān)聽未捕獲錯誤事件 process.on('uncaughtException', error => { console.log(`工作進程 ${process.pid} 發(fā)生錯誤`, error); process.emit('disconnect'); process.exit(1); }); // 創(chuàng)建 web server // 各個工作進程都會監(jiān)聽端口 8000(Node.js 內部會做處理,不會導致端口沖突) http.createServer((req, res) => { res.writeHead(200); res.end('hello worldn'); }).listen(8000); console.log(`啟動工作進程: ${process.pid}`); }
應用實踐
上面分析了 Node.js 進程退出的各種情況,現(xiàn)在我們來做一個監(jiān)聽進程退出的工具,在 Node.js 進程退出時,允許使用方執(zhí)行自己的退出邏輯:
// exit-hook.js // 保存需要執(zhí)行的退出任務 const tasks = []; // 添加退出任務 const addExitTask = fn => tasks.push(fn); const handleExit = (code, error) => { // ...handleExit 的實現(xiàn)見下面 }; // 監(jiān)聽各種退出事件 process.on('exit', code => handleExit(code)); // 按照 POSIX 的規(guī)范,我們用 128 + 信號編號 得到最終的退出碼 // 信號編號參考下面的圖片,大家可以在 linux 系統(tǒng)下執(zhí)行 kill -l 查看所有的信號編號 process.on('SIGHUP', () => handleExit(128 + 1)); process.on('SIGINT', () => handleExit(128 + 2)); process.on('SIGTERM', () => handleExit(128 + 15)); // windows 下按下 ctrl+break 的退出信號 process.on('SIGBREAK', () => handleExit(128 + 21)); // 退出碼 1 代表未捕獲的錯誤導致進程退出 process.on('uncaughtException', error => handleExit(1, error)); process.on('unhandledRejection', error => handleExit(1, error));
信號編號:
接下來我們要實現(xiàn)真正的進程退出函數(shù) handleExit,因為用戶傳入的任務函數(shù)可能是同步的,也可能是異步的;我們可以借助 process.nextTick 來保證用戶的同步代碼都已經執(zhí)行完成,可以簡單理解 process.nextTick 會在每個事件循環(huán)階段的同步代碼執(zhí)行完成后執(zhí)行(理解 process.nextTick);針對異步任務,我們需要用戶調用 callback 來告訴我們異步任務已經執(zhí)行完成了:
// 標記是否正在退出,避免多次執(zhí)行 let isExiting = false; const handleExit = (code, error) => { if (isExiting) return; isExiting = true; // 標記已經執(zhí)行了退出動作,避免多次調用 let hasDoExit = fasle; const doExit = () => { if (hasDoExit) return; hasDoExit = true process.nextTick(() => process.exit(code)) } // 記錄有多少個異步任務 let asyncTaskCount = 0; // 異步任務結束后,用戶需要調用的回調 let ayncTaskCallback = () => { process.nextTick(() => { asyncTaskCount-- if (asyncTaskCount === 0) doExit() }) } // 執(zhí)行所有的退出任務 tasks.forEach(taskFn => { // 如果 taskFn 函數(shù)的參數(shù)個數(shù)大于 1,認為傳遞了 callback 參數(shù),是一個異步任務 if (taskFn.length > 1) { asyncTaskCount++ taskFn(error, ayncTaskCallback) } else { taskFn(error) } }); // 如果存在異步任務 if (asyncTaskCount > 0) { // 超過 10s 后,強制退出 setTimeout(() => { doExit(); }, 10 * 1000) } else { doExit() } };
至此,我們的進程退出監(jiān)聽工具就完成了,完整的實現(xiàn)可以查看這個開源庫 async-exit-hook
https://github.com/darukjs/daruk-exit-hook
進程優(yōu)雅退出
通常我們的 web server 在重啟、被運行容器調度(pm2 或者 docker 等)、出現(xiàn)異常導致進程退出時,我們希望執(zhí)行退出動作,如完成已經連接到服務的請求響應、清理數(shù)據庫連接、打印錯誤日志、觸發(fā)告警等,做完退出動作后,再退出進程,我們可以使用剛才的進程退出監(jiān)聽工具實現(xiàn):
const http = require('http'); // 創(chuàng)建 web server const server = http.createServer((req, res) => { res.writeHead(200); res.end('hello worldn'); }).listen(8000); // 使用我們在上面開發(fā)的工具添加進程退出任務 addExitTask((error, callback) => { // 打印錯誤日志、觸發(fā)告警、釋放數(shù)據庫連接等 console.log('進程異常退出', error) // 停止接受新的請求 server.close((error) => { if (error) { console.log('停止接受新請求錯誤', error) } else { console.log('已停止接受新的請求') } }) // 比較簡單的做法是,等待一定的時間(這里我們等待 5s),讓存量請求執(zhí)行完畢 // 如果要完全保證所有請求都處理完畢,需要記錄每一個連接,在所有連接都釋放后,才執(zhí)行退出動作 // 可以參考開源庫 https://github.com/sebhildebrandt/http-graceful-shutdown setTimout(callback, 5 * 1000) })
總結
通過上面的文字,相信你已經對導致 Node.js 進程退出的各種情況心里有數(shù)了。在服務上線后,雖然 k8s、pm2 等工具能夠在進程異常退出時,不停地拉起進程,保證服務的可用性,但我們也應該在代碼中主動感知進程的異?;蛘弑徽{度的情況,從而能夠更早發(fā)現(xiàn)問題。