Node.jsのClusterをセットアップして、処理を並列化・高速化する

Node.jsが多数のイベントの非同期な処理に長けていることはよく知られていますが、それが単一のスレッドで行われていることを多くの人は知りません。Node.jsは実際にはマルチスレッドではないので、リクエストは全て単一スレッドのイベントループで処理されているだけなのです。

そこで、Node.jsクラスタを使って、クワッドコアプロセッサの能力を最大限に引き出しましょう。コードの複数のインスタンスで起動し、さらに多くのリクエストを処理します。少し難しく思えるかも知れませんが、Node.js v0.8で導入されたclusterモジュールを使えば、実はとても簡単です。

もちろん、これは、作業を別々のプロセスに分割することのできるアプリならどんなアプリにでも役立ちますが、webサイトのような多くのIOリクエストを処理するアプリには特に重要です。

残念ながら、並行処理は複雑なので、サーバ上でのアプリケーションのクラスタリングは必ずしも簡単ではありません。複数のプロセスに同一のポートをリッスンさせる必要があるときはどうすればいいでしょうか? 同時に1つのポートにアクセスできるプロセスはただ1つだということを思い出しましょう。解決策として、各プロセスがそれぞれ異なるポートをリッスンするように設定してから、Nginxをセットアップしてポート間でリクエストの負荷を分散するという素朴な方法があります。

この解決策は実行可能ですが、より多くの作業のセットアップと各プロセスの設定、さらに、言うまでもなくNginxの設定が必要になります。この解決策では、管理すべきものが増えるだけです。

別の方法として、マスタープロセスを複数の子プロセスにforkすることができます(通常、1つのプロセッサに1つの子プロセス)。この場合、子プロセス達は1つのポートを親プロセスと共有することを許可されているので(プロセス間通信、つまりIPCのおかげです)、複数のポートの管理に悩む必要はありません。

複数ポートの管理こそ、あなたに代わってclusterモジュールがやってくれることなのです。

Clusterモジュールを使った作業

アプリケーションのクラスタリングは、特にExpressプロジェクトのようなwebサーバコードでは、極めて簡単です。下記のとおりに書くだけです。

var cluster = require('cluster');  
var express = require('express');  
var numCPUs = require('os').cpus().length;

if (cluster.isMaster) {  
    for (var i = 0; i < numCPUs; i++) {
        // Create a worker
        cluster.fork();
    }
} else {
    // Workers share the TCP connection in this server
    var app = express();

    app.get('/', function (req, res) {
        res.send('Hello World!');
    });

    // All workers use this port
    app.listen(8080);
}

このコードの機能は、マスターコードとワーカコードの2つの部分に分けられます。この分岐は、if文(if (cluster.isMaster) {...})で行われます。ここでのマスターの唯一の目的はワーカを全て作成すること(作成されるワーカの数は、利用可能なCPUの数に基づきます)で、ワーカはExpressサーバの別々のインスタンスを実行する責任を負います。

ワーカはメインプロセスからforkされると、モジュールの先頭からコードを再実行します。ワーカがif文まで進むと、cluster.isMasterfalseが返されるので、Expressアプリ、ルートを作成してから、ポート8080をリッスンします。クワッドコアプロセッサの場合は、4つのワーカが生まれ、同じポートをリッスンしてリクエストが来るのを待ちます。

でも、リクエストはワーカ間でどのように分けられるのでしょうか。全てのワーカが一つ一つのリクエストをリッスンしてそれに応答することができない(そして、そうしてはならない)ことは明らかです。実は、これに対処するために、別々のワーカ間に分散されたリクエストを処理するロードバランサがclusterモジュール内に組み込まれています。LinuxとOSXでは、ラウンドロビン(cluster.SCHED_RR)ポリシーがデフォルトで有効になっています(ただし、Windowsには該当しません)。スケジューリングに利用可能な他の選択肢はオペレーティングシステムに任せること(cluster.SCHED_NONE)だけで、これはWindowsではデフォルトです。

スケジューリングポリシーは、cluster.schedulingPolicy内に設定するか、または、環境変数NODE_CLUSTER_SCHED_POLICYに設定(’rr’ または’none’のどちらかの値)することによって設定することができます。

また、異なる複数のプロセスがどうやって単一のポートを共有するのでしょうか。ネットワークリクエストを処理する多数のプロセスを実行させるために問題になる点は、従来、同時に開くことのできるポートは1つだけだということです。clusterの大きな利点は、あなたに代わってポート共有を処理してくれることであり、webサーバなどのために開いたどのポートにも、全ての子プロセスがアクセス可能です。これはIPCを使って行われるので、マスターは各ワーカにポートハンドルを送るだけです。

このような機能のおかげで、クラスタリングはとても簡単なのです。

cluster.fork() vs child_process.fork()

child_processfork()メソッドの経験がある人なら、それにcluster.fork()が似ているように思えるかもしれません(そして、両者は多くの点で似ています)。そこで、ここでは、この2つのforkの方法の主要な違いについて説明します。

cluster.fork()child_process.fork()には、いくつかの主な違いがあります。child_process.fork()メソッドはやや低レベルで、引数としてモジュールの位置(ファイルパス)を渡す必要があり、その他のオプションの引数として現在のワーキングディレクトリ、プロセスを所有するユーザ、環境変数なども渡す必要があります。

もう1つの違いは、clusterが、自身が実行を始めたのと同じモジュールの先頭からワーカの実行コードを起動することです。したがって、アプリケーションのエントリポイントがindex.jsでありながらワーカがcluster-my-app.jsの中で生成された場合でも、ワーカはやはり、index.jsの先頭から実行コードを起動します。child_processはそれとは異なり、必ずしも所与のアプリのエントリポイントではなく、渡されたファイルからコードの実行を始めます。

ここまでで読まれた方は、「clusterモジュールは、子プロセスを生成するために実際には密かにchild_processモジュールを使用し、それがchild_process自体のfork()メソッドで行わることで子プロセスがIPCを通じて通信することが可能になり、そうやってポートハンドルがワーカ間で共有されるのだろう」と考えているかもしれません。

明確に言えば、Nodeでのforkは実際には現在のプロセスのクローンを作成するのではなく、新しいV8インスタンスを起動させるという点で、POISIX forkとは大きく異なります。

この方法は、最も簡単なマルチスレッドの方法の1つではありますが、注意して使用すべきです。1000のワーカを生成することができても、そうするべきとは限りません。ワーカはそれぞれシステムリソースを消費するので、本当に必要なワーカだけを生成しましょう。Nodeドキュメントによれば、子プロセスはそれぞれ新しいV8インスタンスなので、インスタンスごとにそれぞれ30msの起動時間と、10mb以上のメモリを想定する必要があります。

エラー処理

ワーカのうちの1つ(または複数)が死んだらどうしますか? クラッシュの後ワーカを再起動できないのならば、クラスタリングの美点が根本的に失われてしまいます。幸いclusterモジュールは EventEmitterを拡張し、’exit’イベントを提供します。ワーカである子プロセスの1つが死ぬと、’exit’イベントが通知するのです。

下記のコードを使うと、イベントのログを記録し、プロセスを再起動できます。

cluster.on('exit', function(worker, code, signal) {  
    console.log('Worker %d died with code/signal %s. Restarting worker...', worker.process.pid, signal || code);
    cluster.fork();
});

こうすると、たった4行のコードで専用の内部プロセスマネージャが備わったようなものですね!

パフォーマンスの比較

次のパートは興味深いですよ。クラスタリングが実際のところどの程度役に立つのか見てみましょう。

この実験では、先に挙げたコード例に似ているWebアプリをセットアップしました。しかし一番大きな違いは、Expressのルート内で行われている処理のシミュレーションを、sleepモジュールを使い、大量のランダムなデータをユーザに返すことによって行っているという点です。

下記は同じWebアプリですが、クラスタリングしたものです。

var cluster = require('cluster');  
var crypto = require('crypto');  
var express = require('express');  
var sleep = require('sleep');  
var numCPUs = require('os').cpus().length;

if (cluster.isMaster) {  
    for (var i = 0; i < numCPUs; i++) {
        // Create a worker
        cluster.fork();
    }
} else {
    // Workers share the TCP connection in this server
    var app = express();

    app.get('/', function (req, res) {
        // Simulate route processing delay
        var randSleep = Math.round(10000 + (Math.random() * 10000));
        sleep.usleep(randSleep);

        var numChars = Math.round(5000 + (Math.random() * 5000));
        var randChars = crypto.randomBytes(numChars).toString('hex');
        res.send(randChars);
    });

    // All workers use this port
    app.listen(8080);
}

そして比較用の’control’のコードがこちらです。全く同じもので、ただcluter.fork()がないだけです。

var crypto = require('crypto');  
var express = require('express');  
var sleep = require('sleep');

var app = express();

app.get('/', function (req, res) {  
    // Simulate route processing delay
    var randSleep = Math.round(10000 + (Math.random() * 10000));
    sleep.usleep(randSleep);

    var numChars = Math.round(5000 + (Math.random() * 5000));
    var randChars = crypto.randomBytes(numChars).toString('hex');
    res.send(randChars);
});

app.listen(8080);

ユーザ負荷が高い場合のシミュレーションには、Siege というコマンドラインツールを使うことになります。Siegeを使えば、任意のURLに大量のリクエストを発行することができます。

Siegeは可用性、スループット、リクエスト処理率等のパフォーマンスメトリクスを追跡するという点で優れています。

下記はSiegeでのテストに使うコマンドです。

$ siege -c100 -t60s http://localhost:8080/

このコマンドを先に挙げたWebアプリの両方のバージョンで実行したところ、いっそう興味深い結果が出ました。

分類 リクエスト処理数の総計 リクエスト数/秒 平均応答時間 スループット
クラスタリングなし 3467 58.69 1.18 秒 0.84 MB/秒
クラスタリングあり (4プロセス) 11146 188.72 0.03 秒 2.70 MB/秒

ご覧のとおり、このリストに挙げた全てのメトリクスについて、クラスタリングありのアプリケーションがシングルプロセスのものより約3.2倍パフォーマンスが高くなっていますし、平均応答時間ではさらに大幅に上回っています。