socket.ioで受け付けられる同時接続数をshardingで4倍に改善した
はじめに
ジーニーCHAT開発部の西澤です。
ジーニーはチャット型Web接客プラットフォーム「GENIEE CHAT[1]」を提供しています。
この記事では、GENIEE CHATシステムをスケールアウトして受け付けられる同時接続数を改善した方法について書きたいと思います。
システムアーキテクチャ
まずはシステムの概要を説明します。
ユーザーのブラウザに表示されるチャットウィジェットとバックエンドのサーバは、socket.ioを用いて双方向のリアルタイム通信を実現しています。[2]
ウィジェットとバックエンドとの間には双方の通信形式の差異を吸収するWebサーバがあります。これはいわゆるBFF(Backends For Frontends)と呼ばれるもので、上図の灰色の部分に相当します。
ユーザーがメッセージを送信する際は、
- ブラウザ上でユーザーが入力したテキストやフォームなどの送信ボタンを押す
- チャットウィジェットがsocket.ioでメッセージを送信する
- ALBを通り、KongのLoad balancerによってBFFに転送される
- BFFはSNSにメッセージを発行する(図では省略されています)
という流れになります。バックエンドがメッセージを送信する際は、
- バックエンドで作成した返答メッセージをSNSに発行する(図では省略されています)
- SNSはsubscribeされているSQSにメッセージを配信する[3]
- BFFでSQSからメッセージを取得し、対応するウィジェットの経路に送信する
となります。
ユーザーがメッセージを送信する際のLoad balancingにはハッシュ値が使われます。ハッシュ値の計算方法はKong Upstreamのhash_onオプションで指定することができ、hash_onにipを指定するとリクエストの送信元IPアドレスを元にハッシュ値が計算されます。
同じ送信元IPアドレスのメッセージは同じBFFに転送されるためsticky sessionが実現できます。これにより各ユーザーは特定のセッションに所属し、会話をセッション単位で独立させることができます。
直面した課題
サービス開始当初は快適にチャットをすることができましたが、多くのユーザーにご利用いただけるようになるにつれてチャットボットの応答が遅くなる問題がみられるようになりました。
システム全体のボトルネックを調査した結果、BFFでSQSからメッセージを取得し、対応するウィジェットの経路に送信する処理が詰まっていることが判明しました。
SQSには各BFFのプロセスとは関係のないセッションのメッセージも含まれており、8プロセスのBFFでは平均すると8メッセージのうち7メッセージが関係のないメッセージになっていました。[4] これらの無関係なメッセージを減らすことで、BFFがSQSからメッセージを取得する部分のスループットの改善ができそうです。
スループットの改善をしつつスケールアウトできる仕組みを考えた結果、BFFの負荷分散のアプローチとしてパスベースによるBFFのshardingを採用しました。
新しいシステムアーキテクチャ
メッセージ全体の流れは以前から変わっていませんが、メッセージの送信先URLなどが変わっており、主な変更点が2つあります。
1.URLの一部にセッションIDの一部を含め、Kongではそれに応じてルーティングするようにした
セッションのIDは乱数で生成されているのですが、均等に分散されることが確認できたためその一部をキーとしました。
パスによるRoutingをKongで行い, BFFインスタンス(実際の構成では複数台存在)の振り分けは従来通りKongのhash_onオプションでipを指定することでsticky sessionを実現しています。
これにより、セッションのIDの一部によって接続するBFFを限定できるようになりました。
ちなみにURLではなくHeaderを使う案もありました。この案ではsocket.io接続のパスの変更やKongのRouting追加がないので楽なのですが、ブラウザからwebsocketを使う場合の制約によりカスタムヘッダを渡すことができないため断念しました。[5]
2. SNSのSubscriptionFilterを追加した
SNSのSubscriptionFilterでmatchingを行うことができます。
バックエンドからSNSに発行されるメッセージにセッションIDの一部を含め、SNSのSubscriptionFilterによりセッションIDがマッチしたメッセージのみがSQSに配信されるようになります。
これにより、各BFFがSQSから取得するメッセージ数を削減することができました。
リリース時の運用
旧アーキテクチャと新アーキテクチャでメッセージの送信先URLを分けているので、まずは旧エンドポイントと新エンドポイントを並行稼働し、数日様子を見てからBFF, pubsub(SNS, SQS) -> ウィジェットの順に旧系をクローズすることで安全にリリースすることができました。
またバックエンドとBFFがpubsubで分離されていたことは上記のような段階的なリリースができるうえに、バックエンドの改修範囲が少なくなるメリットもありました。
改善結果
Lambda + Seleniumを使用した負荷テストを行ったところ、従来に比べて4倍以上の同時接続数を受け付けられるようになりました。
まとめ
BFFの負荷分散のアプローチとしてパスベースによるBFFのshardingを採用した結果、従来の4倍以上の同時接続数を受け付けられるようになり、スケールアウトにも対応することができました。
今後の課題としてはBFFをauto scalingできるようなインフラ構成、またEKSの導入によるauto scaling、可用性の向上などに取り組んでいきたいです。
補足
なぜAmazon API GatewayではなくKongを使っているのか
ALB, SNS, SQSといったAWSでインフラを構成しているのになぜAmazon API Gatewayを使わずKongを使っているのだろうかと疑問に感じた方がいるかもしれません。答えの一つとしては上記のアーキテクチャで説明したIP hashによるRoutingができるためです。他にも、
- JWT認証のプラグインが有用
- 実質nginxの設定を管理画面で設定できる
- localで動かせる(localstackのAPI Gatewayは有償)
などの理由が挙げられます。
BFFをスケールアウト、スケールインした際の接続の挙動
KongのLoad balancerはConsistent Hashingをサポートしています。
BFFをスケールアウト、スケールインした際の挙動は、
when the balancer gets modified by a change in its targets (adding, removing, failing, or changing weights), only the minimum number of hashing losses occur. This maximizes upstream cache hits.
となります。[6]
-
socket.ioではWebsocketが通らない場合はHTTP long-pollingになります。これによりファイアウォールなどが原因でWebsocketが疎通しない環境にも対応することができます。https://socket.io/docs/v3/how-it-works ↩︎
-
SNSとSQSを組み合わせたメッセージングの手法はfanoutと呼ばれています。https://docs.aws.amazon.com/sns/latest/dg/sns-sqs-as-subscriber.html ↩︎
-
無関係なメッセージは破棄しています。無関係かどうかの判定はsocketioのroomsで行っています。https://socket.io/docs/v3/rooms/ ↩︎
-
https://socket.io/docs/v3/client-initialization/#extraheaders ↩︎
-
https://docs.konghq.com/gateway/latest/how-kong-works/load-balancing/#balancing-algorithms ↩︎
Discussion