
WebSocket×冗長構成
担当案件でWebSocketを冗長構成で利用する際に、接続を持たないサーバからの送信が失敗する問題に遭遇した為その原因と解決策を調査した。
今回の問題
WebSocketの基本的な特徴は以下の通り
- クライアントとサーバ間で1本のTCPコネクションを維持して双方向通信を可能にする
- 接続開始時はHTTPでハンドシェイクを行い、独自のWebSocketフレーム形式で通信を続ける
- 1本のWebSocketコネクションは、基本的にあるクライアントとあるサーバプロセス(orスレッド)の間で1対1に対応する
今回のトラブルはこの3.の特徴に起因したもの。
冗長構成の環境でクライアントはサーバAとコネクションを確立していたが、別ノードのサーバBからそのクライアントへの送信処理が実行され、Bは当該クライアントとの接続を持たないため送信が失敗した。
確立したコネクションをそのサーバが保持し続ける仕組みから、WebSocketを冗長構成の環境で使用する場合、以下のような課題を意識する必要がある
- 所有ノードの把握
- どのサーバがどのクライアントとのコネクションを保持しているかを共有できないと、今回のように存在しない接続に対して送信を試みて失敗する
- ロードバランサの影響
- WebSocketは長時間同じTCPコネクションを維持するため、接続先サーバは固定され続ける。そのためロードバランサは接続単位でしか分散できない。
- Stickyを利用すればセッションの一貫性は保てると考えられるが、負荷分散の効果が薄れるため、冗長構成の解決策としては十分ではない。
- メッセージ配送の一貫性
- 複数ノードから送信要求がある場合、クラスタ内部でどのノードが対象クライアントを保持しているかを解決し正しい宛先で受信できる仕組みが必要となる。
解決策
冗長構成でWebSocketを運用する場合、代表的な解決策は以下の通り。
- Stickyセッション
- ロードバランサでクライアントを常に同じサーバに振り分ける。
- 簡易的に問題を回避できるが、負荷分散の効果は限定的でフェイルオーバーにも弱い。
- メッセージブローカー経由
- Redis Pub/Sub、RabbitMQ などを介して各サーバからの送信要求をブローカーに集約し、クライアント接続を保持しているサーバにルーティングする。
- 配送の一貫性は担保しやすいが、ブローカーの運用コストや遅延が増える
- サーバ間転送
- 各サーバが「どのクライアントをどのサーバが保持しているか」を共有し、非所有サーバにリクエストが来た場合は所有サーバへメッセージを転送する。
- 外部ブローカーを挟まないため遅延は少ないが、所有ノードの管理や内部通信の仕組みが必要になる。
今回はアプリケーション内の実装で素のWebSocketを利用していること、加えて一部の処理ではユーザへの結果返却のために送信/受信を待機する必要があることから、3.のサーバ間転送方式を採用した。
実装サンプル
以下はSpring Boot + Redis を用いたサーバ間転送方式の最小実装サンプル。複数ノード環境で、非所有ノードからの送信要求が所有ノードへ転送される流れを確認できる。
https://github.com/MasahikoKawamura/gateway-spring
まとめ
WebSocketは単一サーバに依存するため、冗長構成では所有ノードの問題解決が不可欠となる。 本記事ではその解決策の一例として、非所有ノードから所有ノードへのサーバ間転送方式を実装した。