WebSocketには注意が必要

近い将来WebSocketがRailsで使用できるようになると聞くと、デベロッパはみんな舞い上がって興奮します。


しかし、あなたのユーザは、あなたがWebSocketを使用しているかどうかなんて気にしません

  • ユーザは、”快適なリアルタイムWebアプリ”を求めている。
  • デベロッパは、”快適でビルドが簡単なリアルタイムWebアプリ”を求めている。
  • オペレーションは、”デプロイ、スケール、管理が簡単なリアルタイムWebアプリ”を求めている。

上記全ての要望をWebSocketがかなえてくれるのなら素晴らしいことですが、この実装の詳細は高いコストがかかります。

超高性能で全二重なクライアントとサーバ間の通信は、本当に私たちに必要なのか?

WebSocketは、クライアントに情報を配信するための簡単なAPIと、クライアントからWebサーバへ情報を送信するための簡単なAPIを提供します。

サーバからクライアントへ情報を送信するためのリアルタイムチャネルは大歓迎です。これは実際にHTTP 1.1の一部を占めています。

しかし、Webブラウザからサーバへ情報を送信する最新のAPIは、デベロッパに新たな選択肢を与えています。

  • ユーザがチャット上にメッセージを投稿する場合に、RESTをコールしてメッセージをPOSTするかどうか、またはRESTを無視してWebSocketを使用するかどうか。

  • 新しいバックチャネルを使用する場合、どうやってデバッグするか? 何が行われているのかを、どうやってログに残すのか? どうやって分析するのか? 自分のサイトへの他のトラフィックが遅くならないようにするには、どうしたらいいのか? コントローラアクション内のエンドポイントも露出させるのか? どのようにレート制限するのか? バックグラウンドのWebSocketのスレッドが、データベース接続の限界まで使い切らないようにするのは、どうしたらいいのか?

:warning:APIが、データベースへの何百もの異なる接続の同時アクセスを許可した場合、問題が発生するでしょう。

バックチャネルを取り入れれば完璧というわけではありません。多くの警告もついてきます。

私は大半のWebアプリケーションにWebサーバへの新しいバックチャネルが必要だとは思いません。技術レベルで話をすると、もしあなたがWeb上で1万件のインタラクティブなコンソール・セッションを管理するのであれば、このような構成を選択することになるでしょう。HTTPヘッダのパースを必要とせず、Railsもミドルウェアのクロールなどを必要としなくなることにより、より効率的にサーバにデータを送信することができます。

しかし、世の中の大半のWebアプリケーションは、アプリケーションの読み込みがメインです。ユーザの多くはライブアップデートの恩恵を受けていますが、情報を変更する人はごくわずかです。もともとミリ秒未満で行われるようなHTTPヘッダのパースの最適化が必要な状況というものはほとんどありません。一方で、Rackのミドルウェアを回避することは、フルスタックのミドルウェアが10から20ミリ秒でクロールする場合は特に重要になるでしょう。しかしこれは、私たちが最適化できる実装の詳細であって、クライアントからサーバへのコミュニケーションのためのRESTを無視する理由にはなりません。

リアルタイムWebアプリケーションでは、情報を確実かつ迅速にクライアントに配信するために、私たちは簡単なAPIを必要とします。サーバへ情報を送信するための新しいメカニズムは必要ありません。

WebSocketの何が問題なのか?

WebSocketは、とびきり不安定なスペックでの波乱の経緯があります。その無謀さの副作用は、かなり多くの点で見受けられます。Ilya Grigorikの完璧な実装を見てください。5つのフレーミングプロトコルと、3つのハンドシェイクプロトコルなどがあります。

これは現在ようやく安定し、私たちはRFC6455を使用することができます。RFC6455は、最近の主なブラウザの全てに広く実装されていますが、いくつかの副次的な弊害があります。

  • Internet Explorer 9以前のブラウザはサポートされていない。

  • 多くのライブラリ(最も有名なRubyのライブラリを含め)は、Hixie-75に欠陥があるにも関わらず、複数の実装が付随する。

この副次的な弊害は、やがて解決されるだろうと私は確信しています。最も完璧と言える実装でも、重要な技術的欠点が伴うものだからです。

1) プロキシサーバは、セキュリティ保護されていないHTTP上でWebSocketを実行すると、大混乱を招く可能性がある

このプロキシサーバの問題は、周知のこととなっています。私たちのDiscourseの最初のリリースではWebSocketを使用していましたが、”トピックのアップデートが見つかりません”などといったレポートが絶えず届きました。様々なプロキシの中で、私の携帯のTelstraネットワークは様子が違いました。基本的にオープンなソケットは手に入るのですが、いかなるデータも通しませんでした。

「WebSocketが閉じてしまったにも関わらず、オープンであるように見える」という問題に対処するために、WebSocketを実装する場合は通常pingやpongメッセージを導入します。このソリューションは、HTTPS上で実行する時はうまく機能しますが、HTTP上では無駄になってしまい、悪意のあるプロキシに破壊されてしまいます。

つまり、「でも、HTTPSが使えるに決まっている」という主張は、WebSocketの採用に対する意見としては弱すぎるのです。私は全てのWebがHTTPSになればいいと思っています。いずれはそうなりますし、HTTPSのコストも日に日に安くなってきています。しかし、セキュリティで保護されていないHTTP上でWebSocketをデプロイすると、おかしなことが絶対に起こるというのは知っておいてください。残念ながらDiscourseでHTTPのサポート対象外としたことは採用に差支えるかもしれないので、私たちはまだ選択肢として考えていません。 

2) Webブラウザは膨大な数のオープンなWebSocketの接続を可能にする

評判の良くない、単一ホストに対する6接続の制限はWebSocketには適用されません。逆に、より多くの接続が可能です(Chromeでは255接続、Firefoxでは200接続)。これは素晴らしいことであると同時に、恐ろしいことでもあります。つまり、多くのタブを開くエンドユーザが、高負荷の原因となり、膨大なサーバリソースを継続的に消費してしまいます。WebSocketベースのアプリケーションで20のタブを開けば、クライアントとサーバ間の通信を軽減しない限り、20もの接続というリスクを冒すことになります。

以下は、通信を軽減するいくつかの方法です。

  • あるものを実行する信頼できるキューがあるなら、時間をおいてから(もしくはバックグランドタブ内にある時に)ソケットとの接続を中断し、後で再接続して確かめてみる。

  • あるものを実行する信頼できるキューがあるなら、通信を抑え、プロキシもしくはiptablesで多くのTCP接続を返すことができる。しかし、適切な接続を拒んでいる場合は、推測するのが難しくなります。

  • FirefoxやChromeでは、Shared Web Workerを利用することによって、接続を共有することができる。しかし、モバイルではサポートされる見込みはなく、Microsoftの製品でもサポートされていません。どうやら、FacebookではShared Workerの検証作業を行っているようです(GmailやTwitterでは行っていません)。

  • MessageBusブラウザのVisibility APIを使うことで、フォーカスされていない状態のタブの通信速度を落とし、バックグランドタブでのポーリングを2分遅らすことができる。


訳:WebSocketは50タブに対して50接続。HTTP/2は50タブに対して1接続。

3) WebSocketとHTTP/2トランスポートは統一されていない

HTTP/2はWebSocketよりも、更に効果的に複数のタブに関する問題に対処することが可能です。単独のHTTP/2接続はタブをまたがって多重化されるので、より素早く新しいタブにページを読み込むことができ、ネットワークの観点で見れば、ポーリングまたはロングポーリングのコストを削減することができます。しかし残念ながら、HTTP/2はWebSocketと一緒には扱えません。HTTP/2を経由してWebSocketの通信を行う方法はありません。2つは別個のプロトコルです。

両プロトコルを統一するための有効期限切れのドラフトもありますが、これを後押しする活動はないようです。

HTTP/2には、複数のDATAフレームを送信することによって、クライアントにデータをストリームする機能があります。これによりサーバからクライアントへのストリーミングデータが完全にサポートされることになります。

かなり多くの複雑なRubyコードを含むSocketサーバを運用させるのとは違い、HTTP/2サーバの運用は非常に簡単です。現在、HTTP/2はNGINXでサポートされているので、プロトコルを有効にするだけで完了します。

4) サーバ側で効率良くWebSocketを実装するにはepollkqueue、またはI/O完了ポートが必要

効率的なロングポーリング、HTTPストリーミング(またはServer Sent Events)をピュアRubyに実装するのは非常に簡単です。IO.selectを何度も起動する必要がないからです。私たちが取り組まなくてはならない最も複雑なものはTimerThreadでしょう。

RubyではIO selectを使って、新しいデータに対してソケットの配列を監視することができます。しかし、これは非常に効率が悪く、新しいデータがあるかどうか、カーネルに配列中を探させるように指示してしまいます。それに、1024入力(カーネルのコンパイル方法によって異なります)というハードリミットがあるので、長いリスト上で選択することができません。epoll(それとBSD向けのkqueue)を実装することによって、EventMachineがこの制限の問題を解決してくれます。
ただし、epollを正しく実装することは、簡単ではありません

5) WebSocketのロードバランシングは面倒

あなたが、WebSocketというファームを運営することに決めたのであれば、適切なロードバランシングは面倒です。Socketサーバがオーバーロードしているのを発見し、急いで別のサーバを追加すると決めた場合でも、既存のトラフィックを再度バランシングする賢い方法はありません。無制限に開いている接続のせいで、オーバーロードしたサーバを終了するはめになります。この時点であなたは、接続という洪水に溺れてしまっています(クライアントによって多少は軽減されるかもしれません)し、更に”再接続”した際には、ページをリフレッシュする必要があり、ソケットサーバを再起動し、そしてWebサーバがあふれてしまうでしょう。

WebSocketでは、HTTPプロキシとは対照的にTCPプロキシを起動することが余儀なくされます。TCPプロキシはヘッダを入れたり、URLを書き直したりすることができません。また、従来、HTTPプロキシが処理する様々な役割も実行できません。

通常、フロントエンドのHTTPプロキシによって緩和されるDoS攻撃は、TCPプロキシでは対処できません。では、誰かがソケットに接続し、Railsアプリケーションでデータベースを読み込む原因となるメッセージを送り始めたら、どうなるでしょう。単一の接続が多大な損害をもたらすことになります。

6) WebSocketの実装を洗練していくと、結局HTTPの再発明になる

Webブラウザで10のチャネル(チャットチャネルや通知チャネルなど)がサブスクライブされる必要があったとしましょう。当然10の異なるWebSocket接続を作りたくはありませんから、結局、単独のWebSocket上に複数のチャネルに対するコマンドを多重化することになります。

“/chat”チャネルに”Sam said hello”を渡す、というのはHTTPに非常によく似たものになります。投稿するチャネルを明示する”ルーティング”はHTTPヘッダに、そしてペイロードはHTTPボディに非常によく似たものになるのです。また、HTTP/2とは異なり、ヘッダどころかボディでさえも圧縮することはできないでしょう

7) WebSocketは、信頼性という錯覚を与える

WebSocketは、非常に興味をそそられるAPIを提供しています。

  • 接続可能
  • TCPによりサーバと信頼性のある接続を確立する
  • メッセージの送受信ができる

しかし、インターネットはあまり信用できる場とは言えません。ラップトップはオフラインになりますし、勧誘電話が煩わしい時には機内モードに変更したりすることもあります。インターネットが使えない場面はしばしば存在します。

つまり、この非常に興味をそそられるAPIは依然として、信頼性のあるメッセージングに支えられる必要があり、メッセージのバックログなどで情報をアップデートする必要があります。

WebSocketを実装する際には、欠損やサーバで処理される順序の入れ替わりなどが起こり得る単なるHTTP呼び出しであるかのように扱う必要があります。WebSocketは、信頼性という錯覚を与えただけなのです。

WebSocketは、実装についての取り決めであって、機能ではない

WebSocketは、せいぜい付加価値でしかないのです。更に別のトランスポート機構も提供されています。

大規模なインターネットサイトの多くがWebSocketを採用していないのには、確固たる技術的な理由があります。TwitterはHTTP/2とポーリングを使い、FacebookとGmailはロングポーリングを使っています。「WebSocketが唯一の方法であり、未来形である」と考えるのは間違っています。Webブラウザが許可する膨大な量のWebSocketの接続のせいで、HTTP/2がこの争いの勝者となるかもしれないですし、HTTP/3がそのプロトコルを統合するかもしれません。

  • 専用のソケットサーバを動かすのは避けたいものでしょう(ソケットが標準のHTTPのトラフィックに支障をきたさないように、規模を拡大して動かしたいでしょう)。Discourseではロングポーリングのサーバは運用していません。キャパシティの増強は些細なことなのです。キャパシティは常にバランスが保たれています。

  • ポーリングを使って、30秒の遅れにも問題を感じずに満足するかもしれません。

  • HTTP/2で提供されている強化されたトランスポート機構を好み、ロングポーリングとHTTP/2上のストリーミングを使うかもしれません。

WebSocketよりも、メッセージングの信頼性の方がはるかに重要

MessageBus信頼できるpublish-subscribeチャネルによって支えられています。メッセージはグローバルに順序付けされます。また、メッセージはチャネルに対してローカルに順序付けされます。このことは、いかなる点においても、(上限はあるものの)古いメッセージを”取り戻す”ことができることを意味します。APIからわかるように、クライアントがサブスクライブする時、そのチャネルがどの位置にあるのかをサーバに知らせるためのオプションを持つということです。

// subscribe to the chat channel at position 7
MessageBus.subscribe('/chat', function(msg){ alert(msg); }, 7);

MessageBusの信頼できる基盤のおかげで、純粋なWebSocketの実行に影響を与える類の問題の影響を受けません。

この基盤により、他の多くの使用法でクロスプロセスキャッシュを書くことは取るに足らないことになるのです。

信頼性のあるメッセージングは非常によく理解されている概念です。信頼性のあるメッセージングを実行するために、ErlangやRabbitMQ、ZeroMQ、Redis、PostgreSQLあるいはMySQLでさえも使うことができるのです。

信頼性のあるメッセージングが実装できれば、複数のトランスポート機構を簡単に実装することができます。ロングポーリング、チャンクされたエンコーディングを用いるロングポーリング、EventSource、ポーリング、隠しiframeなどの処理能力を、自分のフレームワーク上で使えるようになるのです。

:warning:リアルタイム・フレームワークを選択する場合は、WebSocketよりも信頼できる基盤の方が好ましい

私の立ち位置は?

DiscourseではWebSocketを使っていません。DiscourseのDockerではHTTP/2のテンプレートを使っています。

私たちはリアルタイムのWebアプリケーケーションを運用しています。200行程のコードでちゃんとしたチャットルームが出来上がりました。単にRubyGemsに含めることにより、Rails3またはRails4で、今のところ問題なく運用できています。日々、私たちの提供するチャットルーム利用者からの何百万というロングポーリングを処理します。誰かがDiscourseのトピックに返事を書くとすぐに、画面にポップアップされます。

MessageBusは私たちのスタックの基礎であり、不可欠なものだと思います。サーバとサーバ、サーバとクライアントのどちらについても、信頼できるライブ・コミュニケーションを実現することができました。
転送には依存しません。RackやRedisに依存している部分もありますが、それだけです。

みんながWebSocketの話で盛り上がっている時、私は心の中に、このような注意書きを思い浮かべます。


注釈:
CAUTION 注意
SHARP OBJECTS 鋭利なものには
GLOVES REQUIRED 手袋が必須

WebSocketで解決している事例の大部分の代わりを務められるHTTP/2が、すでに存在する世界では、WebSocketが批准される可能性は低いでしょう。

この記事をレビューしてくれたIlya、Evan、Matt、Jeff、Richard、Jeremyに深く感謝します。