概要
弊社のサーバ(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



