ネットワーク●●●○○

WebSocket

WebSocket ・ うぇぶそけっと

ブラウザとサーバの間に、どちらからでも送れる常時接続の通路を開くプロトコル。

✦ 30秒で試す — 航海つなぎっぱなしにすれば、サーバのほうから先に話しかけられる2つ目の出来事は、起きてからどれくらいで届く?予想してみる

概要

WebSocket は、ブラウザとサーバの間に「つなぎっぱなしの双方向の通路」を作るプロトコルです。HTTP が「クライアントが聞き、サーバが答えたら終わり」の往復なのに対し、WebSocket は一度つながると、クライアントとサーバのどちらからでも、好きなときに何度でもメッセージを送れます。

この性質が活きるのは、「サーバの側で起きた出来事を、すぐブラウザに届けたい」場面です。チャットで誰かが発言した、株価が動いた、共同編集でほかの人がカーソルを動かした、対戦ゲームで相手が移動した。どれも、クライアントが尋ねる前にサーバから知らせる必要があります。Slack や Google ドキュメントのようなリアルタイム性の高い Web アプリの裏では、WebSocket かその仲間がほぼ必ず動いています。

名前のとおり、OS のネットワーク通信の口であるソケットに近い「素の双方向の通路」を、ブラウザの世界に持ち込んだものです。ブラウザからは new WebSocket(url) という JavaScript の素朴な API で使えます。

なぜ生まれたか

もともとの Web は文書を取り寄せる仕組みなので、HTTP には「クライアントから始まる往復」しかなく、サーバから話しかける手段がありませんでした。それでもチャットや通知を Web で作りたい開発者たちは、2000年代に力技でしのいでいました。

1つはポーリングです。数秒ごとに「何かあった?」と聞き続けます。出来事がなくてもリクエストが飛び続け、しかも間隔の分だけ遅れます。改良版のロングポーリングは、サーバが返事を保留しておき、出来事が起きた瞬間に返します。遅れは減りますが、返事のたびにリクエストを張り直すので、そのたびにヘッダや Cookie を送り直す重さが残り、サーバは保留中の接続を大量に抱えます。こうした苦肉の策はまとめて「Comet」と呼ばれていました。

WebSocket はこの工夫を根本から解くために設計され、2011年に RFC 6455 として標準化されました。うまいのは「最初のあいさつだけ HTTP を使う」ことです。HTTP のアップグレードの仕組みを使うので、既存の443番・80番のポートとプロキシなどの Web の設備をそのまま通り抜けられ、あいさつが済んだら HTTP の衣を脱いで素の双方向の通路に切り替わります。新しいポートも新しい設備も求めなかったことが、普及の決め手になりました。

詳細

アップグレードのハンドシェイク

WebSocket の接続は、ふつうの HTTP のリクエストとして始まります。ブラウザは Upgrade: websocket ヘッダを付けた GET を送り、「この接続を WebSocket に切り替えたい」と申し出ます。サーバが応じると、ステータスコード 101(Switching Protocols)を返し、その瞬間から、この TCP の接続の上を流れるのは HTTP ではなく WebSocket のフレームになります。

サーバブラウザサーバブラウザここから WebSocket のフレームに切り替わるどちらかが Close フレームを送るまで接続は続くGET /chat と Upgrade websocket・Sec-WebSocket-Key101 Switching Protocols・Sec-WebSocket-Acceptメッセージメッセージサーバからのプッシュもいつでも送れるPing に Pong で応える死活確認

ハンドシェイクの Sec-WebSocket-Key と Sec-WebSocket-Accept は、サーバが決まった計算で応えることで「相手が本当に WebSocket を理解している」ことを確かめるためのもので、認証や暗号化の役目はありません。確立後の通信は「フレーム」という軽い単位で流れ、テキスト(JSON を載せることが多い)とバイナリの両方を運べます。フレームの上乗せは数バイトなので、HTTP のリクエストのたびに数百バイトのヘッダを送り直すのと比べて、小さなメッセージを頻繁にやり取りする用途に向いています。暗号化には TLS を使い、URL のスキームは http・https に対応して ws・wss になります。本番で wss 以外を使う理由はまずありません(平文の ws は途中のプロキシに壊されることも多い)。

ポーリングと比べて、何が変わったのか

違いは「出来事が起きてから届くまでの遅れ」と「空振りのリクエスト」に表れます。ポーリングでは、出来事は次のリクエストが来るまでサーバで待たされ、平均で間隔の半分ほど遅れます。何も起きていないときも、リクエストは飛び続けます。ロングポーリングではすぐ返せますが、返したあとブラウザが次のリクエストを張り直すまでの隙間に起きた出来事は待たされます。WebSocket では通路が開いたままなので、出来事は片道の時間で届き、空振りもありません。

下の部品で確かめてください。左のサーバをクリックすると出来事が起き、同じ出来事が3つの方式で同時にブラウザへ向かいます。何回か、それから素早く続けてクリックして、遅れと空振りの数を比べてください。

⚡ 体験: サーバで出来事を起こして、3つの届け方を並べる
サーバクリックで出来事を起こすポーリング 3秒ごとに「何かある?」ブラウザ—遅れ 平均 — ・ 空振り 0回 ・ 0.0KBロングポーリング サーバが返事を保留するブラウザ—遅れ 平均 — ・ 空振り 0回 ・ 0.0KBWebSocket つなぎっぱなしの双方向接続を確立中ブラウザ—遅れ 平均 — ・ 空振り 0回 ・ 0.0KB
リクエスト空の返事(空振り)出来事を運ぶ返事・フレームサーバで待っている出来事

左のサーバをクリックして、出来事(新着メッセージ)を起こしてください。同じ出来事が3つの方式で同時にブラウザへ向かいます。何もしなくても、ポーリングのレーンでは空振りのリクエストが行き来しています。

HTTP との使い分け

双方向で速いからといって、何でも WebSocket にすべきではありません。ページの遷移やふつうの API 呼び出しのような「要求して応答をもらえば終わり」の通信は、REST API など HTTP のほうが向いています。HTTP にはキャッシュ、ステータスコードによるエラーの表現、再試行のしやすさといった成熟した道具立てがあり、ステートレスなのでスケーリングも簡単だからです。

WebSocket を選ぶ目安は、「サーバからのプッシュが要るか」「メッセージの頻度や遅れの要求を HTTP の往復で満たせないか」です。サーバからブラウザへの一方向のプッシュで足りるなら、HTTP の応答を流し続けるだけの Server-Sent Events という、より軽い選択肢があります。自動再接続がブラウザに組み込まれていて、ふつうの HTTP として扱えるので、通知やフィードの更新ならこちらで十分なことが多いです。チャットやゲームのように双方向に頻繁にやり取りするなら WebSocket、という住み分けが目安です。

台数を増やしたときの配り方

WebSocket の難しさは、プロトコルよりも運用に現れます。いちばん大きいのは、接続が長生きして、サーバのメモリ上の状態になることです。サーバを1台から複数台に増やした途端、「サーバ A につながっている人に、サーバ B が受けたメッセージをどう届けるか」という問題が生まれます。定石は、Redis(NoSQL の代表的なインメモリデータベース)の Pub/Sub 機能やメッセージキューを、サーバ間の伝言板として挟むことです。

ブラウザ1「こんにちは」を送るブラウザ2ブラウザ3同じルームの相手WS サーバ Aブラウザ1・2 がつないでいるWS サーバ Bブラウザ3 がつないでいるRedis Pub/Subチャンネル room:42 の伝言板① publish② 購読者へ配るA にも配られる③ B は自分につながっているブラウザ3 へフレームを流す
複数台の WebSocket サーバでメッセージを配る構成。どのサーバが受けたメッセージも、伝言板(Redis Pub/Sub)を経由して全サーバに配られ、それぞれが自分につながっているブラウザへ流す

運用の落とし穴

経路の途中にも注意が要ります。間に挟まるリバースプロキシやロードバランサがアップグレードを正しく中継する設定になっているか、何も流れていない接続を一定時間で切るタイムアウトの値が短すぎないかを確かめます。「60秒たつとなぜか切れる」は、この層の設定が原因の定番のトラブルです。プロトコルには Ping・Pong のフレームがあり、定期的に打つことで接続を保ち、相手が消えたことにも気づけます。

クライアントの側では、モバイル回線の切り替わりなどで接続は必ず切れるものとして作ります。自動で再接続するときは、全員が同時につなぎ直してサーバを押しつぶさないよう、リトライと指数バックオフに揺らぎ(ジッター)を加えて間隔を散らします。切れている間に取りこぼしたメッセージを、最後に受け取った番号から取り直す仕組みも設計に入れておきます。再接続・フォールバック・ルームの管理といった定番の面倒をまとめて引き受ける Socket.IO のようなライブラリや、Pusher・Ably のようなマネージドサービスが広く使われているのは、裏を返せば素の WebSocket の運用がそれだけ大変だということです。

最後にセキュリティです。ブラウザは、別のオリジンからの fetch の応答を CORS の許可がなければ読ませませんが、WebSocket にはこの仕組みが働きません。しかも接続のときには、そのサイトの Cookie が自動で送られます。サーバがハンドシェイクの Origin ヘッダを自分で確かめないと、悪意のあるサイトが利用者のログイン状態のまま接続を開き、メッセージを読み書きできてしまいます(Cross-Site WebSocket Hijacking という脆弱性)。ハンドシェイクで Origin を検証し、認証を確かめることを忘れないようにします。

試問

画面の左はサーバで、押すと出来事(新着メッセージ)が1つ起き、同じ出来事がポーリング(3秒ごとに聞きに来る)・ロングポーリング(サーバが依頼を保留し、返したら張り直す)・WebSocket(つなぎっぱなしの通路)の3本のレーンで同時にブラウザへ向かいます。片道は0.4秒で、各レーンの右のブラウザに、届いた出来事の遅れが出ます。

✦ 命題挑戦中

ロングポーリングでは片道の2倍以上遅れるのに、WebSocket では片道の時間で届く出来事を起こせ

  • ロングポーリングで、片道(0.4秒)の2倍以上待たされて届く出来事を作る
  • その同じ出来事が、WebSocket では片道の時間で届いている
⚡ 体験: サーバで出来事を起こして、3つの届け方を並べる
サーバクリックで出来事を起こすポーリング 3秒ごとに「何かある?」ブラウザ—遅れ 平均 — ・ 空振り 0回 ・ 0.0KBロングポーリング サーバが返事を保留するブラウザ—遅れ 平均 — ・ 空振り 0回 ・ 0.0KBWebSocket つなぎっぱなしの双方向接続を確立中ブラウザ—遅れ 平均 — ・ 空振り 0回 ・ 0.0KB
リクエスト空の返事(空振り)出来事を運ぶ返事・フレームサーバで待っている出来事

左のサーバをクリックして、出来事(新着メッセージ)を起こしてください。同じ出来事が3つの方式で同時にブラウザへ向かいます。何もしなくても、ポーリングのレーンでは空振りのリクエストが行き来しています。