故事起因:

故事的起因是我们项目中在一台机器利用pm2部署了多个nodejs的项目,但是每个项目需要一个指定的nodejs版本(因为每个项目中依赖不同版本node的c++ addon),例如项目 A 需要使用 node12 来运行,项目 B 中使用 node14 来运行。

问题描述:

在进行机器扩容的时候发现新部署的机器上对于本应该用 node12 启动的项目 A 死活使用的是 node14 来启动的。

|500

最开始肯定是带着气愤的心情去质疑运维扩容的机器为什么 node 版本环境和之前的机器不同?之前扩容的时候都没出现过类似的问题,为什么这次扩容的机器就会有问题。

运维同学当然很无辜呀~,我们也没做什么改动呀,扩容的流程也是规范化的不会出问题才对。
于是前端同学开始了漫长的排查,通过各种渠道排查了诸如:环境变量中的 node 指向,指向中的 node 是否安装,通过 CI 部署时安装的依赖内容等等。。。因为所在公司的部署流程及其”成熟“,从申请权限到排查问题花了很久很久。。

直到我们注意到了pm2中的一个字段 mode:

|500

于是我尝试在本地复现这个问题:

复现 DEMO

  • 我们通过 pm2 的配置文件 ecosystem.config.js 来启动两个 node server,两个 server 的名字分别为 node10 app 和 node14 app,并且 script 文件分别为 index1.js 和 index2.js,分别配置对应的启动的 node 版本 interpreter 参数

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 路径  
 }]  
}

// 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`); 

// 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 一下:

|500

  • 浏览器分别打开 localhost:8000localhost:8001:

|500

Everything works fine!! 两个项目分别使用了我们配置的 node 版本运行了。

  • 之后我们稍微修改一下配置文件改成如下:
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 正常:

|500

浏览器访问两个端口号: |500
一个是 v16.13.0,一个是 v14.18.1?我们明明设置的是 v10.0.0,这个 v16.13.0 是哪里来的?

茶泡好,烟点起,让我们一步一步来

instances 配置:

首先当然是查看instance这个配置是什么意思,干啥用的,配置了instance进入的cluster模式又是什么?
pm2 cluster模式
简单理解下来,cluster 模式就是让你的服务尽可能的利用你的计算机性能(如多个 cpu),创建多个子进程,均衡服务的负载,在不改变代码的前提下尽可能大的提升服务的性能,而 instances 就是允许你的服务可以用上几个 cpu。

|500
说到这里我们看下 pm2 中的源码:


//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 我们来仔细看下:

|500

忽略最后的一个grep命令,有三个和pm2相关的命令,其中第一个叫做God Daemon,之后的两个就是我们对应的两个node-server(node10 app和node14 app)。当我们 pm2 delete 0 或者 pm2 delete 1 对应 kill 掉的线程其实是后面两个,而 god daemon 是伴随 pm2 启动的,所谓的 master process (老外现在叫做 primary process)。我们试下:
|500
所以我们的项目在进行 pm2 start 命令时,所有的进程如下:

|500
这也是上面我们为什么执行 pkill -f pm2 来杀掉所有的 pm2 指令
(后来查文档知道 pm2 提供一个杀掉 god 进程的方式,pm2 kill)

PM2 中的 Fork 模式

现在已经理清了 pm2 启动项目的进程创建过程,接下来看 fork 是如何实现的:
从/lib/god.js中看到 God.executeApp = function executeApp(env, cb) {…} 方法,里面有个大的 ifelse:

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 中:(省略掉不关心的部分)

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分别是啥: |500

  • 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 中的内容:(省略掉不关心的部分)

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都是些什么: |500

其中的一些内容和上面的fork模式下的env一致,显然 cluster 的使用方式我们还不是很清楚,它不像child_process那样的 node xxx.js 的调用方式,那么 node 的 cluster 怎么使用呢?

nodejs 中的 cluster.fork 如何使用?

这里就简单使用 node官方文档的demo:

//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 如下

|500

cluster 模块其实是封装的 child_process,在 http 服务中,cluster 模块会自动建立一个 master-slave 的架构,master 进程会将收到的 request 自动分发给 slave 进程,父子进程通过 ipc 进行通讯。至于如何讲任务,官方文档中有提到两种方式:1.round-robin。(大学学过来着,忘干净了) 2.master 进程建立一个监听 socket,然后分发给子进程。

详细源码请参考
通过源码解析 Node.js 中 cluster 模块的主要功能实现
cluster_how_it_works

回到问题

我们之前的问题是发现在 cluster 模式下我们配置的 node 版本并没有生效,结合了 cluster 模块的使用和 pm2 中的源码分析可知:

当模式是在cluster的时候,首先会通过 setupMaster 来配置 exec 参数(在 god.js 中),来决定要反复执行的 js 文件,再根据配置的 instances 数量去决定执行多少次。

//下面代码在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 kill 杀掉 God daemon。 然后就用希望的 node 版本启动即可。

结论

通过本文了解了 pm2 中在 fork 和 cluster 模式下的一些机制,最终也得出了结论,pm2 在 cluster 模式下,配置的 node 版本并不会生效,而是由第一次启动 pm2 服务的 node 版本,即 God daemon 运行所在的 node 版本决定。