Node.js v0.6 の新機能として cluster モジュール が導入されました.
cluster
モジュールは,HTTP を含めた TCP 接続を複数の子プロセス (ワーカプロセス) で処理することにより,特にマルチコア環境でのスループット (リクエスト/秒) を向上するための機能です.しかし,ドキュメントにはその使い方が書かれているだけで,どのように実現されているかは書かれていないので,ここで簡単に紹介しておきます.
Node.js のクラスタ機能は v0.5.10 で突然コマンドラインオプションとして導入されましたが,直後の「東京 Node 学園祭 2011」が行われた頃にはコマンドラインオプションは廃止されて
cluster
モジュールによって API が提供されるようになり,その翌週の v0.6.0 リリース数時間前にはその API が変更されるというドタバタぶりでしたw早速複数のプルリクエストが届いているなど,現時点では API が安定しているとは言い難い状況ですが,ここで紹介する基本的な実現方式については大きく変わることはないものと考えられます.
ソケットの基本
まずは,基本となるソケットについて復習しておきましょう.
通常,サーバサイドのアプリケーションは次の手順でソケットを利用します (引数の詳細やエラーチェック等は省略,ブロッキング・ノンブロッキング等は本エントリでは省略します).
listening = socket(...); // ソケットをオープン bind(listening, ...); // ローカルアドレス&ポートを割り当て listen(listening, ...); // リスニングソケットに変換 for (;;) { connected = accept(listening, ...); // 接続済みソケットを返す ... close(connected); }
ここでは 2 種類のソケットが登場します.ひとつはクライアントからの接続を待ち受けるためのソケットで,ここでは「リスニングソケット」と呼びます.もうひとつは実際にクライアントとの接続を表すソケットで,ここでは「接続済みソケット」と呼びます.
socket()
, bind()
, listen()
でリスニングソケットを準備して,accept()
でリスニングソケットに届いた接続済みソケットを取得します.接続済みソケットからクライアントの要求を受信したり,結果を送信するなどしてクローズすることで,一つのクライアントに対する処理が完了します.これを繰り返すことで,単純なサーバを実現することができます.
ただし,これではクライアントを一つずつしか処理することができません.同時に複数のクライアントを処理するには,一工夫が必要です.Node.js の場合は,
accept()
を select()
等のシステムコールによって「多重化」するイベントループを使うためにこの問題はありませんが,しばらくの間それは忘れましょう.フォーク型
複数のクライアントからの接続を処理するために,古くからよく使われた素朴な方法が,クライアントからの接続ごとに
fork()
して新しいプロセスを作成するというものです.listening = socket(...); // ソケットをオープン bind(listening, ...); // ローカルアドレス&ポートを割り当て listen(listening, ...); // リスニングソケットに変換 for (;;) { connected = accept(listening, ...); // 接続済みソケットを返す if (fork() == 0) { // 子プロセス (親プロセスとは並行に実行される) ... exit(0); } // 親プロセス close(connected); }
子プロセスはソケット (ファイル記述子) を親プロセスから引き継ぎますから,接続済みソケットを使ってクライアントと送受信することができます.接続済みソケットを標準入出力に設定して
exec()
(現在のプロセス上で別のプログラムを実行) すれば,CGI の動作になります.フォーク型は単純であるため,スレッドが普及する以前 (20 年前とか) に Unix 上で作られた多くのサーバアプリケーションで利用されていました.しかし,クライアントからの接続のたびにプロセスを起動するオーバーヘッドが大きいため,多くのクライアントを扱うことが難しいという問題がありました.
より多くのクライアントをサポートするには,あらかじめ子プロセスを起動 (プリフォーク) しておく必要があります (本エントリではスレッドは扱いません).以下ではその方法を見ていきましょう.
なお,以下では親プロセスをマスタ,子プロセスをワーカと呼びます.
ゲートウェイ型
一つ目の方法は,クライアントとワーカの間で送受信されるデータを,マスタが間に入って転送するゲートウェイ方式です.マスタはクライアントとってはサーバ,ワーカに対してはクライアントのように振る舞います.各ワーカはそれぞれ独自のポート番号でマスタからの接続を待ち受けます.
// マスタ listening = socket(...); // ソケットをオープン bind(listening, ...); // ローカルアドレス&ポートを割り当て listen(listening, ...); // リスニングソケットに変換 for (;;) { connected = accept(listening, ...); // 接続済みソケットを返す worker = socket(...); // ワーカとの通信用のソケットをオープン connect(worker, ...); // ワーカに接続 ... // クライアントとワーカの間でデータを転送 close(worker); close(connected); }
おっと,これでは最初の例と同じように,同時に一つのクライアントしか接続できませんね.実際には,
accept()
もワーカとの接続や送受信も,全て多重化されてイベントループの中で行われるということにしてください.ともあれ (JW),この方法の場合,
connect()
で接続するワーカをラウンドロビンなどの方法で切り替えることにより,複数のワーカにクライアントからの処理を分散することができます.新しいクライアントをどのワーカで処理させるか (ロードバランシング) はマスタが判断することになります.しかし,全てのクライアントからの送受信データがマスタを経由するため,マスタのスループットが全てのワーカを含めた全体の上限になってしまいます.実際には,この方法を親プロセスと子プロセスの間で利用することはあまりなく,複数のサーバマシン間で処理を分散するためのリバースプロキシ等で使われることが多いでしょう.
Node.js 関連では Nodejitsu の node-http-proxy でこの方法が使われています.
接続済みソケット共有型
ソケット (ファイル記述子) は,Unix ドメインソケットやパイプを通じてプロセス間で受け渡すことができます.これを利用して接続済みソケットをマスタからワーカに受け渡すことで,接続した後のクライアントとの送受信はワーカに任せることが実現できます.
// マスタ listening = socket(...); // ソケットをオープン bind(listening, ...); // ローカルアドレス&ポートを割り当て listen(listening, ...); // リスニングソケットに変換 for (;;) { connected = accept(listening, ...); // 接続済みソケットを返す ... // connected をワーカに転送する close(connected); }
先のゲートウェイ方式方式とは異なり,クライアントからの接続時に接続済みソケットがワーカに渡された後では,もうマスタはクライアントとの通信に関与しません.ですから,ゲートウェイ方式に比べると接続済みソケット共有型の方がずっと効率的といえるでしょう.複数のワーカがある場合に,どのワーカに接続済みソケットを渡すか (ロードバランシング) はマスタが判断することになります.
2013/05/19 追記
Node.js の cluster モジュールは、v0.11.2 から Unix 系 (非 Windows) プラットフォームではこの方式がデフォルトになりました。マスタプロセスはラウンドロビン方式でワーカプロセスに接続済みソケットを渡します。明示的に「接続済みソケット共有型」を利用するには、
Node.js の cluster モジュールは、v0.11.2 から Unix 系 (非 Windows) プラットフォームではこの方式がデフォルトになりました。マスタプロセスはラウンドロビン方式でワーカプロセスに接続済みソケットを渡します。明示的に「接続済みソケット共有型」を利用するには、
cluster.schedulingPolicy
に cluster.SCHED_RR
を指定します (または、NODE_CLUSTER_SCHED_POLICY
環境変数に rr
を指定します)。リスニングソケット共有型
プロセス間で受け渡すことができるソケットは接続済みソケットには限りません.リスニングソケットもまた受け渡すことができます.
// マスタ listening = socket(...); // ソケットをオープン bind(listening, ...); // ローカルアドレス&ポートを割り当て ... // ソケットをワーカに渡す
これまでの例では
listen()
~accept()
はマスタで実行していましたが,この方式ではワーカがそれを実行します.// ワーカ ... // マスタからソケットを受け取る listen(listening, ...); // ソケットをリスニングソケットに変換 for (;;) { connected = accept(listening, ...); // 接続済みソケットを返す ... close(connected); }
この場合,同じリスニングソケットに対して複数のワーカが同時に接続を待ち受けることになります.実際にクライアントからの接続が着信した場合に,どのワーカがそれを受け取るかは OS カーネルによって決められます.マスタは関与しません.そのため,この方法は「カーネルによるロードバランシング」と呼ばれることがあります.
この方法では,マスタは初期化時にリスニングソケットを準備するだけです.クライアントとの間のやり取りは,接続から送受信まで完全にワーカとの間で直接行われます.マスタは一切関与しません.ですから,これまでの方法の中でもっとも効率がいいといえるでしょう.
Node.js v0.6 で導入された cluster モジュールは,この方法を採用しています.
2013/05/19 追記
この方法では一部のワーカプロセスに接続が偏りやすいため、v0.11.2 から Unix 系 (非 Windows) プラットフォームでは前述の「接続済みソケット共有型」がデフォルトになりました。v0.11.2 以降で「リスニングソケット共有型」を利用するには、
この方法では一部のワーカプロセスに接続が偏りやすいため、v0.11.2 から Unix 系 (非 Windows) プラットフォームでは前述の「接続済みソケット共有型」がデフォルトになりました。v0.11.2 以降で「リスニングソケット共有型」を利用するには、
cluster.schedulingPolicy
に cluster.SCHED_NONE
を指定します (または、NODE_CLUSTER_SCHED_POLICY
環境変数に none
を指定します)。Node.js cluster モジュールの実際
それでは,実際に Node.js の cluster モジュールがどのように動作するか,簡単に見てみましょう.以下は
cluster
モジュールを使用しない単純な HTTP サーバの例です.var http = require('http'); http.createServer(function (req, res) { res.writeHead(200, {'Content-Type': 'text/plain'}); res.end('Hello World\n'); }).listen(1337, 'localhost');
これを
cluster
モジュールを使わず直接実行すると,http.Server
(実際には net.Server
) の listen()
メソッドは,内部的にソケットの socket()
と bind()
, listen()
と accept()
を呼び出します.一方,次のように
cluster
モジュールを使用すると,var cluster = require('cluster'); var http = require('http'); var numCPUs = require('os').cpus().length; if (cluster.isMaster) { // マスタ for (var i = 0; i < numCPUs; i++) { cluster.fork(); // ワーカを起動 } cluster.on('death', function(worker) { console.log('worker ' + worker.pid + ' died'); }); } else { // ワーカ http.createServer(function (req, res) { res.writeHead(200, {'Content-Type': 'text/plain'}); res.end('Hello World\n'); }).listen(1337, 'localhost'); }
ワーカとして実行される部分のコードは,先ほどの
cluster
を使わない例と全く同じですが,http.Server
の listen()
メソッドの動作が変わります.ワーカとして実行されているプロセスでは,http.Server
の listen()
メソッドはマスタに「"localhost"
に 1337
番でバインドされたソケットをよこせ」という要求 (プロセス間通信) を行います.そこでマスタは socket()
, bind()
を呼び出してソケットを作成し,ワーカに転送します.ワーカはそのソケットに対して listen()
, accept()
を実行します.別のワーカが同様に「
"localhost"
に 1337
番でバインドされたソケットをよこせ」とマスタに要求すると,マスタは先ほど作成したソケットを転送します.これにより,複数のワーカ間でリスニングソケットが共有されます.このように,Node.js の cluster モジュールを使うと,従来とほぼ同じコードで複数プロセスによる負荷分散を効率よく実現することができます.皆さんも是非
cluster
モジュールを使ってみてください.なお,@hoakobera さんによる「Node.js の Cluster のベンチマークをとってみた」も併せてどうぞ.