# cluster:扩展你的node应用

# 负载均衡一个HTTP服务器

让我们使用cluster模块克隆和负载一个简单的HTTP服务器。这里有一个很简单的例子,稍微改动了,模拟在响应之前CPU的工作:

// server.js
const http = require('http');
const pid = process.pid;
http.createServer((req, res) => {
  for (let i = 0, i< 1e7; i++ ) {}
  res.end(`handle by process.${pid}`);
}).listen(8080, () => {
  console.log(`started process`, pid);
})
1
2
3
4
5
6
7
8
9

为了核实我们创建的均衡器是在工作,我在返回结果里面加了进程的pid,确定是应用的拿一个实例处理该请求。

在我们创建的集群将这个服务器克隆为多工作进程之前,让我们先做一个简单的基准测试-这个服务器每秒中可以处理多少个请求。我们可以使用apache基准测试工具。运行哪个简单的服务器server.js之后,运行ab命令:

ab -c200 -t10 http://localhost:8080/
1

这个命令会每10秒钟发送200个并发请求来测试负载服务器。

现在我们有一个基准性能参照,我们可以使用cluster模块通过克隆策略扩展一个应用。

依据上面的例子-server.js,我们可以用这个内容为主进程创建一个新的文件(cluster.js):

// cluter.js
const cluster = require('cluster');
const os = require('os');
if (cluster.isMaster) {
  const cpus = os.cpus().length;
  console.log('forking for ', cpus, ' CPUS');
  for (let i =0; i < cpus; i++) {
    cluster.fork();
  }
} else {
  require('./server.js')
}
1
2
3
4
5
6
7
8
9
10
11
12

在 cluster.js 文件中,我们首先导入 cluster 和 os 模块。我们使用 os 模块通过 os.cpus() 获取 CPU 的内核数量。

cluster 模块给我们一个易得的 Boolean 标识 isMaster 判断 cluster.js 文件是否在主进程上面加载的。第一次执行这个文件,我们将会在主进程上面执行并且 isMaster 标识将会被设置为 true。在这个例子中,我们可以指导主进程根据 CPU 核数多次衍生我们的服务器。

现在我们仅仅通过 os 模块获取了 CPU 核的数量,然后用一个循环遍历这个数字,调用 cluster.fork 方法。for 循环根据系统中 cpu 的数量创建尽可能多的工作进程,充分利用多核的优势。

当在主进程里执行 cluster.fork 之后,当前的文件 cluster.js 将会被再次执行,不过这次是在 worker 模式执行的 isMaster 被设置为了 false。这里有另外一个标识被设置为了 true,如果你用到的话,是 isWorker。

当应用作为一个 worker 运行时,它可以做些实际的工作。这是我们需要定义我们服务逻辑的地方,例如,我们可以通过导入之前就已经存在的 server.js 文件。

基本上就是这样。在一个机器里面充分利用处理能力就是这么简单。为了测试 cluster,运行 cluster.js 文件:

node cluster.js
1

当我们多次请求 web server,请求将会被带有不同进程 id 的不同工作进程所处理。工作子进程并不完全是按顺序轮流处理的,因为 cluster 模块在选择了下一个工作进程的时候做了很多优化,但是负载将会分布在不同的工作进程之间。

我们可以用上面同样的 ab 命令测试集群负载测试:

# 向所有的工作进程广播信息

在主进程和工作进程之间交流很容易,因为在cluster模块底层使用的是child_process.fork API,意味着在主进程和工作进程之间有通信通道。

基于上面server.js/cluster.js的例子,我们可以使用cluster。works获取工作进程对象的列表,是一个包含所有工作进程的引用的对象,并且可以被用来读取工作进程的信息。既然我们在主进程与所有工作进程之间有通信通道,仅仅使用一个循环就可以给它们广播信息,例如:

Object.values(cluster.works).forEach(work => {
  worker.send('hello work', worker.id);
})
1
2
3

我们简单地使用Object.values来从cluster.workers对象获取所有工作进程的一个数组。然后,对于每个工作进程,我们可以使用send函数发送任何我们想要发送的值。

这个工作进程文件里面,我们的例子是server.js,为了从主进程读取收到的信息,我们可以在全局process对象上面注册一个message事件,例如:

process.on('message', msg => {
  console.log(`Messge from master: ${msg}`);
})

1
2
3
4

# 增加服务器的可用性

在一个node应用内运行一个单一实例,其中一个问题就是当实例崩溃时,应用不得不重启,这意味这及时这个过程时自动的,这两个行为之间应该也有一些停机的时间。

这种情况业适用于但服务器部署新的代码必须重新启动。使用一个实例,将会有停机时间影响系统的可用性。

当我们有多个实例时,用一些额外少量的代码很容易增加系统的可用性。

为了在服务器进程中随机的模拟进程崩溃,我们可以在一个随机时间的定时器内出发process.exit:

// in server.js
setTimeout(() => {
    process.exit(1); // death by random timeout
}, Math.random() * 10000);
1
2
3
4

当一个工作进程像这样退出时,主进程将会在cluster对象上使用exit事件被通知到。我们可以为这个时间注册一个处理器当任何一个工作进程退出时衍生出一个新的工作进程。

// 在 isMaster=true 块里面的 for 循环后面
cluster.on('exit', (worker, code, signal) => {
    if(code !== 0 && !worker.exitedAfterDisconnect) {
        console.log(`工作进程 ${worker.id} 崩溃了,正在开始一个新的工作进程`);
        cluster.fork();
    }
})
1
2
3
4
5
6
7

上面添加了一个很好的判断条件,确保工作进程崩溃了,而不是手动断掉连接或者被主进程杀掉了。例如,主进程可能根据负载模式判断我们使用了太多的资源,它认为在这种情况下需要杀掉一些工作进程。就这样做,我们可以在任何一个工作进程上面使用disconnect方法,在这种请框下,exitAfterDisconnect标示将被设置为true。上面的判断保证在这些情况下不会衍生一个心的工作进程。

如果我们运行 cluster(在 server.js 中随机让一些工作进程崩溃),随机的几秒之后,工作进程将会崩溃掉并且主进程马上衍生一个新的工作进程增加系统的可用性。你可以使用同样的 ab 命令测量集群的可用性并且看下有多少请求,服务器将不能处理(因为一些不幸的请求将不得不面对崩溃的情形并且很难避免)。

当我测试的时候,10 秒,测试间隔 200 个并发请求,超过 1800 个请求中只有 17 个请求失败了。

超过了 99% 的可用性。仅仅添加了几行代码,现在再也不需要担心进程崩溃了。主进程将会帮我们留意这些流程。

# 不停机重启

当我们想要重启所有的工作进程,比如,我们需要部署一些新的代码。这种情况呢?

我们有多个实例在运行,因此我们可以一次只启动他们之中的一个,而不是全部重启,允许当一个工作进程重启的时候,其他的工作进程继续服务请求。

用集群模块实现这个很容易。因为我们不想重启主进程,我们需要一个方法发送给主进程一个命令指导它去重启所有的工作进程。这个在 Linux 系统上面容易,我们可以简单的监听像 SIGUSR2 事件,这个事件在我们在进程 id 上面使用 kill 命令时,会触发并且将那个标志发送过去:

// In Node
process.on('SIGUSR2', () => { //... });

//触发
$ kill -SIGUSR2 PID
1
2
3
4
5

这种方式,主进程不会被杀掉并且有了一个我们可以指导它做一些事情的方式。在这里使用的 SIGUSR2 是一个合适的标志,因为这将会是一个用户命令。如果你在疑惑为什么不是 SIGUSR1,因为被 Node 用来调试(debugger)用了,需要避免冲突。

不幸的是,在 windows 上面,这些进程标志(signal)不被支持,我们需要找到另一种方式命令主进程做一些事情。这里有一些替代的方案。例如,我们可以使用标准的输入或者 socket 输入。或者我们可以模拟一个 process.pid 文件的退出并且监视它的删除事件。但是为了让个示例简单,我们假设这个服务器在 Linux 上面运行。

Node 在 windows 上面工作的非常好,但是我认为在 Linux 平台托管生产环境的 Node 应用更加安全。并不是 Node 本身的问题,但是很多其他的生产工具在 Linux 上面更加稳定。这是我个人的观点,你可以忽略掉。

顺便说下,在最近版本的 windows 上面,你可以使用一个真正的 Linux 子系统并且它工作的非常好。我自己测试过,真是令人印象深刻。如果你在 window 上面开发一个 Node 应用,试一下在 windows 上面的 bash 命令。

在我们的例子中,当主进程接受到一个 SIGUSR2 标志,意味着是时候重启它的工作进程了,但是我们需要一次只重启一个工作进程。这也简单地意味着主进程在重启完当前的工作进程后应该重启下一个工作进程。

为了开始这个任务,我们需要使用 cluster.workers 对象得到一个所有当前进程地引用,我们可以在一个数组中存储下来。

const workers = Object.values(cluster.workers);
1

然后,我们可以创建一个接受一个需要重启的工作进程的索引的 restartWorker 函数。我们在它已经为下个工作进程准备好了,然后调用函数,在序列里面做重启的工作。下面有一个我们可以在示例中使用的 restartWorker 函数:

const restartWorker = (workerIndex) => {
    const worker = workers[workerIndex];
    if(!worker) return;
    worker.on('exit', () => {
        if (!worker.exitedAfterDisconnect) return;
        console.log('退出的进程', worker.process.pid);

        cluster.fork().on('listening', () => {
            restartWorker(workerIndex + 1);
        });
    });
    worker.disconnect();
};

restartWorker(0);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在 restartWorker 函数内部,我们获取到一个需要重启的工作进程的引用,我们会从序列里面递归地调用这个函数,我们需要一个停止的条件。当我们不再需要重启一个工作进程时,我们可以返回(return)。我们基本上想要断掉(disconnect)这个工作进程(使用 worker.disconnect)时,但是在重启下一个工作进程之前,我们需要衍生一个新的工作进程代替当前这个我们我想要断掉的工作进程。

我们可以使用工作进程上面的 exit 事件,当前的进程退出后,衍生一个新的工作进程,但是我们不得不保证这个 exit 行为实际上是被一个正常 disconnect 调用之后触发的。我们可以使用 exitedAfterDisconnect 标识。如果这个标识不是 true,那么这个退出行为是被其他一些事情引起的而不是我们的 disconnect 调用,我们应该立马退出函数。但是如果这个标识是 true,我们可以继续并且衍生一个新的工作进程代替刚刚我们断掉的那个工作进程。

当这个新的衍生的工作进程准备好后,我们再重启下一个。然后记住衍生的进程不是同步的,因此我们不能再衍生调用之后直接重启下一个工作进程。应该是,我们在一个新衍生的工作进程上面模拟一个 listening 事件,告诉我们这个工作进程连接上了并且准备好了。当这个事件触发时,我们可以安全地重启序列中的下一个工作进程。

这就是我们需要的不停机重启。为了测试,你需要读取主进程的process id 并且将它发送到 SIGUSR2 标志:

console.log(`Master PID: ${process.pid}`);
1

开启这个集群,复制主进程 id,并且使用 kill -SIGUSR2 PID 命令重启这个集群。当重启集群时,为了看下重启进程对可用性造成的影响,你也可以使用同样的 ab 命令。剧透警告,你应该会有 0 个失败的请求:

进程监视器,如 PM2,我在生产中使用,我们经历的到目前为止,所有的任务都非常简单,提供很多监视 Node.js 应用健康状况的特性。例如,使用 PM2,为任何一个应用启动一个集群,你所需要做的使用 -i 参数:

pm2 start server.js -i max
1

并且不停机重启,仅仅使用这个有魔法的命令:

pm2 reload all
1

然而,我发现当你使用这些命令时,有助于你首先理解背后发生了什么。

# 共享状态和粘性负载均衡(Sticky Load Balancing)

美好的事情总实有代价的。当我们负载均衡一个 Node 应用时,我们失去了一些仅仅适用于单进程的特性。这个问题某种程度上和我们所知道的在其他语言中的关于线程之间共享数据线程安全相似。在我们的示例中,是在工作进程之间共享数据。

例如,设置过集群,我们不在内存中缓存一些东西,因为每一个工作进程都有自己的内存空间。如果我们在一个工作进程的内存里面缓存东西,其他的工作进程将访问不到缓存的东西。

如果我们需要在设置过集群的应用中缓存东西,我们不得不使用一个分开的实体(entity)并且从所有的工作进程里面读取那个实体的 API。这个实体可以是个一个数据库服务器或者如果想使用内存中的缓存,你可以使用一个像 Redis 的服务器或者创建一个专有的带有读写 API 的 Node 进程,用来让所有其他的进程之间相互交流。

不要因为为你需要缓存的应用使用一个分离的实体是分解你应用的可伸缩性的一部分,把这个看成一个缺点。即使你在一个单一的核心机器上面运行,也是应该做的事情。

不同于缓存,当我们在一个集群上面运行时,通常有状态的沟通成为了一个问题。因为不保证交流的一方是同一个工作进程,在任何一个工作进程上创建一个有状态的通道不是一个选择。

最常见的例子是对用户进行身份验证。

使用集群,身份验证的请求来自于主进程均衡器,被发送给工作进程,假设在这个例子中是 A。

工作进程 A 现在识别到了用户的状态。然而,相同的用户发出另一个请求时,负载均衡器最终把请求发送到其他的工作进程中去了,而那些工作进程并没有验证他们的身份。在一个实例内存中保持一个对经过验证的用户会话的引用不再工作了。

这个问题有多种解决办法。我们在一个共享的数据库或者一个 Redis 节点通过存储用户的会话信息,在很多工作进程之间共享状态。然而,应用这个策略需要一些代码的变动,并不总是一个选择,对吗?

如果你不能做一些代码的变动来实现一个会话的共享存储,这里有一个变动很小但很高效的策略。你可以使用所谓的粘性负载均衡。这是一种更为简单的实现,很多负载均衡器开箱即用地支持这一策略。理念很简单。当一个用户使用一个工作进程实例验证用户身份,我们可以在负载均衡器的水平保留一个那个关系的记录。

然后同样的用户发送一个新的请求,我们可以在记录中查找识别出哪一个工作进程有他们的身份验证信息,然后将请求发送给他们,而不是正常的分布式行为。这种方式,在服务器端的代码不需要改变,但是对于已经身份验证的用户,不能得到负载均衡的优势,因此如果你没有其他选择的话就使用粘性负载均衡。

集群模块实际上不支持粘性负载均衡,但是一些其他的负载均衡器可以配置默认支持粘性负载均衡。