HTTP は本質的に「クライアントが要求 → サーバが応答」の 片方向 プロトコルです。チャットやリアルタイム株価表示のように、サーバから能動的にプッシュしたい用途には不向き。WebSocket(RFC 6455, 2011年) はこの制約を、HTTP の上から「Upgrade」して双方向 TCP を確立するという発想で解決しました。本講ではハンドシェイクの仕組み、フレーム構造、Ping/Pong による接続維持、TLS 化(wss://)、そして Python と JavaScript で最小チャットを動かすハンズオンまで踏み込みます。最後に SSE・Long Polling・WebRTC・socket.io との使い分けも整理します。
本講を終えると、以下を達成できるようになります。
図の見方:① の短周期ポーリングは「とりあえず何回も訊く」方式。HTTP ヘッダのオーバーヘッドが毎回発生し、無駄が多い。② Long Polling はサーバが応答を保留することで頻度を下げるが、本質はリクエスト型のまま。③ WebSocket は最初だけ HTTP で「ハンドシェイク」し、以降は 1本の TCP の上で自由に双方向メッセージ を流せる。
つながる知識: HTTP/2 にも Server Push がありますが、これはあくまで「応答に付随してファイルを先送りする」用途であって、長時間の双方向対話には設計されていません(2022 年の Chrome では既定で無効化された)。HTTP/3 でも同様。「ブラウザとサーバの双方向対話」を素直に表現できるのは依然として WebSocket、または UDP ベースの WebTransport(2024 年現在まだ標準化途中)です。
確認: 通常の HTTP では実現困難で、WebSocket が解決した「双方向通信」とは具体的にどのような能力か?
正解:C。HTTP は基本的に「クライアントが要求 → サーバが応答」のリクエスト/レスポンス・モデル。サーバ側からの能動送信が必要だと、Long Polling や Comet のような擬似的な手段で「リクエストを開いたまま応答を遅延させる」工夫が必要だった。WebSocket は最初から双方向のコネクションを張る設計で、サーバが好きなタイミングでフレームを送れる。チャット・通知・株価・オンラインゲームでこれが必須になる。
# クライアント → サーバ
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
# サーバ → クライアント
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
# ↓ ここから先は WebSocket フレームでの双方向通信
「キャッシュされた古い HTTP 応答を WebSocket 開始と勘違いしないため」 の検証手順です。サーバはクライアントの Key にマジックストリングを足してハッシュを取り、Accept として返します。クライアントは同じ計算をして突き合わせ、一致しなければ接続を破棄します。
# Python で確認
import hashlib, base64
key = "dGhlIHNhbXBsZSBub25jZQ=="
magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
accept = base64.b64encode(hashlib.sha1((key + magic).encode()).digest()).decode()
print(accept) # → s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
考えてみよう: なぜ HTTP/1.1 GET から始まるのでしょうか?TCP を直接立てて独自プロトコルを話せば、Upgrade のような遠回りは不要なはずです。理由は 「インターネット中のプロキシ・ファイアウォール・LB が HTTP しか通さない」 という現実への適応です。HTTP リクエストとして見えれば、80/443 を経由してまず通り、その後で素性を変えられる。レガシーインフラに優しい新プロトコルの設計テクニック として知られています。
Q. なぜ WebSocket は独自ポート(例えば 81 番のような新しい予約ポート)を使わず、わざわざ 80/443 で HTTP として始めて Upgrade するという回りくどい設計を採用したのか?
企業ファイアウォールと中間プロキシは、HTTP(80)/HTTPS(443) 以外を高確率で塞いでいる。新しいポートを開けてもらうのは現実的に不可能で、新プロトコルが普及する最大の障壁になる。
HTTP として始めれば、Layer 7 を理解しないファイアウォールも「普通の Web リクエストだ」と認識して通す。Upgrade で昇格してから WebSocket フレームを流せば、結果として双方向通信が確立できる。
これは 「インストール済みインフラを敵にしない」 という実装上の知恵で、QUIC が UDP/443 を使う(新しいトランスポートだが既存ポートに乗る)、HTTP/2 が ALPN で TLS の中で交渉する、なども同じ発想。新プロトコルの普及戦略として広く参考にされる設計パターン。
図の見方:最小構成は FIN+opcode+MASK+payload len = 2 バイト。ペイロードが 125 バイト以下なら 7 ビットの payload len 欄に収まり、追加ヘッダなし。126 バイト以上なら 16 ビット拡張、64 KB 以上なら 64 ビット拡張が登場します。HTTP のように Content-Type: 等を毎回送らないので、短いメッセージほど効率的です。
| opcode | 意味 | 用途 |
|---|---|---|
| 0x0 | continuation | 分割フレームの続き |
| 0x1 | text | UTF-8 文字列(JSON 等) |
| 0x2 | binary | 任意バイト列(画像・Protobuf 等) |
| 0x8 | close | 切断要求(ステータスコード付き) |
| 0x9 | ping | 生存確認(送る側) |
| 0xA | pong | 生存確認(返す側) |
WebSocket フレームの クライアント → サーバ方向は必ずマスク が施されます(MASK=1、4 バイトの masking key で XOR)。これは キャッシュ汚染攻撃(cache poisoning) 対策。中間プロキシが WebSocket を理解せず、ペイロードを HTTP メッセージと誤解してキャッシュに入れてしまうのを防ぐため、毎フレームでバイト列を撹拌します。サーバ → クライアント方向はマスクなし(MASK=0)で OK。
大きいメッセージは FIN=0 のフレームを複数連ねて、最後に FIN=1 で送るルール。これによりストリーミング的に流せます。例:1 GB のファイル送信を 1 フレームではなく、64 KB ずつのフラグメントに分割。
# 例:大きい binary を 3 フラグメントに分割
[FIN=0, opcode=0x2, payload=...] ← 最初のフラグメント(binary 開始)
[FIN=0, opcode=0x0, payload=...] ← 続き(continuation)
[FIN=1, opcode=0x0, payload=...] ← 最後の続き(完結)
つながる知識: 制御フレーム(Ping/Pong/Close)は フラグメント化できません。これは「制御メッセージは即座に確実に届く必要がある」ためで、データフラグメントの最中でも割り込んで送られます。これは TCP / IP の世界における「制御プレーンとデータプレーンの分離」発想を、アプリ層プロトコルの設計に持ち込んだ例として読めます。
確認: WebSocket のフラグメンテーション(分割送信)について、正しい記述はどれか。
正解:B。データは大きなファイルを 1 MB ずつ送るような用途で分割でき、各フレームの FIN ビットで「これが最後か」を表現する。一方、制御フレームは「即座に届くべき」という設計思想から分割禁止で、最大ペイロード 125 バイト。これにより閉じる宣言や Ping/Pong 応答がデータフラグメントの隙間で途切れず確実に処理できる ─ TCP の制御セグメントが優先転送されるのと同じ設計感覚。
WebSocket 接続は長時間維持されます。途中の NAT・LB・プロキシ がアイドル状態の TCP を勝手に切るのを防ぐには、定期的な Ping/Pong が有効です。多くのライブラリではデフォルト 20〜60 秒間隔で送信します。
図の見方:Ping/Pong は アプリのデータとは別レーン で流れる小さな存在確認メッセージ。これがあるおかげで、何時間も発言のないチャットルームでも切断されません。
| コード | 意味 |
|---|---|
| 1000 | Normal Closure ─ 正常終了 |
| 1001 | Going Away ─ ページ遷移・サーバ停止 |
| 1002 | Protocol Error |
| 1003 | Unsupported Data ─ 受信できない種別 |
| 1006 | Abnormal ─ Close フレームなしの切断(ライブラリが内部で生成、実際には送られない) |
| 1011 | Internal Error ─ サーバ内部エラー |
確認: 多くの WebSocket 実装で「数十秒に 1 回 Ping を送る」運用が標準的なのはなぜか?
正解:C。NAT 機器や LB は「無通信が数分続いたら切断」というタイマを持つことが多く、放置するとアプリは何もしなくても接続が消える(再接続が必要)。Ping/Pong は NAT バインディングを維持するためのアイドルアクティビティ として機能する。Cloudflare・AWS ALB など多くの環境で、idle timeout(60〜300 秒)があるため、それを下回る間隔で Ping するのが運用上のお作法になっている。
図の見方:外向きは TLS で暗号化、内側のアプリサーバとのやり取りは平文 ws のまま、というのが運用上ほぼ標準。アプリサーバは TLS 処理から解放され、nginx 側で証明書管理・HTTP/2/3 終端・WebSocket 中継が一元化できる。
location /chat {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # ← 必須
proxy_set_header Connection "upgrade"; # ← 必須
proxy_set_header Host $host;
proxy_read_timeout 86400; # 長寿命化
}
ポイントは Upgrade と Connection: upgrade ヘッダの透過。これらが欠けると nginx が WebSocket と認識せず、HTTP 短命接続として扱ってしまいます。
まずは依存をインストール。
python3 -m pip install websockets
# server.py — 全クライアントにメッセージをブロードキャスト
import asyncio, websockets
CLIENTS = set()
async def handler(ws):
CLIENTS.add(ws)
try:
async for msg in ws: # 受信ループ
print("recv:", msg)
await asyncio.gather(
*[c.send(f"<> {msg}") for c in CLIENTS if c is not ws]
)
finally:
CLIENTS.remove(ws)
async def main():
async with websockets.serve(handler, "localhost", 8765):
print("listening on ws://localhost:8765/")
await asyncio.Future() # 永久に待機
asyncio.run(main())
<!doctype html>
<input id="msg" placeholder="message" />
<button id="send">send</button>
<ul id="log"></ul>
<script>
const ws = new WebSocket("ws://localhost:8765/");
ws.addEventListener("open", () => console.log("connected"));
ws.addEventListener("message", e => {
const li = document.createElement("li");
li.textContent = e.data;
document.getElementById("log").appendChild(li);
});
ws.addEventListener("close", e => console.log("closed", e.code));
document.getElementById("send").addEventListener("click", () => {
const v = document.getElementById("msg").value;
ws.send(v);
});
</script>
1) python3 server.py でサーバ起動 → 2) HTML をブラウザで複数タブ開く → 3) どれかのタブで送信すると 他のタブ全てに表示される。これだけで「双方向チャットの背骨」が完成します。本番運用するには認証・room 機能・永続化を足していくだけ。
つながる知識: 上のサーバ実装は async / await を多用しています。これは 1 プロセスで多数の長寿命コネクションを捌く ための現代的な設計。WebSocket のように接続が長時間生きる用途では、スレッドあたり 1 接続のモデルではメモリが足りなくなります。発展編 第29回 HTTP プログラミング の aiohttp 章とも考え方が共通しています。
| 技術 | 方向 | 下層 | 得意な用途 | 苦手な用途 |
|---|---|---|---|---|
| WebSocket | 双方向 | TCP(HTTP Upgrade) | チャット・ゲーム・通知・株価 | P2P・大量バルク |
| Server-Sent Events(SSE) | サーバ→クライアント のみ | HTTP/1.1 持続接続 | ニュース速報・株価表示・通知 | クライアント送信が必要な場合 |
| Long Polling | 双方向(疑似) | HTTP リクエスト連続 | レガシー対応・WebSocket NG 環境 | レイテンシ・効率 |
| WebRTC | 双方向 P2P | UDP / DTLS / SCTP | 音声・ビデオ通話・低レイテンシ | クライアント間 NAT 越えの複雑さ |
| socket.io | 双方向(WebSocket + フォールバック) | WebSocket / HTTP | 互換性最重視・ルーム機能内蔵 | 素直なプロトコル理解には邪魔 |
→ SSE を選ぶ。HTTP/1.1 のままで動き、再接続・イベント ID も標準で組み込まれている。シンプル。ただし IE / 古い Edge 非対応(現代では問題にならない)。
→ WebSocket。ブラウザ標準 API、wss:// で TLS 化、フレーム効率良し。本講で扱った内容そのもの。
→ WebRTC。UDP ベースでレイテンシが圧倒的に低い。STUN/TURN サーバで NAT 越えする実装の複雑さが代償。
→ socket.io。WebSocket が使えれば WebSocket、ダメなら HTTP Long Polling に自動フォールバック。Room・Namespace 等の機能内蔵。プロトコル学習目的には重すぎる。
考えてみよう: Slack や Discord のチャットは何で動いているでしょうか?Web 版の DevTools を開くと、WebSocket(wss://) の長寿命コネクションがちゃんと見えます。一方、X(Twitter)のタイムライン更新は SSE、Zoom や Google Meet の音声・ビデオは WebRTC ─ それぞれ「双方向の頻度」と「レイテンシ要件」と「データ量」のトレードオフで設計が分かれています。
確認: 株価ダッシュボードや SNS のタイムラインのように サーバから一方向に頻繁に通知を送る 用途で、WebSocket ではなく SSE(Server-Sent Events) を選ぶことが多いのはなぜか?
正解:B。SSE は text/event-stream の MIME を持つ普通の HTTP レスポンスをずっと開いておくだけで、サーバが data: ...\n\n 形式で逐次送る仕組み。ブラウザ側に EventSource API があり再接続も自動。双方向不要なら WebSocket の Upgrade 機構やフレーム実装が不要で、HTTP/2 の多重化にも乗る。「能動送信が必要だがクライアントから多くは送らない」用途で軽量。一方、チャットやゲームのように双方向高頻度なら WebSocket が自然。
| 用語 | 1行説明 |
|---|---|
| WebSocket | RFC 6455。HTTP Upgrade で TCP を双方向通信に変える |
| Upgrade ハンドシェイク | HTTP/1.1 GET → 101 Switching Protocols でプロトコル切替 |
| Sec-WebSocket-Key / Accept | マジックストリングを混ぜた SHA-1 で偽装防止する検証ペア |
| opcode | フレーム種別(text 0x1, binary 0x2, close 0x8, ping 0x9, pong 0xA) |
| FIN | このフレームでメッセージが完結することを示すビット |
| MASK / masking key | クライアント→サーバ方向のペイロードを XOR で撹拌する仕組み |
| フラグメンテーション | 1 メッセージを FIN=0 のフレームを連ねて分割送信できる仕組み |
| Ping/Pong | 長寿命接続を NAT/LB に切られないための生存確認 |
| Close ステータス | 1000=正常、1001=遷移、1006=異常切断 など |
| ws:// / wss:// | 平文 / TLS 化された WebSocket。443 の wss が事実上の標準 |
| SSE(Server-Sent Events) | サーバ→クライアント片方向のシンプル代替 |
| WebRTC | UDP ベースの P2P リアルタイム通信(音声・ビデオ) |