故事起因:
故事的起因是我们项目中在一台机器利用pm2部署了多个nodejs的项目,但是每个项目需要一个指定的nodejs版本(因为每个项目中依赖不同版本node的c++ addon),例如项目 A
需要使用 node12
来运行,项目 B
中使用 node14
来运行。
问题描述:
在进行机器扩容的时候发现新部署的机器上对于本应该用 node12
启动的项目 A
死活使用的是 node14
来启动的。
最开始肯定是带着气愤的心情去质疑运维扩容的机器为什么 node 版本环境和之前的机器不同?之前扩容的时候都没出现过类似的问题,为什么这次扩容的机器就会有问题。
运维同学当然很无辜呀~,我们也没做什么改动呀,扩容的流程也是规范化的不会出问题才对。
于是前端同学开始了漫长的排查,通过各种渠道排查了诸如:环境变量中的 node 指向,指向中的 node 是否安装,通过 CI 部署时安装的依赖内容等等。。。因为所在公司的部署流程及其”成熟“,从申请权限到排查问题花了很久很久。。
直到我们注意到了pm2中的一个字段 mode
:
于是我尝试在本地复现这个问题:
复现 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 一下:
- 浏览器分别打开
localhost:8000
和localhost:8001
:
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 正常:
浏览器访问两个端口号:
一个是 v16.13.0
,一个是 v14.18.1
?我们明明设置的是 v10.0.0
,这个 v16.13.0
是哪里来的?
茶泡好,烟点起,让我们一步一步来
instances 配置:
首先当然是查看instance这个配置是什么意思,干啥用的,配置了instance进入的cluster模式又是什么?
pm2 cluster模式,
简单理解下来,cluster 模式就是让你的服务尽可能的利用你的计算机性能(如多个 cpu),创建多个子进程,均衡服务的负载,在不改变代码的前提下尽可能大的提升服务的性能,而 instances 就是允许你的服务可以用上几个 cpu。
说到这里我们看下 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
我们来仔细看下:
忽略最后的一个grep命令,有三个和pm2相关的命令,其中第一个叫做God Daemon,之后的两个就是我们对应的两个node-server(node10 app和node14 app)。当我们 pm2 delete 0
或者 pm2 delete 1
对应 kill 掉的线程其实是后面两个,而 god daemon 是伴随 pm2 启动的,所谓的 master process
(老外现在叫做 primary process)。我们试下:
所以我们的项目在进行 pm2 start 命令时,所有的进程如下:
这也是上面我们为什么执行 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分别是啥:
- 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都是些什么:
其中的一些内容和上面的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 如下
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 版本决定。