Node.js の Cluster モジュールを使って Express サーバを並列化する

Node.js はシングルプロセスで処理するため、マルチコアを活かして並列処理するにはひと手間準備が必要になる。

今回は、Node.js 組み込みの cluster モジュールを使って Express サーバを並列化してみる。

目次

元となる Express サーバ

今回元にする Express サーバの実装は以下のとおり。

/*! index.js */
const express = require('express');

express()
  .get('/', (req, res) => {
    console.log(`Request`);
    res.send('Hello World');
  })
  .listen(8080, () => {
    console.log(`Server Started`);
  });

http://localhost:8080/ にアクセスすると「Hello World」と答えるだけのサーバだ。

cluster モジュールを組み込む

このようなサーバプロセスを CPU のコア数分だけ Fork して上手く管理してもらうために、Node.js 組み込みの cluster モジュールを組み込んでみる。

/*! index.js */
const cluster = require('cluster');
const os = require('os');
const express = require('express');

// CPU のコア (スレッド) 数を調べる
const numCPUs = os.cpus().length;

if(cluster.isMaster) {
  console.log('Master');
  
  // Worker を生成する
  for(let i = 0; i < numCPUs; i++) {
    console.log(`Master : Cluster Fork ${i}`);
    cluster.fork();
  }
  
  // Worker がクラッシュしたら再生成する
  cluster.on('exit', (worker, code, signal) => {
    console.warn(`[${worker.id}] Worker died : [PID ${worker.process.pid}] [Signal ${signal}] [Code ${code}]`);
    cluster.fork();
  });
}
else {
  console.log(`[${cluster.worker.id}] [PID ${cluster.worker.process.pid}] Worker`);
  // Express サーバの実装は元のまま変更なし (コンソール出力の内容だけ加工)
  
  express()
    .get('/', (req, res) => {
      console.log(`[${cluster.worker.id}] [PID ${cluster.worker.process.pid}] Request`);
      res.send('Hello World');
    })
    .listen(8080, () => {
      console.log(`[${cluster.worker.id}] [PID ${cluster.worker.process.pid}] Server Started`);
    });
}

cluster.isMaster で条件分岐し、マスターコードはプロセスの Fork のみ行い、ワーカコードはこれまでどおり Express サーバの起動のみを行っている。分かりやすくするためコンソール出力の内容だけ加工したものの、Express サーバの実装は何も変更していない。

動作を見てみる

このように実装した index.js を起動して、どのように動くか見てみよう。

$ npm start

> express-cluster-practice@ start /Users/Neo/express-cluster-practice
> node index.js

Master
Master : Cluster Fork 0
Master : Cluster Fork 1
Master : Cluster Fork 2
Master : Cluster Fork 3
Master : Cluster Fork 4
Master : Cluster Fork 5
Master : Cluster Fork 6
Master : Cluster Fork 7
[1] [PID 58925] Worker
[1] [PID 58925] Server Started
[2] [PID 58926] Worker
[3] [PID 58927] Worker
[4] [PID 58928] Worker
[2] [PID 58926] Server Started
[4] [PID 58928] Server Started
[5] [PID 58929] Worker
[3] [PID 58927] Server Started
[8] [PID 58932] Worker
[6] [PID 58930] Worker
[7] [PID 58931] Worker
[5] [PID 58929] Server Started
[8] [PID 58932] Server Started
[6] [PID 58930] Server Started
[7] [PID 58931] Server Started

起動直後はこんな感じ。Master コードからプロセスを8つ Fork している。今回検証に使用した MacBookPro の CPU は Intel Core i7-7820HQ というモデルで、このモデルは4コア8スレッドだった。だから8つのプロセスが Fork できたのだろう。

$ node -e "console.log( require('os').cpus() )"
[ { model: 'Intel(R) Core(TM) i7-7820HQ CPU @ 2.90GHz',
    speed: 2900,
    times: { user: 22561050, nice: 0, sys: 10492570, idle: 187783350, irq: 0 } },
  { model: 'Intel(R) Core(TM) i7-7820HQ CPU @ 2.90GHz',
    speed: 2900,
    times: { user: 1412060, nice: 0, sys: 894360, idle: 218515020, irq: 0 } },
  { model: 'Intel(R) Core(TM) i7-7820HQ CPU @ 2.90GHz',
    speed: 2900,
    times: { user: 19931150, nice: 0, sys: 5917620, idle: 194972820, irq: 0 } },
  { model: 'Intel(R) Core(TM) i7-7820HQ CPU @ 2.90GHz',
    speed: 2900,
    times: { user: 1255140, nice: 0, sys: 730460, idle: 218835770, irq: 0 } },
  { model: 'Intel(R) Core(TM) i7-7820HQ CPU @ 2.90GHz',
    speed: 2900,
    times: { user: 20051370, nice: 0, sys: 5997140, idle: 194773040, irq: 0 } },
  { model: 'Intel(R) Core(TM) i7-7820HQ CPU @ 2.90GHz',
    speed: 2900,
    times: { user: 1252750, nice: 0, sys: 733980, idle: 218834600, irq: 0 } },
  { model: 'Intel(R) Core(TM) i7-7820HQ CPU @ 2.90GHz',
    speed: 2900,
    times: { user: 19638190, nice: 0, sys: 5853950, idle: 195329350, irq: 0 } },
  { model: 'Intel(R) Core(TM) i7-7820HQ CPU @ 2.90GHz',
    speed: 2900,
    times: { user: 1249330, nice: 0, sys: 727480, idle: 218844460, irq: 0 } } ]

cluster.worker.id ないしは worker.id は、プロセスの生成順に 1 からの連番が振られるだけで特に意味なし。プロセス ID (PID) は (cluster.)worker.process.pid で確認できる。

$ ps
  PID TTY           TIME CMD
57532 ttys000    0:00.06 -bash
53123 ttys002    0:00.09 /bin/bash -l
57329 ttys003    0:00.05 /bin/bash -l
58923 ttys003    0:00.24 npm  
58924 ttys003    0:00.15 node index.js
58925 ttys003    0:00.21 /Users/Neo/.nodebrew/node/v8.15.0/bin/node /Users/Neo/express-cluster-practice/index.js
58926 ttys003    0:00.21 /Users/Neo/.nodebrew/node/v8.15.0/bin/node /Users/Neo/express-cluster-practice/index.js
58927 ttys003    0:00.21 /Users/Neo/.nodebrew/node/v8.15.0/bin/node /Users/Neo/express-cluster-practice/index.js
58928 ttys003    0:00.21 /Users/Neo/.nodebrew/node/v8.15.0/bin/node /Users/Neo/express-cluster-practice/index.js
58929 ttys003    0:00.21 /Users/Neo/.nodebrew/node/v8.15.0/bin/node /Users/Neo/express-cluster-practice/index.js
58930 ttys003    0:00.21 /Users/Neo/.nodebrew/node/v8.15.0/bin/node /Users/Neo/express-cluster-practice/index.js
58931 ttys003    0:00.21 /Users/Neo/.nodebrew/node/v8.15.0/bin/node /Users/Neo/express-cluster-practice/index.js
58932 ttys003    0:00.21 /Users/Neo/.nodebrew/node/v8.15.0/bin/node /Users/Neo/express-cluster-practice/index.js
57819 ttys004    0:00.10 /bin/bash -l

ps コマンドからも同じ PID が確認できる。

この状態で、curl コマンドを3回ほど叩いてみる。

$ curl http://localhost:8080/

するとコンソールログには以下のように出力される。

[1] [PID 58925] Request
[2] [PID 58926] Request
[4] [PID 58928] Request

リクエストごとに別々のプロセスで反応していることが分かる。リスンするポートはどのプロセスも 8080 固定だが、cluster モジュールがロードバランサの機能も持っているので、気にせず Fork させられる。

次に、Fork されたプロセスを一つ kill してみる。

$ kill 58925

するとコンソールログには次のように出力される。

[1] Worker died : [PID 58925] [Signal SIGTERM] [Code null]
[9] [PID 59171] Worker
[9] [PID 59171] Server Started

cluster.on('exit') 部分の実装により、PID 58925 のプロセスが死んだことを検知し、新たなワーカプロセスを Fork できている。何か問題が起こってワーカプロセスがクラッシュした時も、コレで上手く対応できる。

以上

サクッと並列処理が実現できてよきよき。