WebSocketで100万同時接続を処理する方法:C1M問題へのディープダイブ
「C10K問題」はもはや過去の話です。今日、私たちが直面しているのは、単一サーバーで100万の同時接続を処理するC1M問題です。
チャットアプリ、ライブスポーツ配信、あるいは共同編集ツールのようなリアルタイムアプリケーションを構築していると、いずれ壁にぶつかります。サーバーがクラッシュし、新しい接続がタイムアウトし、レイテンシが急上昇するのです。そして、その原因がアプリケーションコードにあることは稀です。犯人は通常、オペレーティングシステム(OS)の設定にあります。
この記事では、Linuxサーバーをチューニングして100万のWebSocket同時接続を処理するための具体的な手順を解説します。ファイルディスクリプタ、エフェメラルポート(Ephemeral Ports)、そして単一ノードを超えてスケールするためのアーキテクチャまで、深く掘り下げていきましょう。
ボトルネックは(通常)Node.jsではありません
多くの開発者は、Node.js(またはPython/Ruby)が遅すぎて数百万の接続を処理できないと考えがちです。確かにシングルスレッドの限界はありますが、最初に直面するボトルネックはほぼ間違いなくオペレーティングシステムです。
基本的に、Linuxは数百万の持続的なTCP接続を維持するためではなく、一般的なコンピューティング目的のために設定されているからです。
1. "Too Many Open Files" エラー
Linuxでは、すべてがファイルです。ソケットもファイルですし、ディスク上のファイルもファイルです。プロセスが接続を開くたびに、ファイルディスクリプタ(FD)を1つ消費します。
現在の制限を確認してみてください:
ulimit -n # 出力: 1024 (通常はこれです)
もし1,025番目の接続を開こうとすると、アプリケーションは EMFILE: too many open files エラーを吐いてクラッシュするでしょう。
解決策:
システム全体の制限と、プロセスごとの制限の両方を増やす必要があります。
/etc/sysctl.conf を編集します:
fs.file-max = 2097152
/etc/security/limits.conf を編集します:
* soft nofile 1048576 * hard nofile 1048576 root soft nofile 1048576 root hard nofile 1048576
sysctl -p コマンドで変更を適用してください。これでOSは十分なファイルディスクリプタを許可するようになります。しかし、これは始まりに過ぎません。
2. エフェメラルポート枯渇(Ephemeral Port Exhaustion)の罠
これは、大規模なWebSocketアーキテクチャにおいて最も一般的な「静かなる殺人者」です。
クライアントがサーバーに接続するとき、ソースIPとソースポートを使用します。逆に、あなたのサーバーがバックエンドのデータベースや他のサービスに接続するときは、サーバーがクライアントとなり、ローカルポートを使用します。
利用可能なポートは65,535個しかありません。もしWebSocketサーバーの手前にロードバランサーやプロキシがある場合、外向きの接続のためのポートが不足する可能性があります。
TCPタプル(Tuple)
TCP接続は4つの情報で識別されます:
{ソースIP, ソースポート, 宛先IP, 宛先ポート}
ロードバランサーがWebSocketサーバーに接続するとき、両方のサーバーが特定のIPに固定されている場合、利用可能なソースポート数(約6万個)によって制限を受けることになります。
解決策:
-
ローカルポートの範囲を広げる:
# /etc/sysctl.conf net.ipv4.ip_local_port_range = 1024 65535 -
TCP再利用を有効にする:
net.ipv4.tcp_tw_reuse = 1この設定により、OSは
TIME_WAIT状態のポートをより早く再利用できるようになります。
3. メモリ使用量:ソケットの本当のコスト
100万の接続はどれくらいのRAMを消費するのでしょうか?
過去には、接続1つあたり数キロバイトのカーネルメモリを消費していましたが、最新のカーネルは非常に効率的です。むしろ、アプリケーションメモリの方が大きな要因となります。
Node.jsを使用している場合、すべての Socket オブジェクトはヒープ領域を占有します。
- 空のソケット: ~2-4 KB
- メタデータ込み(User ID, チャンネル情報など): ~10 KB
計算してみましょう:
1,000,000 接続 * 10 KB = 10 GB RAM。
最新のサーバーであれば十分に可能な数値ですが、注意が必要です。
最適化のヒント:
ソケットオブジェクトにUserオブジェクト全体を保存しないでください。userId だけを保存し、詳細は必要なときにRedisから取得するようにしましょう。
// 悪い例 socket.user = { id: 1, name: "Alice", email: "...", bio: "..." }; // 良い例 socket.userId = 1;
4. イベントループの遅延(Event Loop Lag)
Node.jsはシングルスレッドです。100万人のユーザーが接続されている状態で、その全員にメッセージをブロードキャストしようとすると、イベントループがブロックされます。
// 絶対にやってはいけません users.forEach(user => { user.socket.send("こんにちは!"); });
100万個のパケットを送信するには時間がかかります。1送信あたり0.01msかかるとすると、合計で10秒間のブロッキングが発生します。サーバーは10秒間応答しなくなります。
解決策:バッチ処理(Batching)とワーカー(Workers)
- 一度に全員に送信しないでください。 ブロードキャストをチャンク(chunk)単位に分割して送信しましょう。
- マルチプロセスを使用してください。 Node.jsの
clusterモジュールを使用するか、複数のコンテナインスタンスを実行してください。
5. 無限スケールのためのアーキテクチャ
単一サーバーのチューニングで100万接続は可能です。しかし、1,000万、1億接続はどうでしょうか?
分散WebSocketアーキテクチャが必要です。
レイヤー化アプローチ(The Layered Approach)
- ロードバランサー(Nginx/HAProxy): SSLを終端し、バックエンドノードに接続を分散します。
- WebSocketノード(Node.js/Go): アクティブな接続を維持します。ソケットを保持している点では「Stateful」ですが、ビジネスロジックの観点からは「Stateless」に保つべきです。
- Pub/Subレイヤー(Redis/NATS): すべてをつなぐ接着剤の役割を果たします。
Pub/Subの仕組み
サーバー1に接続されたユーザーAが、サーバー2に接続されたユーザーBにメッセージを送りたい場合:
- ユーザーAがサーバー1にメッセージを送信します。
- サーバー1はRedisチャンネルにメッセージを発行(Publish)します:
publish('user:B', payload)。 - サーバー2(そして他のすべてのサーバー)はRedisを購読(Subscribe)しています。
- サーバー2はメッセージを受け取り、ユーザーBがローカルに接続されているか確認します。
- サーバー2がユーザーBにメッセージを送信します。
このアーキテクチャを採用すれば、サーバーを水平方向に無制限に拡張できます。
6. 限界をテストする
本番環境でクラッシュするのを待ってはいけません。テストが必要です。
Artilleryやk6のようなツールも素晴らしいですが、大規模な同時接続をテストするには、クライアントマシンの艦隊(fleet)が必要になるかもしれません。
Tsungは、数百万の同時ユーザーをシミュレーションするのに優れたErlangベースの分散負荷テストツールです。ぜひ一度使ってみてください。
結論
100万接続を処理することは、システムエンジニアにとって勲章のようなものです。それは「npm install」の快適さを抜け出し、Linuxの内部を覗き込むことを要求します。
要約チェックリスト:
ulimitを増やす(Open Files)。sysctlをチューニングする(ポート範囲、TCP再利用)。- アプリケーションメモリを最適化する(オブジェクトではなくIDを保存)。
- 水平スケールのためにPub/Subバックエンド(Redis)を使用する。
次にトラフィックの急増でサーバーが落ちたときは、インスタンスサイズを上げるだけでなく、カーネルを覗いてみてください。答えは通常そこにあります。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう