概要
弊社のサーバ(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に接続する方法の検討リストです。
- モンキーパッチの利用
- around_action で再接続
- unicornの設定を変える
- ActiveRecord::Baseのprevent_writesをfalseにする
- GETからPOSTやPUTなどの適切なHTTPメソッドに変更する
対応手段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が必要なのは
unicornのpreload_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::Mysql2Adapter
のprevent_writes
がtrue
になっていることがわかりました。
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
このときのconnection_klass
がActiveRecord::Base
で current_preventing_writes
がtrue
になっていました。
そこで、この コメント の通り
def user_write_connection(&block) ActiveRecord::Base.connected_to(role: :writing, &block) end
と囲むことでprevent_writes
がfalse
になり、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_to
をbutton_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?
をつけておく
- 理由: preload_appの設定の更新の度、削除したり加筆するのは面倒なので
設定ファイル
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