目次
ローカル環境では完璧に動いていたチャット機能やリアルタイム通知が、本番にデプロイした途端「数十秒で切断される」「サーバーを2台にしたら一部のユーザーにだけ通知が届かない」といった問題を起こすことがあります。原因はアプリのコードではなく、その手前にいるNginx・ロードバランサー・CDNが、長時間つなぎっぱなしの接続を想定していないことにあります。
この記事では、WebSocketやSSE(Server-Sent Events)のような長時間接続を本番インフラで安定して動かすために必要な知識を、タイムアウト、プロキシ設定、水平スケール、再接続設計の4つに分けて解説します。WebSocketそのものの仕組みはWebSocketの解説記事にあるので、本記事では「動いているものを本番で切らさない」ことに集中します。
長時間接続がインフラで切れる理由
通常のHTTPと前提が違う
通常のHTTPリクエストは「送って、返ってきて、終わり」で、長くても数秒です。プロキシやロードバランサーは、この前提で「一定時間データが流れない接続は異常とみなして切る」アイドルタイムアウトを持っています。WebSocketやSSEは接続を開いたまま、データが来たときだけ流すため、静かな時間が続くとこのタイムアウトに引っかかって切断されます。ユーザーが操作していない画面ほど切れやすい、という現象はここから来ています。
経路上のどこで切れているかを特定する
ブラウザとアプリサーバーのあいだには、典型的にはCDN → ロードバランサー → Nginx → アプリという複数の中継点があり、それぞれが独自のタイムアウトを持ちます。切断される秒数がヒントになります。
| 中継点 | 関係する設定 | 既定値の傾向 |
|---|---|---|
| Nginx | proxy_read_timeout / proxy_send_timeout | 60秒 |
| AWS ALB | アイドルタイムアウト | 60秒(1〜4000秒で変更可) |
| Cloudflare(プロキシ経由) | オリジンからの応答待ち時間 | 100秒(プランにより変更可) |
| アプリサーバー | フレームワークのkeep-alive / ping間隔 | 実装により異なる |
「きっかり60秒で切れる」ならNginxかALB、「100秒前後」ならCloudflareを疑う、というように、まず切断までの時間を計測してから設定を探すと効率的です。
Nginxでの設定|Upgradeヘッダーとタイムアウト
WebSocketをプロキシする最小設定
WebSocketはHTTP/1.1のUpgradeヘッダーでプロトコルを切り替えるため、Nginxがそのヘッダーをアプリまで転送しなければ接続が確立しません。リバースプロキシの基本設定に加えて、次の3点が必須です。
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name app.example.com;
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1; # 1: HTTP/1.1でなければUpgradeできない
proxy_set_header Upgrade $http_upgrade; # 2: Upgradeヘッダーを転送
proxy_set_header Connection $connection_upgrade; # 3: Connection: upgrade
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s; # 静かな接続を1時間まで許容
proxy_send_timeout 3600s;
}
}
proxy_read_timeoutは「アップストリームから次のデータが来るまで待つ時間」で、既定値は60秒です。これを延ばすだけでも切断は減りますが、無限に延ばすと本当に死んだ接続を回収できなくなるため、後述するアプリ側のping/pongと組み合わせるのが正解です。
SSEではバッファリングを止める
SSEは通常のHTTPレスポンスを閉じずに少しずつ流す方式なので、Upgradeは不要ですが、Nginxがレスポンスをまとめて返そうとバッファリングすると、イベントがまとめて遅延して届きます。proxy_buffering offにするか、アプリ側でレスポンスヘッダーX-Accel-Buffering: noを返してください。またCache-Control: no-cacheを付け、CDNがレスポンスをキャッシュしないようにします。
location /events/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
chunked_transfer_encoding on;
}
HTTP/1.1ではブラウザが同一ホストに同時に開ける接続数に上限(一般に6本)があるため、タブを複数開くとSSEが接続を食いつぶす問題があります。HTTP/2で配信すればこの制限は解消されるので、SSEを使うならHTTP/2を有効にしておきましょう。
ロードバランサー・CDNでの注意点
L7ロードバランサーの設定
AWS ALBはWebSocketにネイティブ対応していますが、アイドルタイムアウトの既定値は60秒で、Nginxと同様に延長が必要です。Cloudflareはプロキシ経由でもWebSocketを通しますが、オリジンからの応答が一定時間ないと切断する仕様があるため、静かな接続はやはりping/pongで維持する必要があります。ロードバランサーの種類とヘルスチェックの考え方はロードバランサーの解説記事を参照してください。
# ALBのアイドルタイムアウトを1時間に変更する例
aws elbv2 modify-load-balancer-attributes \
--load-balancer-arn arn:aws:elasticloadbalancing:ap-northeast-1:123456789012:loadbalancer/app/my-alb/abc123 \
--attributes Key=idle_timeout.timeout_seconds,Value=3600
スティッキーセッションが必要になる場面
WebSocketは一度確立すれば同じサーバーに張り付いたままなので、原則としてスティッキーセッション(同じクライアントを同じサーバーに振り分ける機能)は不要です。ただしSocket.IOのように、まずHTTPロングポーリングで接続してからWebSocketへ昇格する実装では、昇格前の複数リクエストが別のサーバーに振り分けられると失敗します。この場合はロードバランサーでスティッキーセッションを有効にするか、クライアント側で最初からWebSocketのみを使う設定(transports: ['websocket'])にするかのどちらかが必要です。
水平スケール|サーバーをまたいでメッセージを届ける
「2台目にした途端に届かない」の正体
アプリサーバーが1台のとき、接続中のユーザー一覧はそのプロセスのメモリにあります。サーバーを2台にすると、ユーザーAはサーバー1、ユーザーBはサーバー2につながっているため、サーバー1が「Bに通知を送る」と言っても、Bの接続はサーバー1には存在しません。これが「一部のユーザーにだけ届かない」現象の正体です。
Redis Pub/Subで配信を共有する
解決策は、メッセージの配信を各サーバーの外に出すことです。一般的にはRedisのPub/Sub機能を使い、「送りたいメッセージはRedisのチャンネルにpublishし、各サーバーはそのチャンネルをsubscribeして、自分につながっているクライアントに転送する」構成にします。Socket.IOなら@socket.io/redis-adapterを入れるだけで、この仕組みが自動的に組み込まれます。
// Node.js + ws + ioredis による最小のPub/Sub中継
const { WebSocketServer } = require('ws');
const Redis = require('ioredis');
const pub = new Redis();
const sub = new Redis();
const wss = new WebSocketServer({ port: 3000 });
sub.subscribe('chat');
sub.on('message', (_channel, message) => {
// このサーバーに接続中の全クライアントへ転送
for (const client of wss.clients) {
if (client.readyState === client.OPEN) client.send(message);
}
});
wss.on('connection', (ws) => {
ws.on('message', (data) => pub.publish('chat', data.toString()));
});
Pub/Subは「いま接続中のサーバーに届ける」だけで、オフライン中のメッセージは保持しません。未読の保証が必要なら、DBやRedis Streamsに保存し、再接続時に取りに来る設計を組み合わせます。
切れる前提で設計する|ping/pongと再接続
サーバーからの定期ping
タイムアウトを延ばすより確実なのは、タイムアウトより短い間隔でデータを流すことです。WebSocketにはプロトコルレベルのping/pongフレームがあり、サーバーが定期的にpingを送ってpongが返らなければ切断されたと判断できます。中継点のタイムアウトが60秒なら、pingは25〜30秒間隔が目安です。SSEにはpingがないので、コメント行(: keepalive\n\n)を定期的に送ることで同じ効果を得ます。
クライアント側の再接続
デプロイやサーバーの入れ替えで接続が切れるのは避けられないため、クライアントは自動再接続を前提に実装します。ポイントは3つです。
- 再接続の間隔は指数バックオフ(1秒、2秒、4秒…上限30秒程度)にして、サーバー復旧直後に全クライアントが一斉に殺到するのを防ぐ。乱数(ジッター)も加える
- 切断中に取りこぼしたイベントを補う手段を用意する。SSEなら
Last-Event-IDヘッダーが標準で用意されており、サーバーはそのIDより後のイベントを再送する - 再接続後に「最新状態を取り直す」APIを叩き、差分ではなく状態そのものを同期する。これが最も単純で堅い方法です
トラブル事例:デプロイのたびに通知が数分止まる
症状:アプリをローリングデプロイすると、数分間リアルタイム通知が届かないユーザーが出る。エラーは出ていない。
原因:旧プロセスがSIGTERMを受けてもWebSocket接続を閉じずに即終了しており、クライアント側はTCPの切断に気づくまで(OSのkeepaliveが働くまで)「接続中」だと思い込んでいた。pingを実装していなかったため、死んだ接続を検出する手段がなかった。
対処:サーバー側でSIGTERM受信時にクローズフレーム(コード1001)を送ってから終了するようにし、クライアントには30秒ごとのpingと再接続を実装。さらにNginxのproxy_read_timeoutを延長しすぎていた設定も見直し、死んだ接続を回収できる値に戻しました。
まとめ
WebSocketやSSEを本番で安定させる鍵は、アプリではなく経路上の中継点にあります。Nginxではproxy_http_version 1.1とUpgradeヘッダーの転送、SSEではproxy_buffering off、ロードバランサーとCDNではアイドルタイムアウトの把握、そしてタイムアウトより短い間隔のping/pongが基本セットです。サーバーを複数台にするならRedis Pub/Subで配信を共有し、クライアントは指数バックオフ付きの再接続と状態の再同期を前提に作ります。まずは自分の環境で「何秒で切れるか」を測り、どの中継点が原因かを特定するところから始めてください。Nginx全体のチューニングはNginxパフォーマンスチューニングも参考になります。
リアルタイム機能を含むシステムの設計やインフラ構築でお悩みなら、Harmonic Societyのシステム開発・インフラ支援にご相談ください。
Harmonic Society
この記事の内容、自社の業務でも活かせそうですか?
ローカルLLM・AI・クラウドなどの技術導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK。まずは30分の無料相談から。売り込みはしません。
関連記事
Related / 9 articles
Notes & Insights
- プログラミング
DDoS攻撃の仕組みと対策入門|レイヤー別の防御とCDN・クラウドの活用
DDoS攻撃をボリューム型・プロトコル型・アプリ層に分けて仕組みを解説し、自前サーバーで防げない理由、CloudflareやAWS Shieldの標準防御、オリジンIPの隠し方、レートリミットとBot対策、攻撃を受けたときの初動、費用が跳ね上がるDenial of Walletへの備えまでわかります。
- プログラミング
WAFとは?仕組み・導入パターン・誤検知対策|Webアプリを攻撃から守る実践ガイド
WAFがファイアウォールやIDSと何が違うのか、シグネチャとマネージドルールの仕組み、Cloudflare WAF・AWS WAF・ModSecurityの比較、フォーム送信がブロックされる誤検知の調査と例外設定、ログ監視、WAFが代替できないことまで実践的に解説します。
- プログラミング
セキュリティヘッダー入門|CSP・HSTS・X-Frame-Optionsの設定と効果を実践解説
CSP・HSTS・X-Frame-Options・X-Content-Type-Optionsなど主要セキュリティヘッダーが防ぐ攻撃と、CSPのReport-Onlyからの段階導入、nonce/hash、HSTS preloadの不可逆リスク、Nginx・Next.js・Astroでの設定例、確認方法を解説します。
- プログラミング
クラウドの通信費(Egress)入門|データ転送量課金の仕組みと転送コストを抑える設計
クラウドの「受信無料・送信有料」の原則、AZ間・リージョン間・インターネット向けの単価差、NATゲートウェイ処理料の罠、CDNで転送量を減らす方法、バックアップやログ転送の見落とし、請求書で転送料を特定する手順を解説。想定外の請求を防げます。
- プログラミング
秘密情報をGitに入れない仕組み|.gitignore・git-secrets・履歴から漏れた鍵の削除
APIキーや.envをGitにコミットしてしまう典型経路と、.gitignore・.env.exampleの運用、pre-commitでのgitleaks検知、GitHub secret scanningの活用、漏れた鍵の無効化と履歴書き換え(git filter-repo)の手順を解説。仕組みで再発を防げます。
- プログラミング
開発・ステージング・本番環境の分離設計|環境差分をなくす構成とアクセス制御
開発・ステージング・本番それぞれの目的と、構成をコードで揃える方法、環境別の設定注入、本番データを使わないテストデータ戦略、ステージングの保護(Basic認証・IP制限・noindex)、コストを抑える運用までを解説。環境差分による本番障害を防げます。
- プログラミング
ngrok・Cloudflare Tunnelでローカルを公開|Webhook開発とデモ環境の作り方
NAT内のローカル環境にStripeやLINEのWebhookを届けるトンネリングの仕組みを解説。ngrok・Cloudflare Tunnel・localtunnelの比較、固定ドメインと認証、リクエスト検査、公開時のセキュリティ、自宅サーバー公開への応用までわかります。
- プログラミング
ローカル開発環境のHTTPS化|mkcert・hostsファイル・自己署名証明書の正しい使い方
ローカル開発をHTTPS前提にすべき理由(Secure Cookie・Service Worker・OAuth)と、mkcertでローカルCAを作りhostsで独自ドメインを割り当ててVite・Next.js・Dockerで使う手順を解説。証明書警告を無視する癖の危険も理解できます。
- プログラミング
localhost・0.0.0.0・127.0.0.1の違い|ポートとUnixソケットを理解して「つながらない」を解決
localhost・127.0.0.1・0.0.0.0の意味の違い、Dockerで外から接続できない原因、host.docker.internal、ポート競合の調べ方、Unixソケットの利点と権限、1024未満ポートの制約を解説。「つながらない」を仕組みから解決できます。
Harmonic Society
「読んで終わり」にせず、自社の業務で試してみませんか?
AI・ローカルLLM・クラウドの導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK・売り込みなし。
無料・30分・オンラインOK|1営業日以内に返信します