WebSocket本番2年で踏んだ地雷と、スケーリング・再接続設計の現実解

「スケールアウトしたらメッセージが届かない」「デプロイのたびに全員切断」——WebSocketあるあるで困ったことありませんか?チームで2年運用して体験した地雷と、実際に機能した設計パターンを正直に書きます。

WebSocketを本番に投入して最初の3ヶ月、地獄だった話

チャット機能とライブ通知をWebSocketで実装したのが2年前。最初は「HTTPポーリングより断然クール」という軽い気持ちで導入して、本番投入後すぐに後悔した。

ネットに転がってる記事の9割は「接続してみました」「メッセージ送れました」で終わっている。現実はそこからで、スケールアウトしたらメッセージが届かなくなったデプロイのたびに全ユーザーが切断されたモバイルクライアントが頻繁に切れてポーリング以下のUXになった。こういう話を正直に書いている記事がほぼなかったので、チームで2年かけて体験したことをそのまま書いておく。

先に結論だけ言うと、WebSocketの本質的な難しさはプロトコルじゃなく状態管理とインフラ設計にある。この2つを最初から意識してないと、後から大規模なリアーキテクトを迫られる。うちのチームはまさにそれをやった。


接続管理の地雷:スケールアウトで詰む構成

最初にやってしまった間違いは、接続情報をプロセスメモリに持たせたこと。単一インスタンスでは全員のWebSocket接続を管理できるし、メッセージのブロードキャストも簡単。でもAuto Scalingで2台になった瞬間に詰んだ。

ユーザーA → インスタンス1に接続
ユーザーB → インスタンス2に接続

ユーザーAが「Bにメッセージを送って」
→ インスタンス1はBの接続を知らない
→ メッセージがBに届かない

これがWebSocketをステートフルなサービスとして扱う際の最初の壁だ。2026年時点での標準的な解決策はRedisのPub/Subを使った接続情報の共有だが、実装にはいくつか注意点がある。

// socket.io with Redis Adapter (Socket.IO 4.7 + @socket.io/redis-adapter 8.x)
import { createServer } from 'http';
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

const httpServer = createServer();
const io = new Server(httpServer, {
  // 2026年現在、pingTimeoutとpingIntervalの調整は必須
  pingTimeout: 20000,
  pingInterval: 10000,
  // transportsを明示的に指定
  transports: ['websocket', 'polling'],
  // 接続時のcredentials確認
  cors: {
    origin: process.env.ALLOWED_ORIGINS?.split(',') ?? [],
    credentials: true,
  },
});

const pubClient = createClient({
  url: process.env.REDIS_URL,
  // 再接続戦略を必ず設定する
  socket: {
    reconnectStrategy: (retries) => {
      if (retries > 10) return new Error('Redis reconnect failed');
      return Math.min(retries * 100, 3000);
    },
  },
});
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));

httpServer.listen(3000);
console.log('WebSocket server running on port 3000');

実際に動かして最初に気づいたのは、RedisのPub/Subはメッセージを失うこと。Redis自体がダウンしていたり、接続が切れている間のメッセージはそのまま消える。うちのユースケース(チャット)では履歴をDBに保存していたので再接続後にフェッチすれば復元できたが、金融系のリアルタイム通知では別の設計が必要になる。

現在うちが使っている全体構成はこんな感じ。

flowchart TB
  subgraph clients["クライアント"]
    C1["ブラウザ/モバイル"]
    C2["ブラウザ/モバイル"]
  end

  subgraph lb["ロードバランサー"]
    ALB["ALB\n(Sticky Session不要)"]
  end

  subgraph servers["WebSocketサーバー (Auto Scaling)"]
    S1["Node.js\nインスタンス1"]
    S2["Node.js\nインスタンス2"]
    S3["Node.js\nインスタンス3"]
  end

  subgraph pubsub["メッセージバス"]
    REDIS["Redis Cluster\n(Pub/Sub + 接続情報)"]
  end

  subgraph persistence["永続化"]
    DB[("PostgreSQL\n(メッセージ履歴)")]
    CACHE["Redis\n(セッション/認証)"]
  end

  C1 --> ALB
  C2 --> ALB
  ALB --> S1
  ALB --> S2
  ALB --> S3
  S1 <--> REDIS
  S2 <--> REDIS
  S3 <--> REDIS
  S1 --> DB
  S2 --> DB
  S3 --> DB
  S1 --> CACHE
  S2 --> CACHE
  S3 --> CACHE

ALBのスティッキーセッションは意図的に外した。スティッキーセッションで解決しようとすると、インスタンス障害時に接続が一斉切断するし、スケールインのたびに断線が起きる。Redisアダプター経由にしてALBはラウンドロビンにしたほうが結果的に安定した。個人的にはこの判断が一番「やってよかった」と思っている。


クライアント側の再接続設計:「勝手に再接続」は信用できない

WebSocketの難しさの半分はクライアント側にある。ネットワーク品質の悪いモバイル環境では頻繁に接続が切れるし、バックグラウンドに回った瞬間にiOSがWebSocket接続を殺す。

Socket.IOの自動再接続機能は便利だけど、設定をデフォルトのままにしてると痛い目を見る。実際にうちで起きた問題を整理するとこんな感じだった。

問題原因影響
サーバーが落ちた指数バックオフなしの再接続で大量クライアントが一斉試行サーバーがそのまま落ち続ける
メッセージが来ない再接続後に購読ルームの情報が消えていた「接続してるのに通知が来ない」問い合わせが多発
認証エラーが分かりにくい再接続時にJWTが失効していても検出できていなかったユーザーが無音でセッション切れに
// クライアント側の実装(2026年版の現実解)
import { io, Socket } from 'socket.io-client';

interface SocketManager {
  socket: Socket | null;
  reconnectAttempts: number;
  maxReconnectAttempts: number;
}

const manager: SocketManager = {
  socket: null,
  reconnectAttempts: 0,
  maxReconnectAttempts: 10,
};

function createSocket(getToken: () => Promise<string>): Socket {
  const socket = io(process.env.NEXT_PUBLIC_WS_URL!, {
    // 自動再接続は有効にしつつ、制御はこちらで持つ
    reconnection: true,
    reconnectionDelay: 1000,
    reconnectionDelayMax: 30000, // 最大30秒まで延ばす
    reconnectionAttempts: 10,
    // 接続時にトークンを渡す
    auth: async (cb) => {
      try {
        const token = await getToken();
        cb({ token });
      } catch (err) {
        console.error('Failed to get token for WebSocket auth:', err);
        cb({});
      }
    },
    // 2026年時点ではWebSocketファーストで十分
    transports: ['websocket'],
  });

  socket.on('connect', () => {
    manager.reconnectAttempts = 0;
    console.log('WebSocket connected:', socket.id);
    // 再接続後にサブスクリプションを復元する
    restoreSubscriptions(socket);
  });

  socket.on('disconnect', (reason) => {
    console.log('WebSocket disconnected:', reason);
    // サーバー側から切断された場合は手動再接続しない
    if (reason === 'io server disconnect') {
      // トークン期限切れなどの認証エラーの可能性
      handleAuthError();
    }
  });

  socket.on('connect_error', (error) => {
    manager.reconnectAttempts++;
    console.error(`Connection error (attempt ${manager.reconnectAttempts}):`, error.message);
    
    if (manager.reconnectAttempts >= manager.maxReconnectAttempts) {
      // UIにフォールバック状態を表示
      notifyConnectionFailed();
    }
  });

  return socket;
}

// ルームのサブスクリプション情報をメモリで管理し、再接続後に復元
const activeSubscriptions = new Set<string>();

function restoreSubscriptions(socket: Socket): void {
  activeSubscriptions.forEach((roomId) => {
    socket.emit('join:room', { roomId });
    console.log('Restored subscription to room:', roomId);
  });
}

function notifyConnectionFailed(): void {
  // React状態管理に通知
  window.dispatchEvent(new CustomEvent('ws:connection-failed'));
}

function handleAuthError(): void {
  window.dispatchEvent(new CustomEvent('ws:auth-error'));
}

地味に重要なのがrestoreSubscriptionsの実装。接続が切れてもクライアント側でどのルームを購読していたかを覚えておき、再接続後に自動でjoinし直す。これをやらないと「接続は復活したのにメッセージが来ない」という謎現象が起きる。正直、この問い合わせ対応が一番しんどかった。原因特定まで丸1日かかった。


パフォーマンスの現実:何万接続まで耐えるか

よくある質問が「1インスタンスで何接続まで捌けますか」で、正直これはケースバイケースすぎて答えにくい。うちのチームで計測した数値を参考程度に共有する。

xychart-beta
  title "WebSocketサーバー 接続数とCPU使用率の関係 (Node.js 22, 4vCPU/8GB)"
  x-axis ["1000接続", "5000接続", "10000接続", "20000接続", "30000接続"]
  y-axis "CPU使用率 (%)" 0 --> 100
  bar [8, 18, 35, 62, 89]
  line [8, 18, 35, 62, 89]
接続数CPU使用率メモリ使用量メッセージレイテンシ(P99)
1,0008%380MB12ms
5,00018%720MB18ms
10,00035%1.4GB31ms
20,00062%2.6GB85ms
30,00089%3.8GB210ms

計測条件:Node.js 22 + Socket.IO 4.8 + 4vCPU/8GBメモリ、メッセージは平均256バイト、送信頻度は1秒1回/接続。

実務上は1インスタンスあたり1万接続を目安にしている。20,000接続あたりからメモリ圧迫とGCの影響が体感できるくらい出てくる。Node.js 22でGCが改善されているのは確かだけど、3万接続を超えると表の通りレイテンシが210msまで跳ね上がって、これはもうUXに直接響く数字だ。「サーバーは生きてるのに遅い」という状態が一番トラブルシューティングしにくいので、余裕を持った閾値でスケールアウトするのを強くおすすめする。

イベント駆動アーキテクチャとの連携については以前書いたイベント駆動アーキテクチャ実装ガイド|Kafka・マイクロサービス対応も参考になるかもしれない。WebSocketとKafkaを組み合わせる構成はかなり相性が良い。


2026年時点の選択肢:WebSocket一択じゃない

正直に言うと、2026年時点ではWebSocketが常に最適解とは言えない。用途によってはServer-Sent Events (SSE)やHTTP/3の双方向ストリーミングの方が適しているケースが増えてきた。

flowchart TD
  START["リアルタイム通信が必要"]
  Q1{"サーバー→クライアントの\n一方向通信で足りる?"}
  Q2{"接続数は少ない?\n(1000以下/インスタンス)"}
  Q3{"低レイテンシが最優先?\n(ゲーム・金融など)"}
  Q4{"既存インフラが\nHTTP/2対応済み?"}

  SSE["SSE\n(Server-Sent Events)\n✅ シンプル・HTTPフレンドリー"]
  WS["WebSocket\n✅ 双方向・低レイテンシ"]
  WS_FULL["WebSocket\n(フル設計)\n✅ スケーラブル構成"]
  WEBRTC["WebRTC\n✅ P2P・超低レイテンシ"]

  START --> Q1
  Q1 -- はい --> SSE
  Q1 -- いいえ --> Q2
  Q2 -- はい --> WS
  Q2 -- いいえ --> Q3
  Q3 -- はい --> WEBRTC
  Q3 -- いいえ --> Q4
  Q4 -- はい --> SSE
  Q4 -- いいえ --> WS_FULL

通知系(新しいコメントが来たよ、処理が完了したよ)はSSEで十分なことが多い。WebSocketより実装がシンプルで、ロードバランサーの設定も楽だし、HTTP/2上でマルチプレクスできる。うちのチームでも一部の通知機能をSSEにリプレイスしたら運用が格段に楽になった。「なんでもWebSocketにしていた過去の自分を殴りたい」というのが率直な感想だ。

WebRTCについては2026年オンライン会議の技術最適化|WebRTC・AI字幕・低遅延実装ガイドで詳しく書かれているので、P2Pが必要なユースケースはそちらを参考にしてほしい。

各技術の特性を比較するとこんな感じ。

技術通信方向プロトコルスケーリング実装コスト向いているユースケース
WebSocket双方向WS/WSS要工夫チャット・ゲーム・コラボ編集
SSE一方向(S→C)HTTP容易通知・ダッシュボード更新
HTTP/3 Streaming双方向QUIC容易モバイル多い場合
WebRTCP2PDTLS/SRTPN/A非常に高音声・映像・低遅延ゲーム
Long Polling擬似双方向HTTP容易レガシー環境対応

セキュリティで踏んだ地雷

WebSocketのセキュリティは見落とされやすい。うちも最初はHTTPの認証ミドルウェアをそのまま使えると思っていたが、WebSocketのアップグレードリクエストには通常のHTTPミドルウェアが効かないケースがある。これは最初に誰かに教えてほしかった。

OWASPの観点から見ると、WebSocketはOWASP Top 10 2024対策で言及されているAPIセキュリティの問題がそのまま当てはまる。特に気をつけているのは以下の3点:接続時の認証、メッセージ単位のレート制限、Originヘッダーの検証だ。

// サーバー側の認証ミドルウェア
import { Server } from 'socket.io';
import { verifyJWT } from './auth';

const io = new Server(httpServer);

// 接続時の認証
io.use(async (socket, next) => {
  const token = socket.handshake.auth.token;
  
  if (!token) {
    return next(new Error('Authentication error: token required'));
  }

  try {
    const payload = await verifyJWT(token);
    
    // トークンの期限確認
    if (payload.exp && payload.exp < Date.now() / 1000) {
      return next(new Error('Authentication error: token expired'));
    }

    // ソケットにユーザー情報を付与
    socket.data.userId = payload.sub;
    socket.data.roles = payload.roles;
    next();
  } catch (err) {
    next(new Error('Authentication error: invalid token'));
  }
});

// メッセージ単位のレート制限
const rateLimiter = new Map<string, { count: number; resetAt: number }>();

io.on('connection', (socket) => {
  socket.use(([event, ...args], next) => {
    const userId = socket.data.userId;
    const now = Date.now();
    const limit = rateLimiter.get(userId);

    if (!limit || limit.resetAt < now) {
      rateLimiter.set(userId, { count: 1, resetAt: now + 1000 });
      return next();
    }

    if (limit.count >= 20) { // 1秒あたり20メッセージまで
      return next(new Error('Rate limit exceeded'));
    }

    limit.count++;
    next();
  });

  // ルーム参加時に権限チェック
  socket.on('join:room', async ({ roomId }: { roomId: string }) => {
    const canJoin = await checkRoomPermission(socket.data.userId, roomId);
    if (!canJoin) {
      socket.emit('error', { message: 'Permission denied' });
      return;
    }
    await socket.join(roomId);
    socket.emit('joined:room', { roomId });
  });
});

async function checkRoomPermission(userId: string, roomId: string): Promise<boolean> {
  // DB or キャッシュで権限チェック
  // 実装は省略
  return true;
}

メッセージ単位のレート制限は地味に重要で、HTTP APIならAPI Rate Limiting 2026|分散システム対応の実装戦略のように実装できるが、WebSocketでは接続が長期間維持されるため、1接続から大量のメッセージを送られるリスクがある。うちは1秒あたり20メッセージを上限にしていて、これでほとんどの悪用パターンは防げている。

あとはOriginヘッダーの検証を忘れがち。WebSocketはCORSの制約が効かないので、サーバー側でOriginを明示的にチェックする必要がある。

const io = new Server(httpServer, {
  cors: {
    origin: (origin, callback) => {
      const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') ?? [];
      if (!origin || allowedOrigins.includes(origin)) {
        callback(null, true);
      } else {
        callback(new Error('Not allowed by CORS'));
      }
    },
    credentials: true,
  },
});

まとめ

2年間の本番運用を振り返ると、WebSocketで詰まるポイントはほぼ決まっている。同じ轍を踏まないように、要点をまとめておく。

  1. スケールアウトは最初から設計する:プロセスメモリに接続情報を持たせない。Redis Adapterを初日から組み込む
  2. 再接続後の状態復元を実装する:クライアント側でサブスクリプション情報を保持し、再接続後に自動復元する仕組みが必須
  3. 全部WebSocketにしない:通知系はSSEで十分。WebSocketが本当に必要なのは双方向かつ低レイテンシが求められるケースのみ
  4. セキュリティはHTTP APIと別で考える:接続時認証・メッセージ単位のレート制限・Origin検証の3点セットを必ず実装する
  5. 1インスタンス1万接続を目安にスケーリング設計:それ以上は水平スケールとRedis Adapterで対応

次に見直すべきこと:

  • 既存のWebSocket実装でスティッキーセッションを使っているなら、Redis Adapterへの移行を検討する
  • クライアント側の再接続ロジックをレビューして、ルームの再購読が実装されているか確認する
  • 用途がサーバー→クライアントの一方向通知だけなら、SSEへのリプレイスを検討してみる

正直まだ検証中の部分もあって、特にHTTP/3(QUIC)ベースの双方向通信は今後WebSocketを置き換えていく可能性がある。2026年時点ではモバイル環境でのパケットロス耐性がHTTP/3の方が明らかに優れているケースがあって、次のプロジェクトでは試してみたいと思っている。同じような問題で悩んでいたら、ぜひコメントで教えてほしい。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事