ClusterとWorker Threadsを使用したNode.jsアプリケーションの並列スケーリング
Lukas Schneider
DevOps Engineer · Leapcell

Node.jsは、シングルスレッド、イベント駆動型アーキテクチャにより、高い同時実行性とI/Oバウンドな操作の処理に優れています。しかし、CPUバウンドなタスクを扱う場合や、リクエストの絶対量が単一プロセスを圧倒する場合、この性質自体がボトルネックとなることがあります。そのようなシナリオでは、Node.jsアプリケーションを効果的にスケーリングする能力が不可欠になります。この記事では、Node.jsアプリケーションを垂直にスケーリングするための2つの主要なパターン、すなわちclusterモジュールを使用したマルチプロセスとworker_threadsを使用したマルチスレッドについて掘り下げます。これらのパターンを理解することは、最新のマルチコアプロセッサを最大限に活用できる、堅牢で高性能なNode.jsサービスを構築するために不可欠です。各アプローチが単一のNode.jsプロセスの制限をどのように克服するかを探り、開発者が特定のニーズに最も適した戦略を選択できるようにします。
Node.jsにおける並行処理の理解
スケーリングパターンに入る前に、いくつかの基本的な概念を明確にしましょう。
- プロセス: 独自のメモリ空間とリソースを持つ独立した実行環境。Node.jsでは、典型的なアプリケーションは単一のプロセスとして実行されます。
- スレッド: プロセス内での実行シーケンス。単一のプロセス内に複数のスレッドが存在し、そのメモリ空間を共有できます(ただし、Node.jsのメインスレッドはJavaScript実行のためにシングルスレッドです)。
- CPUバウンドタスク: 複雑な計算、データ圧縮、画像処理など、大量のCPU時間を消費する操作。これらは、シングルスレッド環境でイベントループをブロックする可能性があります。
- I/Oバウンドタスク: ネットワークリクエスト、データベースクエリ、ファイルシステムアクセスなど、外部リソースを待機するのにほとんどの時間を費やす操作。Node.jsの非同期性質は、単一スレッドでこれらをうまく処理します。
- イベントループ: Node.jsの非同期、ノンブロッキングI/Oの中核。実行するタスクや実行するコールバックを継続的にチェックします。長時間実行されるCPUバウンドタスクは、イベントループを「ブロック」し、アプリケーションの応答性を低下させる可能性があります。
マルチプロセスClusterモジュールによるスケーリング
clusterモジュールを使用すると、同じサーバーポートを共有する子プロセス(ワーカー)を作成できます。これにより、着信リクエストを複数のNode.jsプロセスに効果的に分散でき、アプリケーションが利用可能なすべてのCPUコアを活用できるようになります。
仕組み
clusterモジュールは、マスター・ワーカーモデルで動作します。
- マスタープロセス: このプロセスは、ワーカープロセスの生成と管理を担当します。通常、単一のポートでリッスンし、着信接続をワーカーに委任します。
- ワーカープロセス: これらは、マスターと同じポートを共有し、実際のクライアントリクエストを処理する独立したNode.jsプロセスです。各ワーカーは、独自のイベントループとメモリ空間を含む、アプリケーションコードの個別のインスタンスを実行します。
実装例
CPU負荷の高い操作を実行するシンプルなHTTPサーバーで例を示しましょう。
server.js(マスター/ワーカーコード):
const cluster = require('cluster'); const http = require('http'); const numCPUs = require('os').cpus().length; if (cluster.isMaster) { console.log(`Master ${process.pid} is running`); // ワーカーをフォークします。 for (let i = 0; i < numCPUs; i++) { cluster.fork(); } cluster.on('exit', (worker, code, signal) => { console.log(`Worker ${worker.process.pid} died`); // オプションで、高可用性を確保するためにここでワーカーを再生成できます // cluster.fork(); }); } else { // ワーカープロセスは任意のTCP接続を共有できます。 // この場合、HTTPサーバーです。 http.createServer((req, res) => { // CPUバウンドなタスクをシミュレートします if (req.url === '/cpu') { let sum = 0; for (let i = 0; i < 1e9; i++) { sum += i; } res.writeHead(200); res.end(`Hello from Worker ${process.pid}! Sum: ${sum}\n`); } else { res.writeHead(200); res.end(`Hello from Worker ${process.pid}!\n`); } }).listen(8000); console.log(`Worker ${process.pid} started`); }
これを実行するには、server.jsとして保存し、node server.jsを実行します。http://localhost:8000にアクセスすると、異なるワーカーPIDによって処理されるリクエストが表示されます。http://localhost:8000/cpuにヒットすると、1つのワーカーがビジーになりますが、他のワーカーは引き続きリクエストを処理できます。
ユースケース
- HTTPリクエストの分散: REST APIやWebサーバー、特に।
- CPU利用率の最大化: 一般的なアプリケーションのために、複数のコアにワークロードを分散します。
- 耐障害性の向上: 1つのワーカーがクラッシュしても、他のワーカーはリクエストの提供を続けることができます。
長所と短所
長所:
- 完全なCPU利用: 利用可能なすべてのコアを活用します。
- 分離: 各ワーカーは独自のメモリ空間を持つため、1つのワーカーの問題が他のワーカーに直接影響を与えることを防ぎます。
- スループットの向上: より多くの同時リクエストを処理できます。
短所:
- プロセス間通信(IPC)のオーバーヘッド: ワーカー間でデータを共有するには明示的なIPCが必要であり、共有メモリよりも遅くなる可能性があります。
- 状態管理の複雑さ: 各ワーカーは分離されているため、グローバルなアプリケーション状態には慎重な同期または外部ストレージ(例: Redis)が必要です。
- メモリ消費量の増加: 各ワーカーは完全なNode.jsプロセスであるため、RAM使用量が増加します。
マルチスレッドWorker Threadsモジュールによるスケーリング
Node.js v10.5.0で導入され、v12.x以降で安定したworker_threadsモジュールは、単一のNode.jsプロセス内で複数のJavaScriptスレッドを実行することを可能にします。clusterモジュールとは異なり、ワーカー・スレッドは同じプロセスメモリを共有します(JavaScript実行のために分離されたV8インスタンスがありますが)、メッセージパッシングを介して通信します。
仕組み
- メインスレッド: これはプライマリNode.js実行スレッドです。新しいワーカー・スレッドを生成できます。
- ワーカー・スレッド: これらは、分離されたV8エンジンのインスタンスと独自のイベントループを実行する別個のスレッドです。メインスレッドと並行してJavaScriptコードを実行できます。メインスレッドとワーカー・スレッド間の通信は、メッセージパッシングメカニズム、または
SharedArrayBufferオブジェクトを共有することによって行われます。
実装例
CPUバウンドなタスクをworker_threadsを使用してリファクタリングしてみましょう。
main.js(メインスレッド):
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads'); const http = require('http'); if (isMainThread) { console.log(`Main thread ${process.pid} is running`); http.createServer((req, res) => { if (req.url === '/cpu') { const worker = new Worker('./worker-task.js', { workerData: { num: 1e9 } // ワーカーに渡すデータ }); worker.on('message', (result) => { res.writeHead(200); res.end(`Hello from Main Thread! Sum: ${result}\n`); }); worker.on('error', (err) => { console.error(err); res.writeHead(500); res.end('Error processing request.\n'); }); worker.on('exit', (code) => { if (code !== 0) console.error(`Worker stopped with exit code ${code}`); }); } else { res.writeHead(200); res.end('Hello from Main Thread (non-CPU path)!\n'); } }).listen(8001); console.log('Server listening on port 8001'); }
worker-task.js(ワーカー・スレッド):
const { parentPort, workerData } = require('worker_threads'); if (parentPort) { // ワーカー・スレッドのコンテキストであることを確認します let sum = 0; for (let i = 0; i < workerData.num; i++) { sum += i; } parentPort.postMessage(sum); }
これを実行するには、main.jsとworker-task.jsを同じディレクトリに保存し、node main.jsを実行します。http://localhost:8001にアクセスします。http://localhost:8001/cpuにヒットすると、計算はワーカー・スレッドにオフロードされ、メイン・スレッドは他のリクエストを処理するために自由になります。
ユースケース
- 単一プロセスでのCPUバウンドタスク: 画像リサイズ、ビデオエンコーディング、データ処理、暗号化操作、またはイベントループをブロックする可能性のある重いデータ操作など、計算に最適です。
- メインスレッドの応答性の維持: デスクトップアプリケーション(例: Electron)でのUI応答性を保証したり、重要なAPIパスでの遅延を防いだりします。
- ノンブロッキングタスクの並列実行: Node.jsはノンブロッキングI/Oに優れていますが、
worker_threadsは、完了順序が重要でなく、全体的な実行時間を短縮したい場合に、複数の独立したI/O操作の並列化に使用できます。
長所と短所
長所:
- イベントループのブロック回避: メインスレッドからCPU負荷の高い作業をオフロードします。
- メモリオーバーヘッドの低減(
clusterと比較して): ワーカーは一部のプロセスリソースを共有するため、完全に分離されたプロセスよりもメモリ消費量が少なくなります。 - 効率的なメッセージパッシング:
postMessageを介した通信は、個別のプロセス間でのIPCよりも一般的に効率的です。 - 共有メモリ(
SharedArrayBuffer経由): スレッドが共有メモリを直接操作する必要がある高度なユースケースを可能にしますが、これには慎重な同期が必要です。
短所:
- HTTPリクエストの分散には直接的でない:
worker_threadsは直接ポートでリッスンするように設計されていません(ワーカーは可能ですが)、clusterのように自動的にロードバランシングしません。通常、リクエストを処理し作業をオフロードするメインスレッドが必要です。 - 同時実行バグの可能性: 共有メモリ(
SharedArrayBuffer経由)は、アトミック操作とロックを注意深く扱わないと、競合状態やデッドロックなどの複雑さを伴います。 - ワーカーごとに依然としてシングルスレッドJavaScript実行: 各ワーカーはJavaScriptを順番に実行します。並行性は、単一ワーカー内でのJavaScriptの並列実行からではなく、複数のワーカーが並行して実行されることから生まれます。
結論
clusterとworker_threadsはどちらもNode.jsアプリケーションを垂直にスケーリングするための強力なメカニズムを提供しますが、それらは異なる主な目的を果たします。clusterモジュールは、複数のプロセスに着信ネットワークリクエストを分散するのに理想的であり、I/Oバウンドまたは汎用WebサーバーワークロードのためにすべてのCPUコアを効果的に活用します。逆に、worker_threadsは、単一のNode.jsプロセス内でCPUバウンドな計算をメインイベントループからオフロードするのに優れており、応答性と持続的なパフォーマンスを保証します。堅牢なNode.jsアプリケーションは、全体的なリクエスト分散のためにclusterを使用し、その後、各ワーカープロセス内で特定のCPU負荷の高い計算のためにworker_threadsを雇用するハイブリッドアプローチさえも活用するかもしれません。これらのパターンのいずれかを選択するか、それらを組み合わせるかは、結局のところ、アプリケーションの特定のパフォーマンスボトルネックとアーキテクチャ要件に依存します。

