spacelyのブログ

Spacely Engineer's Blog

リードレプリカの導入に関する挑戦

概要

弊社のサーバ(Ruby on Rails 6.1, Ruby2.7, MySQL5.7)環境にてデータベースの負荷が増加し、アラートが頻発しています。この問題を解決するために、 rails guide を読みながらリードレプリカの導入を検討しました。 このブログでは導入までの試行錯誤を記載します。導入前後の負荷の比較は記載していません。 開発環境に導入し、テストを実行したところ、GETでもレコードの作成、更新などDBにwriteしている部分ではReadOnlyErrorが発生するようになりました。 そのため、暫定対応として下記のようにwritingのconnected_toブロックで囲むことにしました。

def show
  # read処理
  article = Article.find(params[:id])
  ApplicationRecord.connected_to(role: :writing, prevent_writes: false) do
    # write処理
    article.update!(title: "new title")
  end
end

これによりlocalなどのpuma環境ではスムーズに動作しましたが、unicornを使っている環境では 下記のコードの通りブロック内でwrite DBに接続できていることが確認できても、変わらずReadOnlyErrorが発生しました。 これを回避するため、GETでもwriteしているアクションはPOSTなど適切なHTTPメソッドを使うように変更し、後述のActiveRecord::Baseのprevent_writesをfalseにするで対応しました。

ApplicationRecord.connected_to(role: :writing, prevent_writes: false) do
  ApplicationRecord.connected_to?(role: :writing)
  #=> true
end

対応検討リスト

特定のactionでのみ強制的にwrite DBに接続する方法の検討リストです。

対応手段1 モンキーパッチ

write DBに接続するか、read DBに接続しているかを判断している箇所 ActiveRecord::Middleware::DatabaseSelector#reading_request?write_forced?(request) という独自メソッドを噛ませて特定のアクションの際はwrite DBに接続するように変更しました。 このタイミングではrequest.paramsが取得できなかったため、request.pathを使ってコントローラーとアクションを取得しています。 またメソッド内のローカル変数target_actionsに対象のコントローラーとアクションをハッシュで指定しています。 ここで対応箇所をリスト化できるため、GETリクエストでwriteしている箇所が一目瞭然になります。 しかし、GET以外は早期リターンされますが、すべてのGETアクションに影響を及ぼします。無関係な箇所に不要な分岐が挟まれるのは避けたいです。

pros

  • 対応すべき箇所をリスト化できるため、一目瞭然。

cons

  • すべてのGETアクションに影響。無関係な箇所に不要な分岐が挟まれる。

コード

ActiveRecord::Middleware::DatabaseSelector.class_eval do
  TARGET_ACTIONS = {
    "your/controller_name" => %w[your_action_name]
  }.freeze

  def reading_request?(request)
    return false if non_reading_request?(request) || write_forced?(request)
    true
  end

  def non_reading_request?(request)
    !(request.get? || request.head?)
  end

  def write_forced?(request)
    rails_recognizer = Rails.application.routes.recognize_path(request.path)
    controller_name = rails_recognizer[:controller]
    action_name = rails_recognizer[:action]

    TARGET_ACTIONS[controller_name] || []

    action_name.in?(target_actions)
  rescue ActionController::RoutingError => e
    false
  end
end

対応手段2 around_actionで再接続する

上記の概要の通りブロックで囲むだけではread DBへの接続が優先されてしまったためかReadOnlyErrorになってしまったため、 現在の接続を切った後に再接続するようにしました。 また、writeしている箇所のみ適用すると一旦接続を切っているのでデータの整合性が取れるのか不安だったため around_action を用いてアクション全体をブロックで囲むようにしました。

pros

  • 必要な箇所のみに適用可能。不要なところでの再接続を避けられる。

cons

  • DBへの再接続が頻発し、システム全体のパフォーマンスに影響が出る可能性がある。

コード

around_action :user_write_connection, only: %i[your_action_name]

def user_write_connection(&block)
  ApplicationRecord.connection.disconnect!
  ApplicationRecord.establish_connection
  ApplicationRecord.connected_to(role: :writing, prevent_writes: false, &block)
end

対応手段3 unicornの設定を変える。preload_app false

上記のaround_actionで再接続するでdisconnectが必要なのは unicornpreload_appの値がtrueになっていることが原因でした。 しかし、なぜtrueの場合writeブロック内でもread DBへの接続が優先されたのかはわかっていません。 新しく作ったrails6.1環境で実験してみたところunicorn環境でも再現しなかったためspacelyのなにかの設定が影響していることがわかっていますが 詳細はわかっていません。

この設定をfalseにすることでdisconnectの必要がなくなりましたが、デプロイ後に注意が必要になります。 document によると trueの場合アプリケーションの読み込みエラーが発生すると、マスタープロセスはエラーで終了していましたが、 falseだとデプロイ後に自分でアプリケーションが動作しているか確認する必要があります。

pros

  • 不要なDB接続の切断・再接続がなくなり、処理がシンプルになる。

    cons

  • preload_app falseにすることで、アプリケーションの初期化時の挙動が変わる可能性がある。
  • デプロイ後に自分でアプリケーションが動作しているか確認する必要がある。

コード

config/unicorn.rb

下記のコードはSemanticLoggerの部分は使用していた場合です。 SemanticLoggerの document にはfork後にreopenする必要があると書いてあります がpreload_app falseの場合after_forkの後でアプリケーションを読み込んでいるのでreopenは不要です。 これ以外にもpreload_appがtrueだったおかげで読み込めていた定数が使用されている箇所には preload_appの設定の更新の度、削除したり追加したりするのは面倒なのでdefined?をつけておくと良いと思います。

preload_app false

after_fork do |server, worker|
  # 省略

  # Re-open appenders after forking the process
  defined?(SemanticLogger) && SemanticLogger.reopen
end
コントローラ側
around_action :user_write_connection, only: %i[show]

def user_write_connection(&block)
  ApplicationRecord.connected_to(role: :writing, prevent_writes: false, &block)
end

対応手段4 ActiveRecord::Base.connected_toのprevent_writesをfalseにする

対応手段3の追加調査で SQLを実行するところ で writeするかどうかを判定するメソッドである ActiveRecord::ConnectionAdapters::Mysql2Adapterprevent_writestrueになっていることがわかりました。

def preventing_writes?
  return true if replica?
  return ActiveRecord::Base.connection_handler.prevent_writes if ActiveRecord::Base.legacy_connection_handling
  return false if connection_klass.nil?

  connection_klass.current_preventing_writes
end

github

このときのconnection_klassActiveRecord::Basecurrent_preventing_writestrueになっていました。 そこで、この コメント の通り

def user_write_connection(&block)
  ActiveRecord::Base.connected_to(role: :writing, &block)
end

と囲むことでprevent_writesfalseになり、ReadOnlyErrorにならずSQLが実行されるようになりました。 この方法だとunicornの設定を変えずともGETアクションで書き込めるようになります。

複数のデータベースの接続確認

spacelyでは複数DBを使っており、DBごとに基底クラスを下記のように用意しています。

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true
  connects_to database: { writing: :default, reading: :default_replica }
end

class AnotherApplicationRecord < ApplicationRecord
  self.abstract_class = true
  connects_to database: { writing: :another, reading: :another_replica }
end

そのため、AnotherDBへの書き込みのときは下記のように書かなければread_replicaに書き込みが走ってしまうのではないかと考えたのですが

def another_write_connection(&block)
  ActiveRecord::Base.connected_to(role: :writing) do
    AnotherApplicationRecord.connected_to(role: :writing, &block)
  end
end

GETアクション中で確認したところ、下記のようにしっかり切り替わっていました。 なのでaround_actionに指定するメソッドはDBごとに用意する必要はなく一つだけで良さそうです。

ApplicationRecord.connection_db_config.name
#=> "default_replica"

ActiveRecord::Base.connected_to(role: :writing) do
  ApplicationRecord.connection_db_config.name
end
#=> "default"

AnotherApplicationRecord.connection_db_config.name
#=> "another_replica"

ActiveRecord::Base.connected_to(role: :writing){ AnotherApplicationRecord.connection_db_config.name }
#=> "another"

対応手段5 GETでレコードをいじらない

GETアクションでのデータの作成や更新は避けるべきです。 これらをPOSTやPUTに変更していくことでaround_actionのような余計な設定を消していきたいと思います。 GETのアクションをPOSTにリダイレクトすることはできません。そのためroutes.rbの変更とUI側の変更が必要になります。 link_tobutton_toもしくはform_withに変更することで対応できます。

最後に

弊社では対応手段5を採用し、どうしても難しい場所のみ対応手段4を採用することにしました。 弊社のunicorn環境でもリードレプリカ機能を導入できることがわかりDBの負荷を減らせそうです。 しかし、なぜread DBへの接続が優先されてしまうのかはまだわかっていません。 まだまだrailsのdb connection周りの理解が浅いことがわかりましたので今後も勉強していきたいと思います。

この記事の執筆や開発にはChatGPT-4とGitHub Copilotを用いました。弊社ではこういったツールを積極的に取り入れ、開発の品質と速度を大幅に向上させています。 今季中にrails7, ruby3系対応をすすめていきます。 spacelyでは一緒に働いてくださる方を大募集中です。

執筆者はtoshichanappでした。

おまけ ローカルでunicornを起動する

コマンド

bundle exec unicorn_rails -c config/unicorn.rb

localhost:8080でアクセス可能

ローカルでunicornを起動する際の注意点

  • worker_processesの設定
    • 設定値: worker_processes 1
    • 理由:ローカル環境での開発時は、リソースの消費を抑えるために、ワーカープロセスを1つに制限します。
  • ログの出力先
    • 設定: stderr_path と stdout_path をコメントアウト
    • 理由:ローカル環境では、ログを直接コンソール上に出力したいため、ファイルへの出力をオフにします。
  • after_fork内の設定
    • 設定:
$stdout.sync = true
$stderr.sync = true
  • 理由:ログ出力を即座に反映させるため、stdoutとstderrの同期を有効にします。これにより、ログメッセージがバッファリングされることなく、リアルタイムで表示されます。また、binding.pryでデバッグができます。
  • defined?まわり
    • 理由: preload_appの設定の更新の度、削除したり加筆するのは面倒なのでdefined?をつけておく

設定ファイル

worker_processes 1
listen 8080
timeout 3600
pid "tmp/unicorn.pid"
preload_app false
check_client_connection false
run_once = true

before_fork do |server, worker|

  defined?(ActiveRecord::Base) and
    ActiveRecord::Base.connection.disconnect!

  if run_once
    run_once = false
  end

  old_pid = "#{server.config[:pid]}.oldbin"
  if old_pid != server.pid
    begin
      sig = (worker.nr + 1) >= server.worker_processes ? :QUIT : :TTOU
      Process.kill(sig, File.read(old_pid).to_i)
    rescue Errno::ENOENT, Errno::ESRCH
    end
  end
 
  sleep 1
end

after_fork do |server, worker|
  defined?(ActiveRecord::Base) and
    ActiveRecord::Base.establish_connection

  # Re-open appenders after forking the process
  defined?(SemanticLogger) && SemanticLogger.reopen
  
  $stdout.sync = true
  $stderr.sync = true
end