【ITニュース解説】Service discovery and load balancing in Node.js — without Consul or Kubernetes
2026年09月29日に「Dev.to」が公開したITニュース「Service discovery and load balancing in Node.js — without Consul or Kubernetes」について初心者にもわかりやすく解説しています。
ITニュース概要
Node.jsの分散システムでサービスを連携・負荷分散する際、ConsulやKubernetesのような外部ツールを使わず、フレームワーク内で完結する方法が紹介された。Moleculerフレームワークがサービス情報を共有し、直接通信することで、外部インフラの運用負荷を軽減。サービスの起動・停止・障害にも柔軟に対応し、様々なルーティング戦略で効率的なシステム構築が可能となる。
ITニュース解説
現代のソフトウェア開発では、モノリシックなシステム(すべての機能が一つにまとまった大きなプログラム)から、マイクロサービスと呼ばれる小さな独立したサービスを複数組み合わせる方式へと移行が進んでいる。このマイクロサービスアーキテクチャでは、同じサービスが複数のサーバーやマシンで動作することが一般的である。すると、「特定のサービスが現在どのマシンで動いているのか(サービスディスカバリ)」という問題と、「複数の稼働中のサービスインスタンスの中から、どのインスタンスにリクエストを送るべきか(ロードバランシング)」という二つの新たな課題が生まれる。
これまでの一般的な解決策としては、Consulやetcdのような専用のレジストリサービス、KubernetesのServiceオブジェクト、NginxやHAProxyといったプロキシサーバーなど、インフラストラクチャレベルのツールを利用することが主流であった。これらのツールは確かに機能するが、それぞれが独立したコンポーネントであり、セットアップ、運用、監視、アップグレードといった追加の管理コストが発生する。また、これらのインフラツールはIPアドレスやポート番号しか認識せず、「注文作成サービス」といったアプリケーション固有の情報を理解しないため、より高度な制御が難しいという側面もあった。
今回紹介するアプローチは、サービスディスカバリとロードバランシングの機能を、アプリケーションのフレームワーク自体に組み込むというものだ。Node.js向けのMoleculerというフレームワークでは、この方式が2017年から採用されている。この方法では、専用のレジストリサーバーやサイドカー(アプリケーションの横で動く補助プログラム)を別途用意する必要がなく、リクエストを処理するための余分なネットワークホップ(通信経路の追加ステップ)も発生しない。
Moleculerの仕組みは比較的シンプルである。各Node.jsプロセスは「サービスブローカー」と呼ばれるMoleculerの実行環境を内包して動作し、それぞれが固有のノードIDを持つ。ブローカーが起動すると、自身が提供するサービス、アクション(実行可能な関数)、イベント、そして自由な形式のメタデータ(付加情報)を記述した「INFOパケット」を「トランスポーター」と呼ばれるメッセージバス(NATS、Redis、Kafka、あるいは単純なTCPなど)を通じて公開する。ネットワーク上の他のすべてのブローカーはこれを受信し、自身のローカルなサービスカタログ(利用可能なサービスの一覧)を更新する。
各ブローカーは数秒おきに「ハートビート」と呼ばれる生存信号を送信し、他のノードに自身の生存を伝える。もしあるノードからのハートビートが途絶えると、そのノードは利用不可とマークされる。また、ノードが正常にシャットダウンする際には「DISCONNECTパケット」を送信し、他のノードが無駄にタイムアウトを待つ必要がないように伝える。
アプリケーションコードからサービスを呼び出す際、例えばbroker.call("greeter.hello", …)のように命令すると、呼び出し元のブローカーのローカルレジストリが、あらかじめ設定されたロードバランシング戦略(後述)に基づいて、現在稼働している適切なサービスインスタンスを一つ選択する。そして、選択されたノードに対して直接リクエストを送信する。これは「クライアントサイドディスカバリ」と「クライアントサイドロードバランシング」と呼ばれ、サービスカタログはメッセージバスを介したノード間の情報交換(ゴシッププロトコル)によって最終的に一貫性が保たれる。
具体的な例で見てみよう。仮に「greeter」という、どのノードが応答したかを返すだけの単純なサービスを用意し、それを複数のNode.jsプロセスで実行する。最初は特別な設定なしに3つのワーカー(サービスインスタンス)を起動し、クライアントから連続して呼び出すと、リクエストはそれぞれのワーカーに順番に(ラウンドロビン方式で)均等に分散されることがわかる。誰も明示的にサービスを登録したわけではなく、ワーカーが自身をメッセージバスでアナウンスしただけで、クライアントのレジストリが自動で残りの処理を行った結果である。各ノードは起動時に設定された「zone=eu」のようなメタデータも共有しており、これは後でより高度なルーティングに利用できる。
次に、稼働中のシステムでノードを動的に増減させたり、障害が発生した場合の挙動を見てみる。クライアントがサービスを呼び出し続けている最中に、1つのワーカーを正常停止(SIGTERM)させ、別のワーカーを強制終了(SIGKILL、つまりクラッシュ)させ、さらに新しいワーカーを起動するとどうなるか。
正常停止されたワーカーは、即座にDISCONNECTパケットを送信するため、他のノードのレジストリからすぐに削除され、そのノードへの失敗リクエストは一つも発生しない。これは通常のデプロイメントで最も重要な挙動である。
一方、クラッシュしたワーカーの場合、ハートビートが途絶えるまで他のノードはそれを認識できない。そのため、クラッシュからハートビートタイムアウトが検出されるまでの間は、そのノードに割り当てられたリクエストはタイムアウトエラーとなる。このタイムラグは、heartbeatTimeoutという設定値で調整可能であり、デフォルトよりも短い時間を設定することで、障害検出を早めることができる。
新しいワーカーが起動した場合は、INFOパケットを公開するとすぐに他のノードのレジストリに認識され、起動から1秒以内にはトラフィックを受け入れ始める。登録やヘルスチェックの待機期間、DNSのTTL(有効期間)といった遅延がないため、スケーリングが非常に迅速に行われる。
ロードバランシングの戦略も柔軟に設定できる。デフォルトの「RoundRobin」以外にも、「Random」(ランダムに選択)、ノードが報告するCPU負荷に基づいて選択する「CpuUsage」、各ノードへの応答時間(レイテンシ)に基づいて選択する「Latency」(マルチリージョン環境で有効)、そしてリクエスト内の特定のフィールドに基づいて一貫したハッシュ計算を行い、同じキーが常に同じノードに送られる「Shard」(シャーディング)など、様々な組み込み戦略がある。シャーディングは、インメモリキャッシュやキーごとの順序処理を実現する際に役立つ。
さらに、独自のロードバランシング戦略をわずか十数行のコードで作成することも可能である。例えば、呼び出し元と同じゾーン(地域)にサービスインスタンスがあればそれを優先し、なければ他のゾーンのインスタンスにフォールバックするような戦略も容易に実装できる。これにより、「同じデータセンター内のインスタンスを優先する」といった一般的な要件を、サイドカーなどのインフラを使わずにアプリケーションレベルで実現できる。ノードに設定された「zone」のようなメタデータを活用して、このような賢いルーティングをネットワーク呼び出しなしで行うことができるのだ。また、preferLocal: trueという設定(デフォルトで有効)により、もし呼び出し元のノード自身が対象のサービスをホストしている場合、ネットワークを介さずにそのサービスを直接プロセス内で呼び出すことで、パフォーマンスを最大化できる。これにより、開発中はモノリシックな構成で、本番では分散システムとして同じコードを動かすことが容易になる。
さらに、メッセージブローカーさえも不要とする運用も可能である。MoleculerのTCPトランスポーターを使用する場合、ノードはUDPマルチキャストを通じて互いに発見し合い、その後は直接TCP接続で通信を行う。これにより、サービスディスカバリとロードバランシングを、レジストリサーバーもメッセージバスも一切なしで実現できる。これは小規模なデプロイメントや、オンプレミス、エッジデバイスのような特定の環境で非常に有用な選択肢となる。ただし、ほとんどのクラウドネットワークではVM間のマルチキャストがブロックされるため、クラウド環境で複数のホストを跨ぐ場合は、NATSやRedisのようなメッセージバスを利用する方が一般的である。
もちろん、Moleculerのこのアプローチにも限界はある。例えば、GoやPythonといったNode.js以外の言語で書かれたサービスを含む「Polyglot(多言語)システム」の場合、MoleculerのレジストリはNode.jsプロセス内に存在するため、GoやPythonのサービスは直接参加できない。このような場合は、やはりConsulのような外部の共通レジストリが「共通言語」となるだろう。 また、すでにKubernetesを使用している場合は、Kubernetesをそのまま利用し続けることが推奨される。Kubernetesはスケジューリング、リスタート、シークレット管理、イングレス(外部からのアクセス管理)などに優れており、Moleculerのフレームワーク内レジストリと競合するものではない。むしろ、KubernetesがどのPod(コンテナの最小単位)を稼働させるかを決定し、Moleculerのブローカーレジストリが、KubernetesのServiceオブジェクトでは持たないアプリケーションレベルの知識(バージョン情報、メタデータ、アクションごとの戦略など)に基づいて、どのPodのどのサービスに呼び出しを送るかを決定するという組み合わせは、多くのチームで採用されている。これにより、同じコードがKubernetes上でも、単一のVM上でも柔軟に動作する。 非常に大規模なクラスター、具体的には数百以上のノードで構成されるシステムの場合、すべてのノードがすべてのハートビートをリッスンする現在のゴシッププロトコルではO(N²)の通信量が発生し、チャッター(不要な通信)が問題になる可能性がある。この場合は、設定を一行変更するだけで、ディスカバラーをデフォルトのゴシップ方式からRedisやetcdをバックエンドとする共有ストア方式に切り替えることができ、それ以外の部分は変更なく利用できる。
まとめると、例えば5〜50程度のサービスが数台のマシン上で動作するようなNode.jsシステム(これはほとんどのシステムに当てはまる)の場合、Moleculerフレームワーク内のレジストリがサービスディスカバリ、ロードバランシング、ゾーンアフィニティ(地域優先)、シャーディング、ノードヘルス管理といった幅広い機能をカバーする。これにより、必要なインフラはNATSコンテナ一つで済むか、あるいは最もシンプルな構成であれば何もいらない、という非常に効率的なシステム運用が可能になるのだ。