pm2中的cluster模式和fork模式

·  阅读 1699
pm2中的cluster模式和fork模式

故事起因:

故事的起因是我们项目中在一台机器利用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参数
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一下:

image.png

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

image.png

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正常:

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中的源码:

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

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

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 其中的一些内容和上面的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如下 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数量去决定执行多少次。

//下面代码在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

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改