HTTP は 1 往復で終わるはずなのに、なぜ WebSocket は繋がりっぱなしでいられるのか?
ActionCable や Socket.IO を使ったチャット機能、あるいは ws:// から始まる URL を見たことがあるはずだ。だが「HTTP はリクエストを送ってレスポンスが返ったら終わり」というステートレスな仕組みのはずなのに、なぜ同じ TCP 接続の上でサーバーから一方的にメッセージを送り続けられるのか、説明できるだろうか。
- WebSocket は普通の HTTP リクエストとして始まり、
Upgradeヘッダーと101 Switching Protocols応答によって同じ TCP 接続の中身を丸ごと別プロトコルに切り替える。 - 切り替え後はリクエスト・レスポンスの往復という制約が外れ、両者が任意のタイミングで「フレーム」という単位のメッセージを送り合える双方向の通路になる。
- 接続を維持し続けることは無料ではない。ロードバランサのアイドルタイムアウトで黙って切断されたり、複数サーバーに水平分散した瞬間に「誰が誰の接続を持っているか」という新しい問題が生まれたりする。
HTTP の「1 リクエスト 1 レスポンス」ではサーバー起点の通知ができない
通常の HTTP はクライアントがリクエストを送らない限り、サーバーは何も送れない。チャットや通知のようにサーバー側の出来事をすぐ画面に反映したい機能では、この制約が直接の壁になる。素朴な回避策はポーリング(polling)、つまりクライアントが「新着ある?」と一定間隔でリクエストを送り続ける方式だ。新着がなくてもリクエストとレスポンスのヘッダーが毎回発生し、間隔を狭めるほどサーバー負荷が増える一方、間隔を広げるほど通知が遅れるという板挟みになる。
この板挟みを避けるために生まれたのが WebSocket だ。最初の 1 回だけ HTTP でやり取りしてから接続の性質を切り替え、以降はクライアント・サーバーのどちらからでも好きなタイミングでメッセージを送れる 1 本の持続的な通路にする。「1 往復で完結する取引」を毎回やり直す代わりに、「回線を開いたまま両者が好きな時に喋る」形に変える発想の転換である。
ハンドシェイクは「HTTP のふりをした乗っ取り予約」
WebSocket 接続は普通の HTTP GET リクエストとして始まる。ただしヘッダーに Upgrade: websocket と Connection: Upgrade を含めることで、「このリクエストの後、同じ TCP 接続を別のプロトコルに切り替えたい」とサーバーに申告する。サーバーが対応可能なら、本文を持たない 101 Switching Protocols という特殊なステータスコードで応じる。200 番台の成功応答ではなく 101 が使われるのは、「リクエストは処理した」ではなく「プロトコルを切り替える」という別種の意味を持たせるためだ。
クライアント サーバー │ GET / HTTP/1.1 │ │ Upgrade: websocket │ │ Sec-WebSocket-Key:xxx │ │───────────────────────▶│ │ 101 Switching Protocols│ │ Sec-WebSocket-Accept: │ │ (Keyから計算した値) │ │◀───────────────────────│ │ ここから双方向で │ │ 自由にフレームを送受信 │ │◀──────────────────────▶│
このやり取りには「なりすまし」を防ぐ仕掛けもある。クライアントはランダムな値を Sec-WebSocket-Key として送り、サーバーはその値に固定の GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)を連結して SHA-1 ハッシュを取り、base64 エンコードした値を Sec-WebSocket-Accept として返す。RFC 6455 が定めるこの決まった計算をサーバーが行えることで、WebSocket を理解しないプロキシがたまたま Upgrade ヘッダーだけ素通りさせてしまうような誤動作を防いでいる。
普通の HTTP は毎回「用件を書いた手紙を送り、返事を待つ」文通に近い。1 通ごとに封筒(ヘッダー)が要り、相手から不意に手紙が届くことはない。WebSocket のハンドシェイクは、最初の 1 通で「これから電話回線に切り替えていいですか」と尋ね、相手が「はい」と答えた瞬間に受話器がつながった電話に変わる動作だ。回線がつながっている間は、封筒を書き直さずにどちらからでも好きな時に話しかけられる。
切り替え後の中身は「フレーム」という小包の連続
プロトコルが切り替わった後、データは HTTP のようなヘッダー付きテキストではなく、フレームという小さなバイナリ単位でやり取りされる。各フレームには、中身がテキストか(opcode 0x1)バイナリか(0x2)、接続を閉じる合図か(0x8)、後述する生存確認のping(0x9)/pong(0xA)かを示す種別と、ペイロード長が入っている。1 つのメッセージが複数フレームに分割されることもあり、受信側はそれらを組み立て直して元のメッセージに戻す。
もう 1 つの重要な決まりがマスキングだ。クライアントからサーバーへ送る全フレームは、ランダムな 32 ビット値でペイロードを XOR して隠す必要がある。これは暗号化ではなく、WebSocket を理解しない中間プロキシのキャッシュに、細工したバイト列を「普通の HTTP レスポンスのように見せかけて」流し込むキャッシュ汚染攻撃を防ぐための仕掛けだと RFC 6455 は説明している。逆にサーバーからクライアントへのフレームはマスクされない。この非対称性を知らないと、自作の WebSocket サーバーがマスク解除を実装し忘れて、ブラウザ製クライアントとだけ通信できないという不具合にぶつかる。
「繋がりっぱなし」を支えるのは ping/pong と、それでも切れる現実
接続を維持するには、両者が「相手はまだ生きているか」を確認し続ける必要がある。WebSocket にはこのための ping/pong フレームが標準で用意されており、どちらか一方が ping フレームを送ると、受け取った側は同じペイロードを乗せた pong フレームを即座に返す義務を負う。応答が返ってこなければ、アプリケーション側は接続が死んだと判断して再接続処理に入れる。
ただし ping/pong だけでは防げない切断もある。多くのロードバランサやリバースプロキシは、一定時間データが流れない接続を自動的に切るアイドルタイムアウトを持つ。AWS の Application Load Balancer は idle_timeout.timeout_seconds の既定値が 60 秒で、これは WebSocket 接続にもそのまま適用される。60 秒間 ping も含めて何も流れなければ、アプリケーションが気づく前に接続が切られてしまう。対策は 2 つで、ロードバランサ側のアイドルタイムアウトを延ばすか、アプリケーション側で ping 間隔をタイムアウトより十分短く保つかのどちらかになる。「タイムアウトなく繋がりっぱなし」ではなく「タイムアウトより先に手を打ち続けるから繋がって見える」というのが実態だ。
# nginx で WebSocket をプロキシする際の典型的な設定
location /cable {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s; # 既定 60 秒のままだと無通信60秒で切断される
}
水平スケールすると「どのサーバーが誰の接続を持つか」が新しい問題になる
通常の HTTP API はステートレスなので、リクエストをどのサーバーに振ってもよい。WebSocket はそうはいかない。接続はどこか 1 台のサーバープロセスの TCP ソケットに紐づいており、そのユーザーへメッセージを送るには「そのユーザーの接続を今どのサーバーが持っているか」を知る必要がある。Rails の ActionCable が Redis を、Socket.IO が Redis アダプタを前提にした構成を勧めているのはこのためで、各サーバーはメッセージを一旦 Redis の pub/sub に流し、対象の接続を持つサーバーだけがそれを受け取って自分のソケットに書き込む。これを用意し忘れると、サーバーが複数台になった途端に「特定のユーザーにだけ通知が届かない」という、ロードバランサのアルゴリズム次第で再現したりしなかったりする不具合になる。
Rails の ActionCable や GraphQL Subscriptions、証券アプリの価格更新のような「サーバーから能動的に押したい」機能で WebSocket に出会う。本番でハマりやすいのは、開発環境では問題なく動いていた接続が本番のロードバランサ配下だけ数十秒〜数分でぷつぷつ切れる現象で、多くの場合は前段のアイドルタイムアウトかアプリの ping 間隔設定を疑うと解決する。複数台構成にスケールアウトした瞬間に一部ユーザーへの配信だけ抜けるようになったら、pub/sub によるサーバー間連携が入っているかを確認する。
ローカルの WebSocket 対応サーバーに手作りのハンドシェイクを送り、サーバーが返す Sec-WebSocket-Accept が RFC 6455 の計算式通りかを自分の手で検証する。
# 1. WebSocket 対応のテスト用エコーサーバーを起動
docker run --rm -d --name wsdemo -p 8080:8080 jmalloc/echo-server
# 2. 適当なキーでハンドシェイクを送り、レスポンスヘッダーを確認
KEY="dGhlIHNhbXBsZSBub25jZQ=="
curl -i -N \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: $KEY" \
http://localhost:8080/ &
sleep 1 && kill %1 2>/dev/null
# 3. RFC 6455 の計算式(Key + 固定GUID → SHA-1 → base64)を自分でも計算
printf '%s' "${KEY}258EAFA5-E914-47DA-95CA-C5AB0DC85B11" \
| openssl dgst -sha1 -binary | openssl base64
# 後片付け
docker stop wsdemo
手順 2 の応答ヘッダーに含まれる Sec-WebSocket-Accept の値と、手順 3 で自分で計算した値が一致するはずだ。サーバーが「魔法」でハンドシェイクを検証しているのではなく、決まったアルゴリズムを実行しているだけだということが確認できる。
- WebSocket は HTTP とは別のポート・別の接続を新しく張る — 同じ TCP 接続の上でハンドシェイクを通じてプロトコルだけを切り替える。新しい接続を確立し直すわけではない。
- 一度繋がれば切断を気にしなくてよい — ロードバランサやプロキシのアイドルタイムアウトで無通信のまま切られることがあり、ping/pong や適切なタイムアウト設定なしには「繋がりっぱなし」は維持できない。
- WebSocket サーバーは HTTP サーバーと同じようにどこにでも自由に負荷分散できる — 接続状態は特定のサーバープロセスに紐づくため、単純なラウンドロビンだけでは「あるユーザーへの配信」が成立しないことがある。
- Upgrade ヘッダー / 101 Switching Protocols
- 同じ TCP 接続の上でプロトコルを切り替えたいことを示すリクエストヘッダーと、それを承諾するステータスコード。
- Sec-WebSocket-Key / Sec-WebSocket-Accept
- クライアントが送るランダム値と、固定GUIDとのSHA-1ハッシュをサーバーが計算して返す値。誤動作するプロキシの介在を防ぐ。
- フレーム(frame)
- ハンドシェイク後にやり取りされるバイナリ単位のメッセージ。opcodeで種別(テキスト・バイナリ・close・ping・pong)を示す。
- マスキング
- クライアント→サーバー方向の全フレームに義務づけられたXOR処理。中間プロキシへのキャッシュ汚染攻撃を防ぐための仕掛け。
- ping/pong フレーム
- 接続の生存確認に使う制御フレーム。pingを受け取った側は同じペイロードのpongを返す。
- アイドルタイムアウト
- ロードバランサやプロキシが無通信の接続を自動的に切断するまでの時間。WebSocketにもそのまま適用される。
- RFC 6455: The WebSocket Protocol — ハンドシェイクの計算式・フレーム構造・マスキングの必須理由が一次情報として定義されている。
- MDN: Writing WebSocket servers — サーバー実装者視点でハンドシェイクとping/pongの扱いを具体的に解説している。
- AWS: Application Load Balancers(load balancer attributes) — idle_timeout.timeout_secondsの既定値60秒など、ALBの属性が一次情報として載っている。