Node.js cluster 踩坑小结
Node.js
lellansin
1人收藏 337次学习

Node.js cluster 踩坑小结

内容主要分为三个部分,大触可以直接拉到文末看结论:

  • Process:介绍进程与 process 对象
  • child_process:介绍子进程 & IPC 与踩坑
  • cluster:负载实现简介与踩坑

1. Process

首先是进程部分, 关于进程我们需要明确两个概念, 分别是:1)操作系统的的进程 (process)。 2)Node.js 的 process 对象。

  • 1)操作系统的的进程

操作系统的进程是一个服务端非常基础的概念,基础到有点不好介绍(笑)。我们通常感知到进程都是通过各种工具,比如 Unix 的 ps 命令,Win 的 tasklist 命令等。

~ ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 Apr22 ?        00:00:14 init
root         2     1  0 May07 ?        00:00:00 [kthreadd/4230]
root         3     2  0 May07 ?        00:00:00 [khelper/4230]
root       127     1  0 Apr22 ?        00:00:00 upstart-udev-bridge --daemon
root       145     1  0 Apr22 ?        00:00:01 /lib/systemd/systemd-udevd --daemon
syslog     302     1  0 Apr22 ?        00:01:00 rsyslogd
...

其中的一些参数意义:

还有一些参数就不一一列举了,详见 ps 命令。其中有一些参数是 Node.js 从进程内部拿不到的比如当前进程的父进程 ID,当前进程的 CPU 利用率等。

简单的来说,进程是一个应用程序的实例,同一个应用程序可以起多个实例(进程)。并且进程是一个系统资源的集合,这些资源包括内存、CPU等。同时进程也是系统各项资源使用的标识,像有了身份证才能办银行卡一样,各项如 fd、端口等资源都是通过进程为标识使用的。

PS: 操作系统给每个进程都划分了单独的虚拟内存空间,以避免跨进程的内存注入问题。

 

  • 2)process 对象

Node.js 的 process 对象是一堆信息与操作的集合。可能是由于 process 挺多功能是在 C++ 中 binding 的原因(为了开发方便)导致 process 上混合了很多功能,包括但不限于:

  • 进程基础信息
  • 进程 Usage
  • 进程级事件
  • 系统账户信息
  • 环境变量
  • 信号收发
  • 三个标准流
  • Node.js 依赖模块/版本信息
  • ......

你甚至可以在 process 对象上找到操作异步的方法,例如 process.nextTick(摊手),既然都提到 nextTick 不如讲一讲吧(不然干货不够)。

这个问题讲的比较清楚和权威的莫属 Node.js 的官方博客了(The Node.js Event Loop, Timers, and process.nextTick() )其中有给出这个官方版本的 Event Loop

像 setTimeout 这样的 Timer 都是集中在 timers 环节处理,而 process.nextTick 则是插入到每个环节结束之后执行。所以这样会阻塞整个 Event Loop:

function test() { 
  process.nextTick(() => test());
}

而这样不会:

function test() { 
  setTimeout(() => test(), 0);
}

另外由于后面会提到环境变量,这里也顺便插一下环境变量的内容。设置环境变量可以通过 process.env 来获取其值。Node.js 中常用于配置,另外也可以通过读取定义好的配置文件来获取配置。在这方面有很多不错的库例如 dotenvnode-config 等。根据 risingstack 的调查 2016 年 Node.js 的开发者在配置这个问题上处于如下比率:

Party 中小伙伴的提问是 “饿了么的 Node.js 开发目前用的是哪种配置方式”,回答是 “Both”。我们使用配置文件(json)读取配置,并且有一个配置模板,在我们的 CI 系统中构建时会根据不同环境的环境变量生成相应的配置文件然后使用。

2. Child Process

关于子进程 (child_process)模块这里简化一下内容介绍,主要分为 3 个部分:

  • exec:启动一个子进程来执行命令,调用 bash 来解释命令,所以如果有命令有外部参数,则需要注意被注入的情况。
  • spawn:更安全的启动一个子进程来执行命令,使用 option 传入各种参数来设置子进程的 stdin、stdout 等。通过内置的管道来与子进程建立 IPC 通信。
  • fork:spawn 的特殊情况,专门用来产生 worker 或者 worker 池。 返回值是 ChildProcess 对象可以方便的与子进程交互。

详细一些的每个接口介绍可以参见这里。看到这里,了解 Unix 开发的同学可能会问:

child_process.fork 与 POSIX 的 fork 有什么区别?

Node.js 的 fork 创建进程是通过 libuv 的 uv_spawn. 在 Unix 平台下 uv_spawn 最终调用了系统的 fork。POSIX 的 fork 需要 waitpid 等方法手动回收, 如果未注意回收可能导致僵尸进程出现。Node.js 通过内建 IPC 来自动处理回收(这是个 flag,后面提到这里的坑)。

  • IPC

进程间通信(Inter-process communication, IPC)其实是个很简单的概念,只要你将这个进程的数据传递到另外一个进程就是 IPC 了,要实现这个数据的传递方法有非常多中,以下是维基百科中的列表

在 Node.js 中 IPC 的实现,在 Windows 上通过命名管道,在 Unix 通过 UNIX domain sockets 实现(详见官方文档),理论上来讲 UDS 的速度是要比 TCP socket 要快不少的(注意这个 flag,等会要讲坑了)。

在内置 IPC 建立之前父子进程如何通信?

Node.js 在启动子进程的时候,主进程先建立 IPC 频道,然后将 IPC 频道的 fd (文件描述符) 通过 process.env 环境变量(NODE_CHANNEL_FD)的方式传递给子进程,然后子进程通过 fd 连上 IPC 与父进程建立连接。

process.js#L230

function setupChannel() {
  // If we were spawned with env NODE_CHANNEL_FD then load that up and
  // start parsing data from that stream.
  if (process.env.NODE_CHANNEL_FD) {
    const fd = parseInt(process.env.NODE_CHANNEL_FD, 10);
    assert(fd >= 0);

    // Make sure it's not accidentally inherited by child processes.
    delete process.env.NODE_CHANNEL_FD;

    const cp = require('child_process');

    // Load tcp_wrap to avoid situation where we might immediately receive
    // a message.
    // FIXME is this really necessary?
    process.binding('tcp_wrap');

    cp._forkChild(fd);
    assert(process.send);
  }
}

child_process.js#L103

exports._forkChild = function(fd) {
  // set process.send()
  var p = new Pipe(true);
  p.open(fd);
  p.unref();
  const control = setupChannel(process, p);
  process.on('newListener', function onNewListener(name) {
    if (name === 'message' || name === 'disconnect') control.ref();
  });
  process.on('removeListener', function onRemoveListener(name) {
    if (name === 'message' || name === 'disconnect') control.unref();
  });
};
  • Node.js 内建 IPC 的问题

前面提到 Node.js 的 IPC 在 Unix 上是基于 UDS 的,由于 UDS 不使用网络底层协议来通信绕过了一堆的安全检查等问题,所以有理论上来说速度应该是挺快的 flag。但是实际使用中我们发现 Node.js 内置的 IPC 存在很大的性能问题。

这里写一个简单的测试,功能是通过 Node.js 内置的 IPC 从主进程向子进程发送数据,每一份发 100 条数据,每条数据 1MB 大小:

master.js

const child_process = require('child_process');

let child = child_process.fork('./child.js');
let data = Array(1024 * 1024).fill('0').join('');

setInterval(() => {
  let i = 100;
  while(i--) child.send(`${data}|${Date.now()}`);
}, 1000);

child.js

let i = 0;

process.on('message', (str) => {
  let now = Date.now();
  let [data, time] = str.split('|')
  console.log(i++, now - Number(time));
});

测试发现,(MBP 2.7GHz I5, Node.js v7.6)速度很慢。代码在上,各位可以自行感受。同样的功能用 TCP socket 实现速度可以差很多倍(见上图),没贴数据是因为 IPC 的这个速度不稳定,会在一个比较大的范围波动。

简而言之,Node.js 自带的 IPC 可能由于实现上的问题(尚未深究C代码)在传输较大数据(例如 1MB以上,具体下限未做详细分析)是存在性能问题的,所以不推荐使用。如果进程间有频繁的数据交互推荐使用别的方案比如 socket 通信、MQ 传递(kafka 已实现一定程度的实时)、RPC(thrift、GRPC)等。

Party 中小伙伴有提问我们开发目前用的是哪种方式,回答是有用 MQ,和 socket 自建 IPC 通信。

除了这个 flag,另外开始有提到一个使用自建 IPC 处理子进程回收的 flag。我们在线上部署维护的过程中碰到机器的 swap 内存爆满情况。排查发现多进程模式中存在 master 死亡后没有通知到 worker 终止进程,使得 worker 成为孤儿进程被系统 init 领养,在长时间无请求的情况下将 worker 的内存折叠进入 swap 内存。

各位在通过 Node.js 创建子进程的时候,正常情况都只会想到 .on 去 listen 子进程的 exit,而很少会考虑到在子进程中去 .on 父进程的异常 crash。

简单的说,手动 wait 回收子进程虽然麻烦,但是设计的时候就会考虑处理 master 挂了没回收的情况。而 Node.js 的子进程通过 IPC 实现这一套隐藏了这个细节,出现了这种问题反而没有那么方便处理。

处理上,各位可以考虑在子进程也做健康检查。在让子进程与父进程之间维持一个心跳,心跳断了(master 异常 crash)就让子进程做一些资源回收然后优雅的 process.exit。或者考虑使用 zookeeper 之类的工具来存每一个节点的情况,也可以系统的注意到所有节点。

3. Cluster

Node.js 的 cluster 这个模块用起来感觉是比较虚的。因为 cluster 是基于 child_process.fork 的,多个 worker 之间的通信也是通过内置的那套 IPC。我们看个简单的代码:

const cluster = require('cluster');            // | | 
const http = require('http');                  // | | 
const numCPUs = require('os').cpus().length;   // | |    都执行了
                                               // | | 
if (cluster.isMaster) {                        // |-|-----------------
  // Fork workers.                             //   | 
  for (var i = 0; i < numCPUs; i++) {          //   | 
    cluster.fork();                            //   | 
  }                                            //   | 仅父进程执行
  cluster.on('exit', (worker) => {             //   | 
    console.log(`${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('hello');                          // | |    都执行了

很多同学用 cluster 的原因大概是为了可以多个 worker 监听同一个端口,在 TCP 那一层的例如 SO_REUSEADDR 等 flag 实际上 Node.js 是没有暴露出来的。而这个 cluster 实际上能让多个 worker 处理同一个端口的请求是做了不少工作的,接下来我们来简单讨论一下 cluster 的负载均衡的情况。

首先在 Node.js 中 LB (load balance,负载均衡)是通过两个方式实现的,分别是 ①句柄共享(win)和 ② round-robin(*nix)。

① 句柄共享的方式主要用在 windows 上。具体是由主进程创建 socket 监听端口后,将 socket 句柄直接分发给感兴趣的 worker,然后当连接进来时,让 worker 直接自行 accept 然后处理。理论上这个方法应该是性能最好的, 但实际使用中存在比较大的分配不均的问题(常见情况是 8 个 worker,70%的连接跟其中的 2 个建立)。

② round-robin,时间片轮转法,所有平台的默认方案(除了 windows)。主进程监听端口,接收到新连接之后,通过时间片轮转法来决定将接收到的客户端的 socket 句柄传递给指定的 worker 处理。至于每个连接由哪个 worker 来处理,完全由内置的循环算法决定。

这个 round-robin 的均衡程度比句柄共享要好一点,不过我想说还不如直接用 nginx 去配 upstream。值得一提的是 node 应用每个进程各自一个端口性能也比 cluster 要好。发现有的同学误会了,补张示意图:

如果你要一个 common 的中规中矩的 LB 推荐用 nginx,如果你要比较好的均衡推荐用 HAproxy(不过不能像 nginx 那样处理静态文件),如果你要 LB 不差缓存比 nginx 好一点的可以用 Varnish(当然资源缓存最好用 CDN)。此外如果你想对应用无感知的 LB,可以找运维支持,比如配 DNS 来 LB (缺点是生效有延迟),厉害的运维还可以在网卡上做 LB 等等。

小结

  • process 对象的功能有点多,很多细节需要具体了解。
  • child_process:IPC 比较坑,各位注意绕一下。包括传输的性能,以及稳定性需要注意。
  • cluster:不推荐使用自带的 LB。

原文地址:Node.js cluster 踩坑小结 

加入1KE学习俱乐部

1KE学习俱乐部是只针对1KE学员开放的私人俱乐部
标签:
Node.js