午前2時の悪夢:リアルタイムシステムが突如「沈黙」したとき
アラームの音が眠りを妨げます。上司からの短いメッセージ:「ユーザーの30%から通知が届かないと苦情が来ている。至急確認してくれ!」。フラッシュセールに備えてシステムを3つのインスタンスに拡張したばかりでしたが、どうやら新しいアーキテクチャに深刻な問題が発生しているようです。
ログを確認したところ、古典的なミスを発見しました。それはメモリの隔離(Memory Isolation)です。単一サーバー環境ではすべて順調に動作します。しかし、ロードバランサー配下のクラスター(Cluster)構成にすると、ソケットクライアントが完全に分断されてしまいます。ユーザーAがサーバー1に接続し、ユーザーBがサーバー2に接続している場合、サーバー1がイベントを発行(emit)しても、サーバー1はサーバー2にいるユーザーBの存在を知らないため、ユーザーBには何も届きません。
なぜSocket.IOはデフォルトで水平スケーリングできないのか?
デフォルトでは、Socket.IOはMemory Adapterを使用します。これはroomsやsidsの情報をその Node.js プロセスのRAM内に直接保存します。
- サーバー1: SocketID_1を管理。
- サーバー2: SocketID_2を管理。
サーバー1でio.emit()を呼び出すと、そのサーバーに直接接続されているクライアントにのみデータが送信されます。サーバー2のクライアントについては全く「関知」しません。これが、1台のサーバーではうまくいくのに、3台になると通知が届くかどうかが「運任せ」になってしまう理由です。この時の通知受信率は、まるで宝くじのようです。
Redis Adapter:業界標準の「救済」ソリューション
実際、この問題を解決する方法はいくつかありますが、すべてが最適というわけではありません。
- Sticky Sessions: ロードバランサーを設定して、特定のクライアントを常に同じサーバーに固定します。これは初期のハンドシェイク(handshake)問題は解決しますが、サーバー間通信を助けるものではありません。
- 独自のPub/Sub実装: RabbitMQやRedisを使用してメッセージを自前で調整することもできますが、非常に時間がかかり、潜在的なバグが発生しやすくなります。
- Socket.IO Redis Adapter: これが最良の選択肢です。ローカルRAMの代わりにRedis Pub/Subメカニズムを使用します。あるサーバーがメッセージを発行すると、それがRedisに送られ、Redisがシステム内の他のすべてのNode.jsサーバーにブロードキャスト(broadcast)します。
私は、安定性と導入の速さからRedis Adapterを選びました。これにより、無意味なデバッグ作業に何時間も費やす必要がなくなります。
実践的な実装:構成のステップ
Redisインスタンスを準備してください(時間を節約するためにDockerを使用しても構いません)。5万行のコードをリファクタリングした私の経験から言えるのは、本番環境に触れる前に必ずローカルで十分にテストすることです。
ステップ1:ライブラリのインストール
ターミナルで以下のコマンドを入力します:
npm install socket.io redis @socket.io/redis-adapter
ステップ2:サーバーの設定
以下は、最適化したコードスニペットです。重要なポイントはcreateAdapter関数にあります。
const { Server } = require("socket.io");
const { createClient } = require("redis");
const { createAdapter } = require("@socket.io/redis-adapter");
async function setupWorker() {
const io = new Server(3000);
const pubClient = createClient({ url: "redis://localhost:6379" });
const subClient = pubClient.duplicate();
// 時間短縮のために並列接続
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
io.on("connection", (socket) => {
console.log(`ユーザー ${socket.id} がプロセス ${process.pid} に接続しました`);
socket.on("send_notification", (data) => {
// Redisがすべてのサーバーにこのメッセージが届くことを保証します
io.emit("receive_notification", data);
});
});
}
setupWorker();
ステップ3:NginxでのSticky Sessionsに関する注意点
トランスポートとしてpollingを使用する場合、Sticky Sessionsを有効にすることが必須です。これがないと、新しいサーバーが古いセッションを認識できず、クライアントで400エラーが頻発します。
Nginxの設定例:
upstream nodes {
ip_hash; # クライアントを特定のサーバーに固定する
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
server {
listen 80;
location /socket.io/ {
proxy_pass http://nodes;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
}
結果の検証
テストするには、2つのターミナルを開き、ポート3000と3001で2つのインスタンスを実行します。クライアントAがポート3000にメッセージを送信したとき、ポート3001に接続しているクライアントBが即座に受信できれば成功です。メッセージがすぐに表示されれば、スケーリングは成功です。
実体験から得た教訓
トラフィックが毎秒10,000メッセージに達する場合、キャッシュ用とSocket.IO用で同じRedisインスタンスを共有しないでください。Redisはシングルスレッド(single-threaded)です。過度なブロードキャストは、他の重要なキャッシュクエリを停滞させる可能性があります。
また、Redisクライアントのerrorイベントのキャッチを忘れないでください。以前、Redisがダウンした際に例外処理がなかったため、サーバーがフリーズしてしまったことがあります。ソケットの処理フローが完全にブロックされ、システム全体が麻痺しました。
Redis Adapterの導入は、単に複数サーバーで実行するためだけのものではありません。これは、複雑なマイクロサービスアーキテクチャを自信を持って構築するための重要な基盤です。自分自身の安眠を守るために、今すぐ導入しましょう!

