Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Solid Queue + SQLite3 で Puma プラグインをasyncモードに設定した話

こんにちは、hachi8833です。

私の個人アプリ↓では全面的にSQLite3を使っています。

Rails 8.1 + Ruby 4.0.1で作り直したenno.jpアプリをfly.ioでリリースしました

そのサブプロジェクトとなるRails APIアプリで初めてSolid Queueを使ったのですが、思わぬところでハマりました。

セットアップ

  • Rails 8.1.2、Ruby 4.0.1
  • Solid Cache/Solid Cable/Solid QueueでSQLite3を利用
  • Webサーバー: Puma
  • デプロイ先: fly.io
  • fly.io上のSQLite3ファイルはlitestreamCloudflare R2ストレージに定期的にレプリケートしている

以下のシリーズ記事にもあるように、Rails 8.1のSQLite3最適化は既に十分進んでいるので、SQLite3のWALモードなどのpragmaはRailsのデフォルトから特に変えていません。

RailsでSQLite3を活用する(全14回)

経緯

このセットアップでアプリをデプロイしたところ、最初は普通に動いているのに、5分ほど放置すると完全に応答を停止してしまうという現象が発生しました。

fly.ioのメトリクスを見てみると、Solid Queueが動くとメモリ容量が爆伸びしています。Claudeがfly.tomlでVM割り当てを1GBに変えようとしたので慌てて止めました。

応答を停止したときに以下のSQLite3::BusyExceptionログが出力されるのはまだいい方で、ログに何も出力されないままサーバーが息をしなくなってしまうことも2回に1回ぐらいのペースで発生しました。

2026-03-03T06:46:18Z app[781561eb377098] nrt [info]SolidQueue-1.3.2 Error in thread (0.0ms)  error: "ActiveRecord::StatementTimeout SQLite3::BusyException: database is locked"
2026-03-03T06:46:20Z app[781561eb377098] nrt [info]SolidQueue-1.3.2 Error in thread (239.8ms)  error: "ActiveRecord::StatementTimeout SQLite3::BusyException: database is locked"

原因

Claudeとともに、config/litestream.ymlのレプリケーション間隔を緩めたり、config/database.ymlのタイムアウトを長めに取ったりとあれこれ調整したのですが、少しばかりマシにはなったものの不安定さは変わりませんでした。

最終的に効いたのは、以下のようにconfig/puma.rbにsolid_queue_mode :asyncを指定したことでした。以後ピタリと現象は収まり、完全に安定稼働しています。

# config/puma.rb
...
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count

# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000)

# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart

# Run the Solid Queue supervisor inside of Puma for single-server deployments.
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
+# Use async mode (threads, not forked processes) to avoid SQLite write-lock
+# contention and memory bloat on a 512 MB VM.
+solid_queue_mode :async if ENV["SOLID_QUEUE_IN_PUMA"]

# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]

なお、if ENV["SOLID_QUEUE_IN_PUMA"]はRails 8.1のrails new で生成されるデフォルトのconfig/deploy.ymlで設定されています。

# config/deploy.yml
...
env:
  secret:
    - RAILS_MASTER_KEY
  clear:
    # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
    # When you start using multiple servers, you should split out job processing to a dedicated machine.
    SOLID_QUEUE_IN_PUMA: true
...

私のRailsアプリの規模が小さいこともあってか、asyncモードにしたことのトレードオフらしきものは今のところ自分のアプリでは見られず、むしろasyncモードの方がレスポンスもきびきびしています。

あくまで現時点の経験則ですが、少なくとも小〜中規模の(マルチプロセスでない)シンプルなRailsアプリでSolid Queue + SQLite3 を使うなら、puma.rbでsolid_queue_mode :asyncを指定してasyncにすべきだと現時点では思っています。

もちろん小〜中規模アプリでasyncモードにしておくだけでなく、SQLite3のデータベースファイルがあくまで「行単位ではなくファイル単位でロックされる」ことを十分認識したうえで、長時間ロックを避けて使うべきだと思います。アプリの改修が進んで頻繁なロックを避けるのが難しくなってきたら、そのときこそPostgreSQLやMySQLなどの本格的なRDBMS、もしくはRedisなどの本格的なストレージに移行すべきでしょう。

Solid Queue側ではどうなっているのか

Solid Queueをチェックしてみたところ、最新のREADMEにasyncのことが追記されていました(#644)。asyncがマージされたのは今年の1/9なのでついこの間です。

せっかくなので以下のREADMEを更新翻訳しました。

Solid Queue README -- DBベースのActive Jobバックエンド(翻訳)

🔗 forkモード vs asyncモード

Solid Queueはデフォルトではforkモードで実行されます。これは、スーパバイザが管理する個別の「ワーカー」「ディスパッチャ」「スケジューラ」ごとに別プロセスをforkするということです。
forkモードは分離とパフォーマンスを最大化しますが、その分メモリ使用量が増加し、一部のRuby実装でうまく動かなくなる可能性もあります。

asyncモードでSolid Queueを実行すると、「ワーカー」「ディスパッチャ」「スケジューラ」がスーパバイザと同一のプロセス内で別スレッドとして実行されるようになります。asyncモードに設定するには、以下のbin/jobsコマンドを実行します。

bin/jobs --mode async

または、SOLID_QUEUE_SUPERVISOR_MODE環境変数にasyncを設定することでasyncモードにすることも可能です。asyncモードにする場合、後述のprocessesオプションは無視されます。

forkモードはデフォルトの推奨モードです。asyncモードは、どうしても使いたい理由がある場合にのみ使うこと。

Solid Queue README -- DBベースのActive Jobバックエンド(翻訳)より

Solid QueueとSQLite3の組み合わせ

ここから先は私の感想というか推測です。

READMEでは見ての通りforkモードを推奨しており、async モードは理由がない限り使わないこととされています。

しかし私のアプリのセットアップでfork モードにすると、上述のように応答停止してしまいます。

もしかすると、Solid Queueのfork / async の説明は、PostgreSQLやMySQLのような行ロックが可能なRDBMSが無意識に前提にされているのではないでしょうか?

ご存知の通りSQLite3は行ロックではなくファイル単位のロックなので、書き込みのために誰かがファイルを掴んでしまえば、他のアクセスはロックが解除されるまで待つしかありません。

うろ覚えですが、Claudeの説明ではforkモードだと複数のワーカーがSQLite3ファイルを掴んでしまうからではとのことで、その点は上述のREADMEとも整合します。

Solid Queueのforkモードは、少なくともSQLite3を使う場合には適していないように思えます。


もうひとつ気になる点があります。

以下は上で引用したconfig/deploy.ymlの再録ですが、rails newで生成されるデフォルトのコメントには「ジョブを実行するには、Solid QueueのスーパバイザをWebサーバーのPumaプロセス内で実行する」「マルチプルサーバーを起動する場合は、ジョブの実行を専用のマシンに分離すること」とあります。

# config/deploy.yml
...
env:
  secret:
    - RAILS_MASTER_KEY
  clear:
    # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
    # When you start using multiple servers, you should split out job processing to a dedicated machine.
    SOLID_QUEUE_IN_PUMA: true
...

これを見る限りでは、puma.rbにRailsのデフォルトとしてSOLID_QUEUE_IN_PUMA: trueが記述されているなら、環境を問わずPumaプラグインが有効になるので、スーパバイザは環境を問わずPumaプロセス内部でジョブを実行する、つまりsolid_queue_mode :asyncに相当する動作が元々前提にされていたのではないかという気がします(もちろん非マルチプロセスが前提です)。

だとすると、Solid Queueで最近追加されたasyncモードについて、Rails側とSolid Queue側の扱いが若干食い違っているかもしれません。Solid Queueで単にforkモードを推奨するよりも、「マルチプルサーバーを起動する場合は、ジョブの実行を専用のマシンに分離すること」ことの方が優先度が高いのではという気がします。

このあたりについて詳しい方の情報・ツッコミお待ちしています🙏

関連記事

Solid Queue README -- DBベースのActive Jobバックエンド(翻訳)

Railsスケーリング(1)Puma、コンカレンシー、GVLのパフォーマンスへの影響を理解する(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。