SmartHR Tech Blog

SmartHR 開発者ブログ

WebSocketをCloud Runで動かすときに知っておきたかった2つのこと

こんにちは。SmartHRでプロダクトエンジニアをしているmkmnです。

SmartHRでは、今年の3月にメッセージ機能をリリースしました。

https://support.smarthr.jp/ja/info/update/ea3t01au02z0/support.smarthr.jp

メッセージ機能では、メッセージのリアルタイム受信を実現するため、SmartHRのプロダクトとして初めてWebSocketを採用しました。

この記事では、SmartHRの標準的な構成であるRuby on RailsのAction CableをGoogle Cloudで動かすためにはじめに注意すべき点をご紹介します。

メッセージ機能の構成

はじめに、メッセージ機能の構成について簡単にご紹介します。 メッセージ機能は、SmartHRでは標準的な以下の技術スタックで構成されています。

  • バックエンド:Ruby / Ruby on Rails
  • フロントエンド:TypeScript / React / Next.js
  • インフラ:Google Cloud

メッセージ機能のインフラ概略図

今回取り上げるWebSocket通信は、Ruby on Railsが提供するAction Cableを利用しています。
Action CableはCloud Run上で動作し、サブスクリプションアダプタにはMemorystore for Redisを利用しています。

Cloud RunでWebSocket通信するときの注意点

まずは、Cloud RunでWebSocket通信する際の注意点をご紹介したいと思います。

タイムアウトには注意しよう

Cloud Runにはリクエストタイムアウトの設定があり、レスポンスを返すまでの時間に制限があります。 cloud.google.com

このタイムアウト設定はWebSocketも例外ではなく、デフォルトで5分、最大60分でコネクションが切断されます。
メッセージをリアルタイムで受信したいのに、途中でコネクションが切断されるのは嬉しくありません。

この問題には、2つの対応をしていきます。

対応1. タイムアウトを延長する

まずは、Cloud Runのリクエストタイムアウトを延長し、コネクションが切断される回数を軽減することが大事になります。

前述の通りタイムアウトは60分まで延長できます。タイムアウトを延長することで、接続が切断される回数を軽減することができます。

しかし、タイムアウトの制約がなくなったわけではないので、これだけでは不十分です。

対応2. 再接続処理をする

そこで2つめの対応として、接続が切断されたときに再接続処理を行うことも必要になります。

Railsが提供するフロントエンドライブラリ(@rails/actioncable - npm )には、接続状態を確認する関数や再接続関数なども用意されています。

これらの関数を活用し再接続処理を行うことで、ほぼ途切れなくリアルタイム接続が可能になります。
github.com

Action Cableを動かすときの注意点

続いてAction Cableを動かすための注意点をご紹介します。

Memorystore for Redisとの組み合わせで動かすときは注意しよう

前述の構成でご紹介した通り、メッセージ機能ではAction CableのサブスクリプションアダプタにMemorystore for Redisを利用しています。

それでは、サブスクリプションアダプタの設定を行ったのち、Cloud Runにアプリケーションをデプロイします。
デプロイは問題なく完了しましたが、いざリクエストをするとエラーを返して失敗しました。

リクエストに失敗した原因を探っていきます。

原因を探る

アプリケーションのエラーを確認すると、次のエラーが出ていることが確認できました。

/usr/local/bundle/gems/redis-client-0.25.1/lib/redis_client/connection_mixin.rb:71:in `call_pipelined': ERR unknown command 'CLIENT', with args beginning with: 'SETNAME' 'ActionCable-PID-32' (redis://192.0.2.1:6379) (RedisClient::CommandError)
    from /usr/local/bundle/gems/redis-client-0.25.1/lib/redis_client.rb:798:in `block in connect'
    from /usr/local/bundle/gems/redis-client-0.25.1/lib/redis_client/middlewares.rb:16:in `call'
    from /usr/local/bundle/gems/redis-client-0.25.1/lib/redis_client.rb:797:in `connect'
    from /usr/local/bundle/gems/redis-client-0.25.1/lib/redis_client.rb:759:in `raw_connection'
    from /usr/local/bundle/gems/redis-client-0.25.1/lib/redis_client.rb:719:in `ensure_connected'
    from /usr/local/bundle/gems/redis-client-0.25.1/lib/redis_client.rb:744:in `ensure_connected'
    from /usr/local/bundle/gems/redis-client-0.25.1/lib/redis_client.rb:443:in `disable_reconnection'
    from /usr/local/bundle/gems/redis-5.4.0/lib/redis.rb:79:in `without_reconnect'
    from /usr/local/bundle/gems/actioncable-7.2.2.1/lib/action_cable/subscription_adapter/redis.rb:90:in `listen'
    from /usr/local/bundle/gems/actioncable-7.2.2.1/lib/action_cable/subscription_adapter/redis.rb:166:in `block in ensure_listener_running'

エラーの内容を見ると、 CLIENT コマンドが見つからずにエラーとなっているようです。
実は、Memorystore for Redisでは利用できないコマンドが存在し、 CLIENT コマンドは利用できないコマンドの1つだったのです。

cloud.google.com

この問題を回避することは可能なのでしょうか。更にバックトレースを追ってみます。

まず、 redis-client のコードを見てみます。
コマンドが実行される798行目付近をみると、どうやら id に値があると CLIENT コマンドを実行するようです。

if id
  prelude << ["CLIENT", "SETNAME", id]
end

# The connection prelude is deliberately not sent to Middlewares
if config.sentinel?
  prelude << ["ROLE"]
  role, = @middlewares.call_pipelined(prelude, config) do
    @raw_connection.call_pipelined(prelude, nil).last
  end
  config.check_role!(role)
else
  unless prelude.empty?
    @middlewares.call_pipelined(prelude, config) do
      @raw_connection.call_pipelined(prelude, nil)
    end
  end
end

https://github.com/redis-rb/redis-client/blob/v0.25.1/lib/redis_client.rb#L784-L801

したがって、 id が付与されなければ CLIENTコマンドの実行を抑制できそうです!

id は、Redisクライアントの initializeの引数として指定されます。

def initialize(
  config,
  id: config.id,
  connect_timeout: config.connect_timeout,
  read_timeout: config.read_timeout,
  write_timeout: config.write_timeout
)
  @config = config
  @id = id&.to_s
  @connect_timeout = connect_timeout
  @read_timeout = read_timeout
  @write_timeout = write_timeout
  @command_builder = config.command_builder
  @pid = PIDCache.pid
end

https://github.com/redis-rb/redis-client/blob/v0.25.1/lib/redis_client.rb#L67-L81

このヒントを元にAction Cable側のコードを見ると、Redisクライアントをinitializeする際の設定で、自動的に id を指定しているコードにたどり着きました。

cattr_accessor :redis_connector, default: ->(config) do
  ::Redis.new(config.except(:adapter, :channel_prefix))
end

https://github.com/rails/rails/blob/v8.0.2/actioncable/lib/action_cable/subscription_adapter/redis.rb#L18-L20

def redis_connection
  self.class.redis_connector.call(config_options)
end

def config_options
  @config_options ||= @server.config.cable.deep_symbolize_keys.merge(id: identifier)
end

https://github.com/rails/rails/blob/v8.0.2/actioncable/lib/action_cable/subscription_adapter/redis.rb#L59-L65

対策. redis_connector を上書きする

原因がわかったので対策をしていきます。

Redisクライアントのinitialize時の引数であるconfig_options から id だけを取り除くことは難しそうです。
ならば、 ActionCable::SubscriptionAdapter::Redis.redis_connector の実行時に、引数 config から id を取り除きましょう。

最終的に以下のように redis_connector を定義し直すことで、無事にAction Cableの接続に成功しました🎉

require "action_cable/subscription_adapter/redis"

ActionCable::SubscriptionAdapter::Redis.redis_connector = ->(config) do
  ::Redis.new(config.except(:adapter, :channel_prefix, :id))
end

なお、この問題の解消にあたっては、先人の知恵に非常に助けられました。感謝。

github.com

まとめ

本記事では、Google CloudでAction Cableを使ったWebSocket通信を始める際の注意点を中心にご紹介しました。

今回ご紹介した内容以外にも、Cloud Runのドキュメントにはベストプラクティスなどが記載されているので、合わせてご覧ください。

cloud.google.com

他にも、認証など考えることは色々あるのですが、また別の機会にお話できればと思います。

We Are Hiring!

SmartHR では一緒に SmartHR を作りあげていく仲間を募集中です!

少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!

hello-world.smarthr.co.jp