🧵

Fiber Scheduler と Redis Pub/Sub を用いて WebSocket サーバーをスケールアウトする

2025/01/15に公開

2025/01/24 追記: 問題点の修正と Rails への組み込みと本番環境の設定 の節を追加しました。


※ この記事は私が GitHub上のDiscussion に投稿した内容を一部抜粋して日本語に翻訳し、体裁をブログ記事の形式に整えたものです

TL;DR

Fiber Scheduler ベースの gem である async-websocket を用いて、Socket.IO の Redis adapter のように Pub/Sub で複数サーバー間で通信して WebSocket サーバーをスケールアウトさせるサンプルコードを書いてみました。ソースコードは以下の repo に置いてあります。

https://github.com/ttanimichi/async-websocket-pubsub-example

(2025/01/24 追記)Fiber Scheduler 作者の Samuel Williams さんにコードレビューして頂いて、ご指摘いただいた問題点を修正し、同様の内容の処理を Rails に組み込んで、本番環境の設定やプロセスの起動方法などについて検討したのが以下の Pull Request です。
https://github.com/ttanimichi/rails-async-websocket-pubsub-example/pull/1

詳細

8年前、オンラインゲームのチャットサーバーを作ったことがあります。その時は Node.js と Socket.IO を使用しました。当時、そのゲームの API サーバーは Ruby on Rails で実装していたので、本当はチャットサーバーも Ruby で実装できれば良かったんですが、当時、Ruby で WebSocket サーバーを実装するには EventMachine を使うのが一般的でした。大規模なゲームで、想定ユーザー数が多かったので、C10K問題 を克服している処理系を使用したいと思い、技術選定の候補に上がったのが以下の3つです。

  1. Golang
  2. Elixir (Erlang)
  3. Node.js

個人的には Golang の goroutine で実装したかったんですが、当時、社内に Golang の経験のあるエンジニアはおらず、私が異動または退職した後もメンテしていくことを考えた時に、一番現実的だったのは Node.js だったので、当時は結局 Node.js を採用しました。チャットサーバーは無事リリースすることができました。Socket.IO が提供する API は高級で、とても便利でした。

現在、私は別の会社で、また WebSocket サーバーを実装することになりました。8年前に Socket.IO を使った開発を経験しているので、今回も Socket.IO を使用して処理系は Deno あたりを使おうかなと思ったのですが、要件として、クライアントは socket.io-client ではなく、素の WebSocket で接続する必要があり、Socket.IO のサーバーにはプレーンな WebSocket で接続することはできないので、今回の要件では Socket.IO を使用できないようです。

素の WebSocket のサーバーを Deno で作ることも検討したのですが、会社のメインのサービスが Ruby on Rails で実装されており、同社のサーバーサイドエンジニアは Ruby に最も慣れているので、今後のメンテを考えたときに理想的には WebSocket サーバーも Ruby で実装したいと考えました。

また、私が8年前にチャットサーバーを実装した際、当時の最新の Ruby は Ruby 2.3 だったと思いますが、現在の Ruby3 であれば、当時の Ruby2 とは違って、既に C10K 問題を克服しているのでは?とも考えました。

最初に検討したのは M:Nスレッド に対応した Ractor を使用することです。M:N スレッドであれば Golang の goroutine 相当のものなのかなと思ったので C10K 問題を克服していると言えそうだなと考えました。Ruby 3.4 がリリースされた昨年の12月25日に、私は @ko1 先生に、Ruby 3.4 の M:N スレッドは production ready になりましたか?と聞いたんですが、残念ながらまだ production ready とは言えないとの回答でした。数年後、もしまた次の機会があれば今度は M:N スレッドに対応した Ractor で実装してみたいと思います。

次に思い至ったのは Ruby3 の Fiber scheduler を使用することです。Fiber はグリーンスレッドのようなものだと思いますので、生成やコンテキストスイッチのコストが小さく、今回の要件に適しているのではないかなと考えました。Fiber scheduler ベースの async-websocket という gem もあるみたいですし、こちらの gem を使用して WebSocket サーバーを実装してみようと考えました。

さて、今回は要件的に Socket.IO を使用できないことになったのですが、Socket.IO の機能の以下の2点は優れているなと感じていました。

  1. Redis adapter の Pub/Sub 等を用いて複数のプロセス(複数台構成のサーバー)にスケールアウトできる点
  2. Room 機能 を用いて特定のチャンネルを購読しているクライアントにのみメッセージを送ることができる点

async-websocket を用いて Ruby で WebSocket サーバーを実装するにあたっても、素朴な実装にはなりますが、自前で同様の機能を実装できないかなと思い、考えたのが以下の図です。

今回の要件的に双方向の通信は必要なく、サービス側からの通知を一方的にクライアントに流すだけです。 なので書き込みは常に専用の Emitter からの Publish になります。Emitter が Redis に対して Publish して、async-websocket のサーバー側が各チャンネルを Subscribe します。クライアントは自身が購読しているチャンネルからのメッセージのみを受け取ります。

上記の構成で動くサンプルコードを実装してみました。ソースコードは以下になります。
https://github.com/ttanimichi/async-websocket-pubsub-example

実際に動かしてみた際の様子が以下の gif です。複数のサーバーを起動し、それぞれに 7070, 7071 の port を割り当てました。emitter からメッセージを Publish し、クライアントはそれぞれ自身が購読しているチャンネルからのメッセージのみを受け取っているのが分かるかと思います。

メインとなる config.ru の実装は、以下のようになっています。

#!/usr/bin/env -S falcon serve --bind http://localhost:7070 --count 1 -c

require 'async/websocket/adapters/rack'
require 'async/redis'

# key: channel name
# value: An array of connections for the channel
$connections = Hash.new { |h, k| h[k] = [] }

run lambda {|env|
  client = Sync do
    endpoint = Async::Redis.local_endpoint
    Async::Redis::Client.new(endpoint)
  end

  Async::WebSocket::Adapters::Rack.open(env, protocols: ['ws']) do |connection|
    loop do
      channel = connection.read.to_str
      puts "channel: #{channel}"

      $connections[channel] << connection

      client.subscribe(channel) do |context|
        puts "subscribed to #{channel}"

        loop do
          event = context.listen
          puts "event: #{event}"

          $connections[channel].each do |conn|
            conn.write("message: #{event[2]}")
            conn.flush
          end
        end
      end
    end
  ensure
    $connections.delete(connection)
  end
}

現状、期待通りに動作しているようです。 が、例えば、以下のような点が個人的には気になっています(これら以外でも何かお気づきの点があれば是非コメント欄等で教えてください) (2025/01/24 追記)Fiber Scheduler 作者の Samuel Williams さんにコードレビューしてもらって解決済み。詳細は次の節の 問題点の修正と Rails への組み込みと本番環境の設定 を参照のこと

  1. connection.readcontext.listen をそれぞれ待ち受けている関係で、無限ループがネストしている。が、仕組み上、これはこういうものということで問題ないか?
  2. Async::Redis::Client.new の部分を Sync の block で囲っているが、これは必要ないか?
  3. Redis の client$connections と同様に、グローバル変数にするべきか?
  4. メモリーリークや falcon 上で動かした場合に競合になりそうな実装になっている箇所はないか?

問題点の修正と Rails への組み込みと本番環境の設定

上記の config.ru には何点か問題がありました。が、Fiber Scheduler 作者の Samuel Williams さんにコードレビューしてもらって解決済み です。また、修正後のバージョンのコードを Rails アプリケーションに組み込んで、そのうえで本番環境の設定やプロセスの起動方法などについて検討したのが以下の Pull Request です。

https://github.com/ttanimichi/rails-async-websocket-pubsub-example/pull/1

こちらの Pull Request も Samuel Williams さんにコードレビューして頂きました。詳細は上記のリンクを読んで欲しいんですが、本番環境の設定に関して特に重要なポイントを何点か教えて頂いたので、メモしておきます。

  • 本番環境でサーバーを起動するには bundle exec falcon host を実行すればいいが、このコマンドはデフォルトで、サーバーの CPU コア数と同じ数のプロセスを起動する
  • nginx 等のリバースプロキシで TLS ターミネーションする場合、ブラウザとリバースプロキシの間の通信は HTTP/2 を使用するのが良いが、リバースプロキシとアプリケーションの間の通信は HTTP/2 Cleartext (H2C) を使用するのが良い(平文の通信であっても HTTP 1.1 よりもパフォーマンスの観点で有利だから)

Discussion