WebSocketのフレーミングとハンドシェイク内部
WebSocketが「なぜ確実に双方向で通る」のか、ビット単位で腑に落ちる。Upgradeハンドシェイク・フレーム構造・マスク・制御フレーム・permessage-deflateまで原理から理解できる。
- 1.WebSocketはHTTP UpgradeとSec-WebSocket-Accept(キー+固定GUIDのSHA-1)で接続を昇格させ、以後はHTTPを使わない独自フレームに切り替わる。
- 2.フレームはFIN・opcode・MASK・ペイロード長で始まり、クライアント発フレームは必ず32ビットマスクキーでXORして送る規定がある。
- 3.Ping/Pong/Closeは制御フレームでデータと多重化され、permessage-deflateは各メッセージをDEFLATE圧縮してフレームに載せる拡張。
なぜフレーミングが必要か
WebSocket(RFC 6455)は、TCP上に全二重のメッセージ境界付きチャネルを作るプロトコルです。TCPはバイト列を順序通りに運ぶだけで、「ここからここまでが1メッセージ」という区切りを持ちません。一方HTTPはリクエスト/レスポンスの単位で完結し、サーバーから自発的に送る手段がありません。WebSocketはこの隙間を、独自のフレーム形式で埋めます。フレームこそが、TCPの無構造なバイトストリームにメッセージ境界・データ種別・制御信号を与える本体です。
接続の確立だけはHTTPに乗りますが、ハンドシェイクが完了した瞬間に、同じTCP接続上の通信はHTTPを完全に捨ててWebSocketフレームへ切り替わります。
Upgradeハンドシェイク
クライアントは通常のHTTP/1.1 GETに、以下のヘッダを付けて送ります。
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Key は、クライアントが生成した16バイトのランダム値をBase64で符号化したものです。これは認証用ではなく、この応答が本当にWebSocketを理解したサーバーから返ったことを確認するためのチャレンジです。サーバーは受け取ったキー文字列に固定のGUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 を連結し、そのSHA-1ハッシュをBase64符号化して Sec-WebSocket-Accept として返します。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
クライアントは自分が送ったキーから同じ計算を再現し、一致すれば接続を確立します。101 Switching Protocols が返った後は、このTCP接続は完全にWebSocketのものになります。
固定GUIDは公開値であり、SHA-1も暗号強度を狙ったものではありません。目的はあくまで「キャッシュやプロキシが古いHTTP応答を返したのではなく、ハンドシェイクを正しく処理したサーバーが応答した」ことの検証です。試験では「Sec-WebSocket-Acceptは認証/暗号化ではなくハンドシェイク完了の証明」と整理しましょう。
フレーム構造
ハンドシェイク後に流れる各フレームは、先頭2バイトに最小限のメタ情報を詰め込みます。ビットの並びは次の通りです。
| フィールド | ビット幅 | 意味 |
|---|---|---|
| FIN | 1 | このフレームでメッセージが完結するか(分割時の最終フレームで1) |
| RSV1-3 | 各1 | 拡張用の予約ビット(permessage-deflateがRSV1を使う) |
| opcode | 4 | フレーム種別(テキスト/バイナリ/制御) |
| MASK | 1 | ペイロードがマスクされているか |
| Payload len | 7 | ペイロード長。126/127は拡張長への切り替え符号 |
| 拡張長 | 16 or 64 | len=126なら16ビット、127なら64ビットで実長を表す |
| Masking-key | 32 | MASK=1のときのみ存在するマスクキー |
ペイロード長は段階的に拡張されます。7ビットの値が0〜125ならそれがそのまま長さです。値が126なら続く16ビット、127なら続く64ビットが実際の長さを表します。これにより小さなフレームは2バイトのヘッダで済み、大きなフレームも64ビット長まで表現できます。
opcodeの主な値は以下です。
| opcode | 種別 | 役割 |
|---|---|---|
| 0x0 | 継続フレーム | 分割メッセージの2片目以降 |
| 0x1 | テキスト | UTF-8として解釈されるデータ |
| 0x2 | バイナリ | 任意のバイト列 |
| 0x8 | Close | 接続終了の合図(制御フレーム) |
| 0x9 | Ping | 生存確認の問い合わせ(制御フレーム) |
| 0xA | Pong | Pingへの応答(制御フレーム) |
分割(フラグメンテーション)とテキスト/バイナリ
1つの論理メッセージは、複数フレームに分割できます。最初のフレームがopcode 0x1(テキスト)か 0x2(バイナリ)でFIN=0、続くフレームはopcode 0x0(継続)でFIN=0、最後のフレームがFIN=1です。これにより送信側は、全長が確定する前からストリーミング的にデータを流せます。受信側は同じopcodeのメッセージ系列として連結し、テキストなら全体を1つのUTF-8列として解釈します。
テキストフレームのペイロードは妥当なUTF-8でなければならず、不正なバイト列を受け取った側はステータスコード1007で接続を閉じる規定です。バイナリフレームにはこの制約がなく、ArrayBuffer や Blob として扱われます(TypedArrayとArrayBufferのメモリ表現 も参照)。
1つのWebSocketフレームが複数のTCPセグメントに割れることも、複数フレームが1セグメントに同梱されることもあります。受信側は到着したバイト列をフレームヘッダから順にパースして境界を復元するため、TCPの分割と、WebSocketのメッセージ境界・フラグメンテーションはまったく別の層の話です。ここを混同しないことが上級者の分かれ目です。
マスキングの意義
クライアントからサーバーへ送るフレームは、必ずマスクしなければならない(MASK=1)と規定されています。送信側は32ビットのランダムなマスクキーを生成し、ペイロードの各バイトを payload[i] XOR maskkey[i mod 4] で変換します。受信側は同じキーで再度XORすれば元に戻ります(XORは可逆)。
変換: transformed[i] = original[i] XOR maskkey[i mod 4]
復元: original[i] = transformed[i] XOR maskkey[i mod 4]
この一見奇妙な要件は、キャッシュポイズニング攻撃の防止が目的です。マスクが無ければ、攻撃者はブラウザのWebSocketからプロキシをHTTPリクエストに見える形のバイト列を送り込み、中間のキャッシュやプロキシを騙せる恐れがありました。ペイロードを毎回ランダムキーでマスクすることで、攻撃者がネットワーク上に出る具体的なバイト列を制御できなくなるため、こうした注入が成立しません。逆にサーバーからクライアントへのフレームはマスクしてはなりません。
マスクキーはフレーム内に平文で同梱されるため、盗聴者は即座に復号できます。マスキングは中間装置を騙されにくくするための対策であり、機密性は提供しません。盗聴・改ざんへの防御は wss:// のTLSが担います。両者の役割を混同しないでください。
制御フレームとClose
opcodeの最上位ビットが立つ 0x8〜0xF は制御フレームです。制御フレームはペイロードが125バイト以下に制限され、分割できません。これは、データメッセージの長い分割の合間にも、Ping/Pong/Closeを即座に割り込ませて処理できるようにするためです。
- Ping/Pong:生存確認に使います。Pingを受けた側は、同じペイロードを載せたPongを速やかに返さねばなりません。アイドル接続の死活監視や、中間装置によるタイムアウト切断の防止に使われます。
- Close:opcode
0x8で送り合います。ペイロード先頭2バイトにステータスコード(1000=正常終了、1001=離脱、1002=プロトコル違反など)を入れ、続けて理由文字列を載せられます。片方がCloseを送ったら相手もCloseで応じ、双方がTCPを閉じるクローズハンドシェイクを経て終了します。
permessage-deflate圧縮
Sec-WebSocket-Extensions ヘッダでハンドシェイク時に折衝する拡張が permessage-deflate(RFC 7692)です。これはメッセージ単位でDEFLATE圧縮を適用し、圧縮済みフレームではフレームヘッダのRSV1ビットを1にして「このメッセージは圧縮されている」と示します。
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
DEFLATEは前のデータを辞書として参照するため、JSONのようにキー名が繰り返される反復的なメッセージで効果が大きいのが特徴です。client_max_window_bits などのパラメータで圧縮ウィンドウのサイズを折衝し、server_no_context_takeover を指定するとメッセージごとに圧縮辞書をリセットします。辞書を持ち越す(context takeover)方が圧縮率は上がりますが、接続ごとに辞書ぶんのメモリを保持し続けるトレードオフがあります。
小さく非反復的なメッセージ(バイナリの乱数列、既に圧縮済みの画像など)では、圧縮しても縮まらずCPUを浪費するだけになります。多数の同時接続を抱えるサーバーでは、context takeoverの辞書メモリが接続数ぶん積み上がる点も無視できません。permessage-deflateは「テキスト中心・反復的・帯域が貴重」な場面で選択的に使うのが定石です。
まとめ
WebSocketは、HTTP UpgradeとSec-WebSocket-Acceptで接続を昇格させた後、HTTPを捨てて独自フレームに切り替わります。フレームはFIN・opcode・MASK・段階的ペイロード長という最小構造でメッセージ境界とデータ種別を表し、クライアント発フレームはキャッシュポイズニング対策として必ずマスクされます。制御フレームでPing/Pong/Closeをデータと多重化し、permessage-deflateで反復データを圧縮できます。確立前のHTTP側の挙動は HTTP/2の多重化とHPACK と Fetch APIの内部 が、オリジン検証の前提は 同一オリジンセキュリティモデル が補完します。
Web/フロントエンド Article
WebSocketのフレーミングとハンドシェイク内部を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
WebSocket
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
フレームはFIN・opcode・MASK・ペイロード長で始まり、クライアント発フレームは必ず32ビットマスクキーでXORして送る規定がある。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「WebSocket / プロトコル」に近いか確認する。
- 強みである「WebSocketはHTTP UpgradeとSec-WebSocket-Accept(キー+固定GUIDのSHA-1)で接続を昇格させ、以後はHTTPを使わない独自フレームに切り替わる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。