Solid Queueにasyncモードを追加し、メモリ使用量を60%削減
Solid Queueに asyncモード が追加され、workerとdispatcherを別プロセスではなく同一プロセス内の異なるスレッドで実行できるようになりました。これにより、1GBの小規模サーバーでも安定した運用が可能になります。
背景
Solid Queueはこれまで forkモード のみをサポートしており、supervisor配下の各worker、dispatcher、schedulerは独立したプロセスとして起動していました。この設計はプロセス間の隔離性とパフォーマンスを提供しますが、メモリ制約のある環境では課題がありました。
PR本文では、1GBのVPS環境でKamalを使用したデプロイ時の問題が報告されています。Solid Queueが1プロセスあたり約100MBを消費し、複数プロセスの起動により総メモリ使用量が700MBに達していました。この状態では、2回目のデプロイ時にメモリ不足が発生し、デプロイが失敗する事態が生じていました。
#343 で同様の問題が議論されており、メモリ制約のある環境での運用が困難である点が指摘されていました。本PRはこの課題に対する解決策として、以前に提案された asyncモードの実装案 を再実装したものです。
技術的な変更
Supervisorの分離
Supervisor クラスが ForkSupervisor と AsyncSupervisor に分割されました。モード選択は Configuration クラスで行われ、起動時に適切なクラスが生成されます。
def start(**options)
configuration = Configuration.new(**options)
if configuration.valid?
klass = configuration.mode.fork? ? ForkSupervisor : AsyncSupervisor
klass.new(configuration).tap(&:start)
else
abort configuration.errors.full_messages.join("\n") + "\nExiting..."
end
end
ForkSupervisor は従来のfork処理を担当し、AsyncSupervisor はスレッドベースの管理を担当します。この分離により、既存のforkモードの動作を変更せずに新しいasyncモードを追加できています。
AsyncSupervisorの実装
AsyncSupervisor は、supervisorと同一プロセス内でworkerとdispatcherをスレッドとして起動します。
private
def supervise
if standalone? then super
else
@thread = create_thread { super }
end
end
def check_and_replace_terminated_processes
terminated_threads = process_instances.select { |thread_id, instance| !instance.alive? }
terminated_threads.each { |thread_id, instance| replace_thread(thread_id, instance) }
end
standalone? メソッドにより、Solid Queue単体での起動とPumaプラグインとしての起動を区別しています。単体起動時は従来通りの動作を維持し、プラグインとして動作する場合はスレッドを生成してsupervise処理を行います。
プロセス監視もスレッドに対応しており、check_and_replace_terminated_processes では alive? メソッドでスレッドの生存確認を行い、終了したスレッドを replace_thread で再起動します。
Runnableの拡張
Processes::Runnable モジュールに run_in_mode メソッドが追加され、実行モードに応じた起動方法の切り替えが実装されました。
def start
run_in_mode do
boot
run
end
end
private
def run_in_mode(&block)
case
when running_as_fork?
fork(&block)
when running_async?
@thread = create_thread(&block)
@thread.object_id
else
block.call
end
end
この実装により、worker、dispatcher、schedulerの各クラスは起動モードを意識せず、start メソッドを呼び出すだけで適切な方法で起動されます。asyncモードでは create_thread がスレッド作成と例外処理を担当し、スレッドオブジェクトIDが返されます。
CLIとPumaプラグインの対応
CLIに --mode オプションが追加されました。
class_option :mode, type: :string, default: "fork", enum: %w[ fork async ],
desc: "Whether to fork processes for workers and dispatchers (fork) or to run these in the same process as the supervisor (async) (default: fork).",
banner: "SOLID_QUEUE_SUPERVISOR_MODE"
Pumaプラグインでは、puma.rb で solid_queue_mode メソッドを使用してモードを指定できます。
module Puma
class DSL
def solid_queue_mode(mode = :fork)
@options[:solid_queue_mode] = mode.to_sym
end
end
end
plugin :solid_queue
solid_queue_mode :async
プラグイン内部では、指定されたモードに応じて start_forked または start_async が呼び出されます。asyncモードでは、Pumaプロセスと同一プロセス内でSolid Queueが動作するため、メモリ使用量が大幅に削減されます。
プロセス数設定の無効化
asyncモードでは、設定ファイルの processes オプションが無視されます。
def workers
workers_options.flat_map do |worker_options|
processes = if mode.fork?
worker_options.fetch(:processes, WORKER_DEFAULTS[:processes])
else
1
end
processes.times.map { Process.new(:worker, worker_options.with_defaults(WORKER_DEFAULTS)) }
end
end
この制約により、asyncモードではworkerは常に1つのスレッドとして起動されます。複数プロセスによる並列処理はforkモードの特権であり、asyncモードではスレッドプール内での並列実行のみが可能です。
設計判断
ForkとAsyncの明確な分離
Supervisor を継承した ForkSupervisor と AsyncSupervisor という2つの独立したクラスが作成されました。これにより、既存のforkモードの動作を保証しつつ、asyncモードを追加できています。
READMEには「推奨され、デフォルトのモードはforkです。asyncは理由があり、何をしているか理解している場合のみ使用してください」という警告が追加されています。この文言は、forkモードが標準であり、asyncモードは特殊な用途向けであることを明確にしています。
シグナルハンドリングの条件付き登録
シグナルハンドラーの登録が standalone? 条件付きになりました。
included do
before_boot :register_signal_handlers, if: :standalone?
after_shutdown :restore_default_signal_handlers, if: :standalone?
end
Pumaプラグインとして動作する場合、Solid Queueは独立したプロセスではないため、独自のシグナルハンドラーを登録する必要がありません。この判断により、Pumaのシグナル処理との競合を回避しています。
終了処理の相違点
asyncモードでは、KILLシグナルによるsupervisor終了時に実行中のジョブを完了できません。テストコードにはこの挙動に関するコメントが含まれています。
# In async mode, killing the supervisor kills all threads too,
# so we can't complete in-flight jobs
この制約は、スレッドがプロセスと生存期間を共有するためです。forkモードでは、各workerが独立したプロセスとして動作するため、supervisor終了後も実行中のジョブを完了できますが、asyncモードではこの保証がありません。
まとめ
本PRは、メモリ制約のある環境でのSolid Queue運用を可能にするasyncモードを追加しました。実際の検証では、1GBサーバーでのメモリ使用量が700MBから270MBへと60%以上削減されています。ForkSupervisorとAsyncSupervisorの分離により、既存のforkモードの動作を変更せず、新しい実行モデルを追加できています。ただし、asyncモードは実行中ジョブの保証が弱いため、用途を理解した上での使用が推奨されています。