ITエンジニアによるITエンジニアのためのブログ

RailsでのDBコネクション不足の例外

Ruby on Railsアプリケーションを運用する際、各プロセス内のスレッド数やシステム全体のDBコネクション数を考慮せずに運用すると、コネクション不足による例外が発生します。この記事では、例外の具体的な原因と解決方法について解説します。

なお、前提として本記事ではウェブサーバーにpuma、ワーカーにsidekiq、データベースにPostgreSQLを使います。

DBコネクション不足エラー

DBコネクション数が不足している場合、以下のいずれかの例外が発生します。

スレッド数がDBコネクションプールサイズを超過した場合の例外:

ActiveRecord::ConnectionTimeoutError (could not obtain a connection from the pool within 5.000 seconds (waited 21.390 seconds); all pooled connections were in use):

システム全体のDBコネクション数不足の例外:

# ActiveRecord内の例外
FATAL:  sorry, too many clients already (ActiveRecord::ConnectionNotEstablished)

# pg gem内の例外(ActiveRecordの例外より浅いスタックで発生)
FATAL:  sorry, too many clients already (PG::ConnectionBad)

それぞれの例外の発生原因と、対策について解説します。

スレッド数がDBコネクションプールサイズを超過

各プロセスにおいて、スレッド数がコネクションプールサイズを超えると「ActiveRecord::ConnectionTimeoutError」が発生します。この例外はウェブプロセス、ワーカープロセスいずれでも発生しうるため、それぞれの場合で例外が起こる理由と解決策を解説します。

ウェブプロセス

ウェブプロセスにおけるこの例外は、以下のようなコードをRailsコントローラーのアクションに記載し、コネクションプールサイズ以上のスレッドを生成することで、ローカル環境で確認できます。

# config/database.yml
development:
 pool: 5

# app/controllers/examples_controller.rb
# コネクションプール数以上のスレッドを生成し、各スレッドで5秒以上コネクションを使う。
def show
  6.times do |n|
    Thread.new do
      begin        ActiveRecord::Base.connection.execute("select pg_sleep(6)")
        puts 'コネクション数'
       ActiveRecord::Base.connection_pool.with_connection { puts ActiveRecord::Base.connection_pool.connections.size }
      rescue ActiveRecord::ConnectionTimeoutError
        puts "エラー スレッド#{n}"
      end
    end
  end
end

実際には上記のようなコードを使うことは稀で、本番環境のアプリケーションコードでは各Pumaスレッドが1リクエストを処理します。各Pumaスレッドは1つのDBコネクションを使用するため、各プロセス内のPumaスレッド数がコネクションプール数を超過し、新しいデータベースコネクションを5秒以内に獲得できないと、この例外が発生します。

逆に言えば、各プロセスのPumaスレッド数がコネクションプールサイズを超過しなければこの例外は発生しません。具体的には、config/database.ymlのpoolと、config/puma.rbのmax_threads_countを同じ値に設定します。

# config/database.yml
default: &default
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

# config/puma.rb
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }

上記はデフォルトで生成されるコードですが、RAILS_MAX_THREADS環境変数が設定されていればその値、設定されていなければ5がそれぞれの設定ファイルで適用され、両者が同じ値になるようになっています。

なお、システム全体でのプロセス数やスレッド数はここでは関係なく、あくまで各プロセス内でのスレッド数とコネクションプールサイズの設定の不一致によってこの例外が発生します。

ワーカープロセス

各ワーカープロセスにおいて、スレッド数がコネクションプール数を超えると、ウェブプロセスの場合と同様にActiveRecord::ConnectionTimeoutErrorが起きます。

ローカル環境での再現方法

この例外をローカル環境で再現するには、コネクションプールサイズ以上のSidekiqスレッドを実行します。

Sidekiqスレッド数(concurrency)を5に設定。sidekiqでは各スレッドが1つのDBコネクションを使うので、スレッド数は1プロセスあたりのDBコネクション数と等しくなります。

# config/sidekiq.yml
:concurrency: 5

DBコネクションプール数を4に設定。

# config/database.yml
development:
 pool: 4

この状態で、5つのジョブを同時に処理しようとすると、ウェブプロセスの場合と同様にコネクション数が足りていない旨のActiveRecord::ConnectionTimeoutErrorが発生します。

# app/jobs/slow_job.rb
class SlowJob < ApplicationJob
  def perform
    ActiveRecord::Base.connection.execute("SELECT pg_sleep(6)")
  end
end

# rails console
5.times { SlowJob.perform_later }

# bash
bundle exec sidekiq

コネクションプールサイズがスレッド数に対して足りていないという状態は、psqlからも確認できます。

# psql
SELECT * FROM pg_stat_activity WHERE datname = 'dbname' and application_name != 'psql';

# 出力例
              application_name              
--------------------------------------------
 sidekiq 6.4.0 [0 of 5 busy]
 sidekiq 6.4.0 [0 of 5 busy]
 sidekiq 6.4.0 [0 of 5 busy]
 sidekiq 6.4.0 [0 of 5 busy]

psqlの上記クエリではsidekiqコネクションのapplication_nameカラムに0 of 5と、スレッドが5個あると表示されますが、実際のレコード(DBコネクション)は4つしか表示されません。つまり、コネクションプール数がスレッド数に対して足りていないことになります。

適切な設定値

スレッド数がDBコネクションプールサイズを超過するのを防ぐためには、両者の数を同じに設定する必要があります。

# config/database.yml
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

# config/sidekiq.yml
# RAILS_MAX_THREADSと同じ値。設定されていなければdatabase.ymlデフォルトの5と同じ値とする。
:concurrency: 5

なお、DBコネクションプール数を5に設定し、sidekiqのconcurrencyをそれ未満、例えば4に設定すると、コネクション数エラーは発生しませんが、スレッドが4つしか生成されませんのでDBコネクションも4つしか使われません。これは、DBコネクション最大値の5に対してシステムのポテンシャルを活かしきっておらず、並列処理能力が低い状態になっているため推奨しません。

システム全体のDBコネクション数不足

各プロセス内のスレッド数とDBコネクションプール数に問題がなくても、システム全体でデータベースの最大コネクション数を使い切ってしまうと、ActiveRecord::ConnectionNotEstablished、またはPG::ConnectionBadという例外が発生します。

FATAL:  sorry, too many clients already (ActiveRecord::ConnectionNotEstablished)

FATAL:  sorry, too many clients already (PG::ConnectionBad)

ローカル環境での再現方法

この例外をローカル環境で再現するには、使用中のDBコネクション数がPostgreSQLの最大コネクション数を超過するようにする必要があります。PostgreSQLのデフォルトの最大コネクション数は100なので、100以上のスレッドを作成するよりは、設定値を1桁台にした方が簡単です。

# psql
# 現在の最大コネクション数を表示。
SHOW max_connections;

# 最大コネクション数を変更。変更後はPosgreSQLサーバーを再起動すると設定が適用されます。
ALTER SYSTEM SET max_connections TO '5';

この状態で、Pumaスレッドからのコネクション数が最大値の5を超過し、かつRailsのDBコネクションプールサイズは超えないようにします。なお、psqlのコネクションもPostgreSQLのコネクション数にカウントされるため、その分を考慮した設定にするか、もしくはpsqlは閉じておきます。

# DBコネクションプールサイズはスレッド数と同じ6にして、ActiveRecord::ConnectionTimeoutErrorが発生しないようにする。

# config/database.yml
development:
 pool: 6

# ActiveRecord::ConnectionNotEstablishedをrescue
# app/controllers/examples_controller.rb
def show
  6.times do |n|
    Thread.new do
      begin        ActiveRecord::Base.connection.execute("select pg_sleep(6)")
        puts 'コネクション数'
       ActiveRecord::Base.connection_pool.with_connection { puts ActiveRecord::Base.connection_pool.connections.size }
       rescue ActiveRecord::ConnectionTimeoutError
          puts "ActiveRecord::ConnectionTimeoutErrorエラー スレッド#{n}"
      rescue ActiveRecord::ConnectionNotEstablished
        puts "ActiveRecord::ConnectionNotEstablishedエラー スレッド#{n}"
      end
    end
  end
end

システム全体で必要なDBコネクション数

ActiveRecord::ConnectionNotEstablishedの例外を防ぐには十分なコネクション数が必要ですが、どのようにその値を求めたらよいでしょうか。

もしウェブプロセスしかなくワーカープロセスを使用していないと仮定すると、システム全体で必要なDBコネクション数は以下の式で求めることができます。

puma最大スレッド数 x puma worker数 x サーバー(コンテナ)数

puma最大スレッド数は、DBコネクションプールエラーの解説で述べたように1プロセス辺りの最大スレッド数の値で、config/puma.rbmax_threads_countで指定します。

puma workerはpumaのclusteredモード使用時における各サーバー内のwebサーバーのプロセス数となります。この値は同じくconfig/puma.rbWEB_CONCURRENCYという環境変数によって設定されます。

workers ENV.fetch("WEB_CONCURRENCY") { 2 }

clusteredモードを使っていない場合、puma worker数は1ですので、システム全体で必要なコネクション数は以下の通りです。

puma最大スレッド数 x 1 x サーバー(コンテナ)数 = puma最大スレッド数 x サーバー(コンテナ)数

例えば、pumaの最大スレッド数が5で、10個のサーバーを運用している場合、システム全体で必要なDBコネクション数は50個となります。

ただし、ワーカーを使っている場合、ワーカーが使用するコネクション数の考慮に入れないと、コネクションが足りなくなってエラーが発生する恐れがあります。ワーカーの必要DBコネクション数については、以下の式で表せます。

スレッド数(各プロセス) x プロセス数 x サーバー(コンテナ)数

Sidekiqの場合、マルチプロセスはSidekiq Enterprise版のみの機能なので、ほどんどのアプリケーションでは各サーバーで1つのSidekiqプロセスのみが実行されているはずです。つまり、上記式は以下のように簡略化できます。

スレッド数(各プロセス) x サーバー(コンテナ)数 

例えば、各プロセス内スレッド数の設定を5にし、ワーカーサーバーを2台運用している場合、必要なDBコネクション数は10個になります。

ここまでの話をまとめると、システム全体で必要なDBコネクション数は以下の通りです。

puma最大スレッド数 x puma worker数 x ウェブサーバー(コンテナ)数 + ワーカースレッド数(各プロセス) x ワーカープロセス数 x ワーカーサーバー(コンテナ)数 

例えば以下のようなシステムで必要なDBコネクション数は、5 x 1 x 10 + 5 x 1 x 2 = 50 + 10 = 60となります。

  • puma最大スレッド数: 5
  • puma worker数:1
  • ウェブサーバー数:10
  • sidekiqスレッド数(各プロセス内): 5
  • sidekiqプロセス数(各サーバー内):1
  • ワーカーサーバー数 : 2

つまり、最大コネクション数の設定が60個以上のPostgresサーバーであればコネクション不足エラーは起きないことになります。この数は前述の通りpsqlでSHOW max_connectionsコマンドを使うことで確認できます。自身のPostgresサーバーを運用していれば設定数を変更するだけで良いですが、もしHerokuなどのPaaSを使っている場合は契約プランによって使える最大コネクション数が決まっていることがあるので、適切なプランを選択することが必要になってきます。

まとめ

Ruby on RailsでDBコネクション不足が原因で起こる例外には、各プロセス内のコネクションプール不足が原因のものと、システム全体でコネクション数が不足するものの2種類があります。それぞれのケースで原因と解決方法をきちんと理解すれば、例外を防ぎつつシステムのポテンシャルを最大限活かすことができるようになります。