pm2中的cluster模式和fork模式

語言: CN / TW / HK

故事起因:

故事的起因是我們專案中在一臺機器利用pm2部署了多個nodejs的專案,但是每個專案需要一個指定的nodejs版本(因為每個專案中依賴不同版本node的c++ addon),例如專案A需要使用node12來執行,專案B中使用node14來執行。

問題描述:

在進行機器擴容的時候發現新部署的機器上對於本應該用node12啟動的專案A死活使用的是node14來啟動的。

image.png

最開始肯定是帶著氣憤的心情去質疑運維擴容的機器為什麼node版本環境和之前的機器不同?之前擴容的時候都沒出現過類似的問題,為什麼這次擴容的機器就會有問題。

運維同學當然很無辜呀~,我們也沒做什麼改動呀,擴容的流程也是規範化的不會出問題才對。
於是前端同學開始了漫長的排查,通過各種渠道排查了諸如:環境變數中的node指向,指向中的node是否安裝,通過CI部署時安裝的依賴內容等等。。。因為所在公司的部署流程及其”成熟“,從申請許可權到排查問題花了很久很久。。

直到我們注意到了pm2中的一個欄位mode:

image.png

於是我嘗試在本地復現這個問題:

復現DEMO

  • 我們通過pm2的配置檔案ecosystem.config.js來啟動兩個node server,兩個server的名字分別為node10 app和node14 app,並且script檔案分別為index1.js和index2.js,分別配置對應的啟動的node版本interpreter引數 js module.exports = { apps : [{ out_file: './out.log', name : "node 10 app", script : "./index1.js", interpreter: "/Users/haochenli/.nvm/versions/node/v10.0.0/bin/node" //node路徑 }, { out_file: './out.log', name : "node 14 app", script : "./index2.js", interpreter: "/Users/haochenli/.nvm/versions/node/v14.18.1/bin/node", //node路徑 }] }

```js // index1.js const http = require('http') const process = require('process')

http.createServer((req, res) => { res.writeHead(200); res.end('hello world\n' + process.version); }).listen(8000);

console.log(Worker ${process.pid} started); js // index2.js const http = require('http') const process = require('process')

http.createServer((req, res) => { res.writeHead(200); res.end('hello world\n' + process.version); }).listen(8001); // 對比index1.js只是改了個埠號

console.log(Worker ${process.pid} started); ```

  • 然後我們起一個terminal,pm2 start一下:

image.png - 瀏覽器分別開啟localhost:8000localhost:8001:

image.png

Everything works fine!! 兩個專案分別使用了我們配置的node版本運行了。

  • 之後我們稍微修改一下配置檔案改成如下: ```js module.exports = { apps : [{ // 對應埠8000的服務
  • instances: 1, //增加instance配置,使服務啟動在cluster模式下 out_file: './out.log', name : "node 10 app", script : "./index1.js", interpreter: "/Users/haochenli/.nvm/versions/node/v10.0.0/bin/node" }, { // 對應埠8001的服務 out_file: './out.log', name : "node 14 app", script : "./index2.js", interpreter: "/Users/haochenli/.nvm/versions/node/v14.18.1/bin/node", }] } `` 先殺掉所有程序(為啥不用pm2 delete和pm2 stop我們後面再說),執行pkill -f pm2。之後再次執行pm2 start`,來看下結果:pm2執行log正常:

image.png

瀏覽器訪問兩個埠號: image.png 一個是v16.13.0,一個是v14.18.1?我們明明設定的是v10.0.0,這個v16.13.0是哪裡來的?

image.png

茶泡好,煙點起,讓我們一步一步來

instances配置:

首先當然是檢視instance這個配置是什麼意思,幹啥用的,配置了instance進入的cluster模式又是什麼?pm2 cluster模式,簡單理解下來,cluster模式就是讓你的服務儘可能的利用你的計算機效能(如多個cpu),建立多個子程序,均衡服務的負載,在不改變程式碼的前提下儘可能大的提升服務的效能,而instances就是允許你的服務可以用上幾個cpu。

image.png 說到這裡我們看下pm2中的原始碼: js //pm2/lib/God.js env.instances = parseInt(env.instances); if (env.instances === 0) { env.instances = numCPUs; } else if (env.instances < 0) { env.instances += numCPUs; } if (env.instances <= 0) { env.instances = 1; } timesLimit(env.instances, 1, function (n, next) { // 執行env.instances次的executeApp .... return God.executeApp() }) 可以看出和文件一致,instances相當於是執行多少次App。

God Daemon程序:

那麼問題又來了,這個God又是什麼,並且上面的程式碼所在檔案也是叫做God.js,經過我的查詢,當我們在終端中查一下程序就知道這個God意味著啥了,在終端中執行ps -aef | grep pm2我們來仔細看下:

image.png 忽略最後的一個grep命令,有三個和pm2相關的命令,其中第一個叫做God Daemon,之後的兩個就是我們對應的兩個node-server(node10 app和node14 app)。當我們pm2 delete 0或者pm2 delete 1對應kill掉的執行緒其實是後面兩個,而god daemon是伴隨pm2啟動的,所謂的master process(老外現在叫做primary process)。我們試下: image.png 所以我們的專案在進行pm2 start命令時,所有的程序如下:

image.png 這也是上面我們為什麼執行pkill -f pm2來殺掉所有的pm2指令(後來查文件知道pm2提供一個殺掉god程序的方式,pm2 kill)

PM2 中的Fork模式

現在已經理清了pm2啟動專案的程序建立過程,接下來看fork是如何實現的:
從/lib/god.js中看到God.executeApp = function executeApp(env, cb) {...}方法,裡面有個大的ifelse: js require('./God/ForkMode.js')(God); require('./God/ClusterMode.js')(God); ... God.executeApp = function executeApp(env, cb) { if (env_copy.exec_mode === 'cluster_mode') { God.nodeApp(env_copy, function nodeApp(err, clu) { var old_env = God.clusters_db[clu.pm2_env.pm_id]; // 這裡會根據id儲存node執行的env if (old_env) { old_env = null; God.clusters_db[clu.pm2_env.pm_id] = null; } God.clusters_db[clu.pm2_env.pm_id] = clu; // 下面一堆監聽事件 clu.once('error', function(err) {...}); clu.once('disconnect', function() {...}); clu.once('exit', function cluExit(code, signal) {...}); return clu.once('online', function () {...}); }); } else { God.forkMode(env_copy, function forkMode(err, clu) { if (cb && err) return cb(err); if (err) return false; var old_env = God.clusters_db[clu.pm2_env.pm_id]; if (old_env) old_env = null; God.clusters_db[env_copy.pm_id] = clu; // 下面一堆監聽事件 clu.once('error', function cluError(err) {...}); clu.once('exit', function cluClose(code, signal) {...}); }); } } 原來重點在./God/ForkMode.js中:(省略掉不關心的部分) js module.exports = function ForkMode(God) { God.forkMode = function forkMode(pm2_env, cb) { ... var spawn = require('child_process').spawn; .... var cspr = spawn(command, args, options); ... } } 原來fork模式下利用了node的child_process.spawn方式執行應用,接下來我們在其中加入log,看一下傳入的command, args, options分別是啥: image.png - commands就是我們通過config檔案的interpreter指定的node版本的目錄 - args是pm2專案中的processContainerFork.js - 我沒有截全,但是能看出來是一些node執行的環境變數,會在processContainerFork中讀取並使用 所以簡單來說fork的模式就是執行一個 path/to/node processContainerFork.js env, 和我們本地執行一個node xxx.js的方式一致,path/to/node實現了利用我們配置的node版本去執行app。 另外多說一句,如果你沒有配置interpreter,pm2會去取pm_exec_path中的內容,就是你在terminal中執行pm2時跑起god daemon的node版本。

PM2中的cluster模式:

接下來我們看看./God/ClusterMode.js中的內容:(省略掉不關心的部分) ```js var cluster = require('cluster');

module.exports = function ClusterMode(God) { God.nodeApp = function nodeApp(env_copy, cb){ ... var clu = null; clu = cluster.fork({pm2_env: JSON.stringify(env_copy), windowsHide: true}); ... } } `` cluster模式原來是呼叫了nodejs提供的cluster模式啟動一個服務,我們再看下入參重的env_copy都是些什麼: ![image.png](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e29a70cc728c4833885a703963b164e8~tplv-k3u1fbpfcp-watermark.image?) 其中的一些內容和上面的fork模式下的env一致,顯然cluster的使用方式我們還不是很清楚,它不像child_process那樣的node xxx.js`的呼叫方式,那麼node的cluster怎麼使用呢?

nodejs中的cluster.fork如何使用?

這裡就簡單使用node官方文件的demo: ```js //cluster.js import cluster from 'cluster'; import http from 'http'; import { cpus } from 'os'; import process from 'process';

const numCPUs = cpus().length;

if (cluster.isPrimary) { console.log(Primary ${process.pid} is running);

// Fork workers. for (let i = 0; i < numCPUs; i++) { cluster.fork(); }

cluster.on('exit', (worker, code, signal) => { console.log(worker ${worker.process.pid} died); }); } else { // Workers can share any TCP connection // In this case it is an HTTP server http.createServer((req, res) => { res.writeHead(200); res.end('hello world\n'); }).listen(8000);

console.log(Worker ${process.pid} started); } `` 程式碼非常簡單,首先你第一次執行這個cluster.js檔案的時候會啟動一個程序,該程序作為primary並標記為isPrimary,之後會根據cpu的數量執行對應次數的cluster.fork,每次cluster.fork`被呼叫,相當於這個cluster.js檔案又被執行一遍,但是此時執行該cluster.js檔案的執行緒不再是isPrimary的,所以會走else中的內容,最終的log如下 image.png 沒錯是我來秀我的12核電腦的。
cluster模組其實是封裝的child_process,在http服務中,cluster模組會自動建立一個master-slave的架構,master程序會將收到的request自動分發給slave程序,父子程序通過ipc進行通訊。至於如何講任務,官方文件中有提到兩種方式:1.round-robin。(大學學過來著,忘乾淨了) 2.master程序建立一個監聽socket,然後分發給子程序。

回到問題

我們之前的問題是發現在cluster模式下我們配置的node版本並沒有生效,結合了cluster模組的使用和pm2中的原始碼分析可知:

當模式是在cluster的時候,首先會通過setupMaster來配置exec引數(在god.js中),來決定要反覆執行的js檔案,再根據配置的instances數量去決定執行多少次。 js //下面程式碼在god.js中 cluster.setupMaster({ windowsHide: true, exec : path.resolve(path.dirname(module.filename), 'ProcessContainer.js') }); 所以在./God/ClusterMode.js中cluster.fork反覆執行的就是ProcessContainer.js檔案, 而在processContainer中並沒有像是在Fork模式下指定node的執行目錄,而是直接使用的process.versions延用God.js執行時的node例項,也就是你執行pm2 start時候第一次啟動God daemon時的node版本。

綜上我們終於得知了在demo中啟動在8000埠的服務為什麼在配置了interpreter為node10的情況下,會用node16啟動服務了。

結論

通過本文了解了pm2中在fork和cluster模式下的一些機制,最終也得出了結論,pm2在cluster模式下,配置的node版本並不會生效,而是由第一次啟動pm2服務的node版本,即God daemon執行所在的node版本決定。

references:# Single thread vs child process vs worker threads vs cluster in nodejs