課題
現在、作っているサービスではsocket.ioを使って作ったWebSocketサーバをELB経由で使っています。
開発環境はELB配下にEC2が1台で、本番環境は複数台です。
これ、どちらも正しく動いているように見えていたんですが、最近セットアップした本番環境の方でWebSocketへのUpgradeがうまくいっておらず、pollingで動いていることに今日気がつきました。
サービスとしてはまぁ一応問題はないんですが、WebSocketで動いていると思っていたものが実はPollingだったというのはわりかし衝撃です。
原因
なんでやねんと思ってググるとわりと簡単に原因に行き当たりました。公式ドキュメントで思いっきり説明されています。
日本語だとこの辺でが参考になります。
どういうことかと言うと、socket-ioは最初はhttp(s)で接続して、WebSocketが使えそうだったらWebSocketを使うというアーキテクチャのため、イニシャルの接続でリクエストが2回発行されているんですね。
ロードバランシングしているとこの二つのリクエストが同じサーバに接続するとは限らないので異なるサーバに接続された場合にセッション情報が共有されていないためにWebSocketへのUpgradeが失敗する、と。
だったら、pollingもうまくいかないんじゃ。。。という気がしますがこれが動いているのは多分、Keep-Aliveが効いているためです。
WebSocketとhttp(s)ではそもそもプロトコルが違うので、どうしても接続が分かれてしまうんだろうと推測します。
対策(不採用)
公式では、以下の対応方法が紹介されています。
Sticky Sessionを使う
ロードバランサでSticky Sessionを使えるのであれば、同一クライアントからのリクエストは常に同一サーバに回されるのでこの問題は発生しません。
ただし、ELBの場合は
- WebSocketを使用する場合はTCPモードを使用する必要がある
- httpモードではHTTPヘッダがELBによって若干書き換えられるのでWebSocket Upgradeができない
- TCPモードではSticky Sessionは使えない
- httpヘッダとか一切見ないで素通ししているだけだから
となるのでこの方法は使えません。
Redisを使ってセッションを共有する
(2015/10/15 追記)同僚からこれはメッセージをシェアするだけでハンドシェイク時には機能しないんじゃないかという指摘を受けました。
socket.io-redisのソース見た感じ多分その通りです。
複数のWebSocketサーバを建てる場合にRedisは有効な解だと思いますが、以下のオリジナルの記述は間違いです。
多分これが正攻法です。
複数立っているsocket.ioサーバのセッション情報をRedisを介して共有することができるようなので、そうすれば複数のリクエストが異なるサーバに繋がっても問題なくなります。
解としてはこれがベストだろうと思うんですが、当たり前ですがRedisサーバを用意する必要があるわけです。
いずれやるのは良いとしても、時期的に今はサーバサイドに大きく手を入れたくはないのです。。。
対策(採用)
なんか方法はないものかと、socket.ioのソースを眺めていた所、かなりあっさりした解決法にたどり着いたのでここに紹介します。
var io = require("socket.io-client");
var url = ...;
var socket = io(url, {
transports: ["websocket"]
});
いじょ。(^^v
transportsのデフォルトは["polling", "websocket"]
ですが、これをwebsocketのみにすることによって最初のhttp(s)での接続をスキップしていきなりWebSocket接続することができます。
この場合(TCP的な)リクエストは1回になるのでセッション問題は発生しません。
ただし、当然ながらWebSocketに対応していないような古いブラウザへの対応はできなくなります。
サービスの対象ブラウザをモダンブラウザに限定できるならこの方法もアリでしょう。
余談ですが、サーバサイドのコードには最初のhttp(s)リクエストに対応するためにCORS対応のコードが入っているんですが、このやり方の場合は多分それも不要になります。(WebSocketにはCORSなんて関係ないから)
まとめ
もはやWebSocket非対応のブラウザなんか使ってはいけませぬ