このレッスンのロックを解除するには、まず「Socket.IOのルーム・名前空間・自動再接続の活用」を完了してください。前のレッスンに戻る

単一サーバーの問題点

すべてのWebSocket接続は、1つのNode.jsプロセスのメモリ内に存在します。単一サーバーで運用する場合、全クライアントはそのサーバーに接続するため、メモリ内の単純なループでメッセージをブロードキャストできます。ロードバランサーの背後に2台目のインスタンスを起動した瞬間、クライアントはプロセス間に分散されます。サーバーAに到着したメッセージは、サーバーBに接続中のクライアントには決して届きません。2つのプロセスはメモリを共有していないためです。

したがって、水平スケーリングにはサーバー間の通信手段が必要です。Redis Pub/Subは最も軽量な選択肢です。各サーバーが一連のチャンネルを購読し、1つのサーバーがパブリッシュすると、Redisがメッセージをすべての購読者にファンアウトします。

ロードバランサーの背後にある2つのNode.jsサーバー、それぞれが独自のクライアントを保持し、間にRedisをpub/subバスとして配置した図

Redis Adapterの接続

`socket.io-redis` adapterはSocket.IOの組み込みadapter APIに接続し、デフォルトのメモリ内ブロードキャストをRedis Pub/Subに置き換えます。全インスタンスにインストールします。

bash
npm install socket.io redis socket.io-redis

次に、サーバーのエントリーポイントで、Redisクライアントにバインドされたadapterを作成し、Socket.IOサーバーにアタッチします。

javascript
const http = require('http');
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
const httpServer = http.createServer();
const io = new Server(httpServer);
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
io.adapter(createAdapter(pubClient, subClient));
io.listen(3000);
});

アタッチが完了すると、`io.emit`、`io.to(room).emit`、`socket.broadcast.emit`のすべての呼び出しがRedisを経由します。同じRedisインスタンスを購読しているサーバーは、どのサーバーが発信元であっても、自身の接続済みソケットにイベントを配信します。

インスタンス間ブロードキャスト

adapterが設定されれば、ブロードキャストコードは変更不要です。1つのサーバーで動作するチャットルームハンドラーは、10台のサーバーでもそのまま動作します。

javascript
io.on('connection', (socket) => {
socket.on('join', (room) => {
socket.join(room);
});
socket.on('message', (room, text) => {
io.to(room).emit('message', {
user: socket.data.username,
text,
time: Date.now()
});
});
});

サーバー1のクライアントAが`message`を発行すると、Socket.IOはイベントをシリアライズし、ルームスコープのチャンネルでRedisにパブリッシュします。他のすべてのサーバーがこれを受け取り、そのルーム内のローカルソケットにファンアウトします。クライアントから見ると、チャットはリアルタイムかつグローバルです。

サーバー1で発生したメッセージがRedis Pub/Subを通過し、サーバー2とサーバー3にファンアウトされ、それぞれがローカルクライアントに配信するフロー図

2ノードを超えるAdapterのスケーリング

上記のパターンはリニアにスケールします。ノード数が増えても健全に保つための運用ルールがいくつかあります。

第一に、Redisを適切な永続化設定で運用してください。Pub/Subメッセージは保存されませんが、adapterはハンドシェイク状態にRedisキーも使用します。クラッシュ時のフラッシュはクライアントの再接続を強制しますが、チャット用途では許容範囲ですがステートフルなワークロードには適しません。

第二に、ロードバランサーにWebSocketアップグレードのみを対象としたSticky Sessionを設定するか、再接続のコストを許容してください。Sticky Sessionがなければ、再接続したクライアントが別のサーバーに振り分けられ、ルームに再参加する必要が生じます。Socket.IOはルームメンバーシップがサーバーローカルであるためこれを透過的に処理しますが、ハンドシェイク中、クライアントはわずかにイベントを取り逃します。

第三に、Redis Pub/Subのチャンネル数と購読者数を監視してください。Socket.IOの各namespaceはチャンネルを追加するため、複数のnamespaceを持つ単一のデプロイメントではチャンネル数がその分増幅されます。

最後に、Redisがボトルネックになった場合は、Redis Cluster全体にPub/Subをシャーディングできます。`@socket.io/redis-adapter`パッケージは、複数の接続オプションを受け入れることでクラスターモードをそのままサポートしています。

よくある落とし穴

よくある間違いは、両方のRedisクライアントが接続完了する前にadapterを作成することです。未接続のクライアントでパブリッシュするとエラーが発生するか、メッセージがサイレントに破棄されます。必ず接続呼び出しを`await`するか、上記のようにreadyコールバックでラップしてください。

もう一つの間違いは、パブリッシュとサブスクライブの両方に単一のRedisクライアントを使用することです。Redisは、サブスクライブモードのクライアントは通常のコマンドを実行できないため、専用の購読者接続が必要です。例の`pubClient.duplicate()`呼び出しが、その2番目の接続を作成します。

より微妙な問題は、サーバー間ロジックに`io.local`を頼ることです。`io.local.emit`は現在のサーバーでのみ起動し、Redisをバイパスします。これはインスタンスごとの診断には便利ですが、メッセージが全ノードに到達したと仮定した瞬間に破綻します。全クライアントに到達させる必要があるものには、プレーンな`io.emit`を使用してください。

まとめ

WebSocketサーバーの水平スケーリングは2つの要素に帰着します。N個のNode.jsインスタンスの前に立つロードバランサーと、それらの間のメッセージバスとしてのRedis Pub/Subです。`socket.io-redis` adapterがバスをラップするため、アプリケーションコードは変更不要です。ルーム、ブロードキャスト、namespaceはすべて、1台の場合と同じようにインスタンス間で動作します。主な運用上の懸念事項は、Redisの可用性、WebSocketアップグレード用のSticky Session、そして`io.local.emit`と`io.emit`を混同しないことです。

レッスンの練習

レッスンのチェックポイント

学んだ内容を復習し、回答へのフィードバックを受けましょう。

0/1
回答済み · 0/1
Redis Pub/Sub を使ったWebSocketサーバーの水平スケーリング