AWSのELB(ALB)におけるSocket.IOのwebsocket接続と負荷分散 - Stickiness(Sticky session)の活用

AWSではELB(Elastic Load Balancing)を活用し、アクセスの振り分けや負荷分散を行なうのがメジャーです。 ロードバランサーを使った構成は、通常のWebシステムではごくごく一般的です。

ただしSocket.IOを使ったウェブソケット(websocket)接続では、問題となる場合があります。 これには理由がありまして、私も調査に苦労しました。

まずは前提となる知識を共有しつつ、備忘録を兼ねて解説します。

目次

Socket.ioの仕組みをざっくりと理解する

websocketのライブラリでSocket.ioが多用されているのは、いくつか理由があります。

その大きな理由のひとつに「HTTPの仕組みを使っているため、企業のファイアウォールやセキュリティーソフトのブロックに対して耐性がある」という点が挙げられます。

socket.io

ロングポーリング(polling)

Socket.ioでは、まずロングポーリングを使ってサーバーと接続します。 ロングポーリングは、初期のLINEアプリでも採用されていた実績のある方式です。

ひとことで言ってしまえば、「通常のHTTPリクエストに対するレスポンスを引き伸ばすことで、長時間の接続を維持する」のがロングポーリングです。 サーバーはHTTPリクエストのレスポンスをすぐに終わらせず留保するため、クライアントとのコネクションを維持します。

qiita.com

ロングポーリングは、一般的なHTTPリクエストの延長線上にある技術です。 そのためセキュリティー制約の強い企業のファイアウォールや、セキュリティーソフトのブロックに対して強みがあります。

ロングポーリングからウェブソケット(websocket)へ

次にSocket.ioが実施するのは、プロトコルの昇格です。

ロングポーリングは素晴らしい仕組みですが、基本的にHTTPの接続を維持できる時間には限界があります。 もちろん接続が切れてしまったら再接続すれば良いのですが、これでは一定間隔で切れ目が生じてしまいます。

そこで登場するのがwebsocketです。 Socket.ioは「ロングポーリングからwebsocketへプロトコルを昇格(Upgrade)」しようと試みます。

Once all the buffers of the existing transport (XHR polling) are flushed, an upgrade gets tested on the side by sending a probe.

引用:https://socket.io/docs/internals/#Upgrade

Connection: Upgrade

HTTP/1.1にはConnectionというヘッダーがあり、コネクションのプロトコルをアップグレードすることができます。

Upgrade: websocket
Connection: Upgrade
引用:http://jxck.hatenablog.com/entry/20120725/1343174392

もしコネクションのアップグレードに成功したならば、Socket.ioはウェブソケットでの通信を開始します。 一方でwebsocket接続に失敗したとしても、ロングポーリングでの接続を継続することができます。

このプロトコル選択の柔軟性が、Socket.ioのすごいところです。 websocketが使える環境下ではwebsocketを利用し、そうでない環境下でもロングポーリングで接続を維持できます。

ロードバランスされるとセッション(sid)が引き継げない問題

socket.ioがウェブソケット接続を開始する場合、以下のようなリクエストを送信します。 ここで注意してほしいのが「sid」という名のパラメーターです。

GET wss://myhost.com/socket.io/?EIO=3&transport=websocket&sid=36Yib8-rSutGQYLfAAAD  
with:  
  "EIO=3"                     # again, the current version of the Engine.IO protocol  
  "transport=websocket"       # the new transport being probed  
  "sid=36Yib8-rSutGQYLfAAAD"  # the unique session id  

https://socket.io/docs/internals/#Upgrade

「sid」とはセッションIDのことですが、ここに大きな問題があります。 最初にアクセスしたサーバーと違うサーバーに振り分けられてしまった場合、発行されたセッションが見つからなくなってしまうのです。

その際は「Session ID unknown」のようなエラーが発生します。

つまり最初にリクエストを送信してからウェブソケットの接続を確立するまで、常に同じサーバーにロードバランスされないと困ってしまうのです。

Redisによるセッションの共有

Socket.ioのセッション情報を引き継げない問題でよく選択されてきた対応策は、インメモリデータベースのRedisによるセッション情報の共有です。 AWSで言うならば、Redis用のElastiCacheを使ってセッション情報を保存します。

複数立っているsocket.ioサーバのセッション情報をRedisを介して共有することができるようなので、そうすれば複数のリクエストが異なるサーバに繋がっても問題なくなります。
引用:https://qiita.com/shunjikonishi/items/526e6181efecf83a5f5b

「socket.io-redis」というRedis向けのライブラリがあり、これを使います。 2年ほど前の話ではありますが、実際に試してみたところ確かに問題ありませんでした。

ElastiCacheでなく自前でRedisを用意しても構わないですが、兎にも角にもRedisが必要です。

Application Load Balancer

さてAWSには、Application Load BalancerというL7のロードバランサーがあります。 単なるELBとの違いがややこしいですが、OSI参照モデルで言えば最上位の第7層(アプリケーション層)でリクエストを振り分けることが可能です。

f:id:konosumi:20190606030658p:plain

Stickiness(Sticky session)

ここで登場するのが、Stickiness(Sticky session)というALBの仕組みです。 スティッキーセッションを使うと、ロードバランサーのアクセスの振り分けを固定することができます。

具体的な手順として、ELB(ALB)経由でサーバーにアクセスすると、HTTPレスポンスに「AWSELB」という名前のCookieが追加されます。 このクッキーはALBが自動に付与する設定もできますし、手動でサーバーから追加する選択肢も可能です。

ELBのスティッキーセッションはELBがサーバにリクエスト振り分ける際、特定のCookieを確認することで、特定のクライアントからのリクエストを特定のサーバに紐付けることが出来る機能です。
ELBのスティッキーセッションの設定は以下3つのパターンが選択出来ますので、それぞれどのような動作となるか確認してみます。

  • 維持無し

  • ELBによって生成されたCookieの維持

  • アプリケーションによって生成されたCookieの維持

引用: http://blog.serverworks.co.jp/tech/2017/01/05/elb-sticky/

HTTPレベルの仕組みであるCookieを読み取ってアクセスを振り分けるため、高機能なアプリケーション層レベルのL7ロードバランサーであると言えます。

なお私は、実際のスティッキーセッションを使ったsocket.ioのアクセスの振り分けは、uorat様のブログ記事を参考に行ないました。 (注釈:ものすごく参考になりました。ありがとうございます)

uorat.hatenablog.com

ELB(ALB)のwebsocket対応

従来のELBには、websocket接続できない欠点がありました。 そのためsocket.ioでwebsocket接続してるのかと思いきや、実際はロングポーリングでしたという事態が発生します。

これを解消したのが、2016年8月の新機能です。なんとwebsocket対応が追加されました。 これによりELBの負荷分散の恩恵を受けつつ、よりリアルタイム性の高い接続を維持することができるようになりました。

WebSocketサポート:WebSocketのリクエストを受けられるようになりました。
引用: 【新機能】新しいロードバランサー Application Load Balancer(ALB)が発表されました | DevelopersIO

さいごに

Stickiness(Sticky session)の活用とELB(ALB)のwebsocket対応により、Socket.ioでも通常のWebシステムと同じようにロードバランスすることができます。

Cookieを使ってクライアント毎にロードバランサーの振り分け先を固定すれば良いなんて、当初は思いもしませんでした。 とても便利な機能なので、今後も活用していきたいです!