RedisとAWS設定から解放!Solid Queueで実現する、RailsアプリのDB完結型ジョブスケジューリング

🏁 はじめに

株式会社スタメンにてプラットフォーム部で SRE / DevEx などに取り組んでいるもりしたです。今回は Ruby on Rails アプリケーションに Solid Queue を導入したお話を書こうと思います。

こんな人に読んでもらえるとうれしく思います。

  • Solid Queue に興味がある
  • Ruby on Rails アプリケーションで手軽に定期実行ジョブを作りたい
  • すでに稼働している定期実行ジョブの Solid Queue へのリプレイスを検討している

💡 Solid Queueとは? DB完結型ジョブキューの基礎

Solid Queue は Active Job 向けに設計された、DBベースのキューイングバックエンドです。

  • SidekiqResque のように Redis などの外部サービスが不要
  • Recurring Tasks 機能を利用することで Cronのように、設定されたスケジュールに従ってジョブを自動的に定期実行できる
  • 利用するDBは既存アプリケーションと共存することも Solid Queue 独自で分離することもできる

📌 導入の背景

ジョブを定期実行したかったためです。
スタメンの主力事業 TUNAG(ツナグ) は TUNAGのコア部分(以降、TUNAG本体)と機能単位で切り出した複数サービスが連携して動作していますが、機能単位で切り出したタスク機能サービスで定期的に実行したい処理がありました。
しかしながら、タスク機能サービスにはジョブを定期実行する基盤がなかったため、基盤構築から始めることとなりました。

⚙️ 既存の定期実行システム:AWS/ECS連携のアーキテクチャ

まずは既存の定期実行がどのように動いているかを確認しました。
TUNAG本体では elastic_whenever gem を利用して ECS Scheduled Task にて rake タスクを実行しています。
rake タスクを追加して定期実行するまでの流れは下記となります。

  • プロダクトコードにて管理するスケジュールファイル config/schedule.rb で下記のように定義する
every '*/5 * * * *' do # 5 分おき
  rake 'scheduled:foo:bar_baz'
end
  • デプロイ時に定義内容から Amazon EventBridge ルールを作成する
  • Amazon EventBridge ルールのイベントスケジュールでECSタスクが起動して rake タスクが実行される

ECS Scheduled Task を利用した定期処理については 2021年のテックブログでも紹介しています
tech.stmn.co.jp

このアーキテクチャの場合、ジョブを動かすためにAWS側の設定を行う必要がありました。 どのように実現しているのか全体像の理解がやや難しいと感じています。

✅ Solid Queueを選んだ理由:Redis/AWSからの脱却

既存の定期実行の仕組みを把握したうえで、新しくタスク機能サービスに導入する定期実行の基盤として Solid Queue を選びました。

  • 複雑なインフラ環境の設定はなるべく行いたくない
  • なるべく標準以外の新しいライブラリは増やしたくなかった
  • Solid Queue を小さいところから導入を試してみたかった

ECS Scheduled Task を利用した仕組みは実績がありますが、AWS側の設定を行うなどの準備が多く、今回はなるべくその手間を減らしたかった。
スタメンでは利用ライブラリのアップデートを毎週行っており、その対象が増えるため、elastic_whenever のような gem をなるべく増やしたくなかった。一方で Solid Queue は、Ruby on Rails の一部であるため、gem が増えても許容できると考えました。

以上が理由の2つですが、結局は、 Solid Queue を試してみたい が一番の理由です。
タスク機能サービスは影響範囲が絞られた小さいサービスなので導入を試してみるには最適でした。

🖥️ Mission Control — Jobs:稼働状況の確認

後述の導入手順で設定が完了し、ジョブが動くようになったら Mission Control — Jobs で実行状況が確認できます。
Sidekiq のダッシュボードと比べると非常にシンプルな印象です。

本番で動いている Mission Control — Jobs の画面をピックアップしてご紹介します。

Recurring tasks タブ:スケジュールの一元管理

ここが Solid Queue による定期実行の最大のメリットを示す画面です。
従来、config/schedule.rb ファイルや Amazon EventBridge ルールに分散していたスケジュール定義が、リポジトリ内の config/recurring.yml ファイルに集約され、その実行状況がこのダッシュボードで一元管理できます。

  • Recurring(=繰り返し)のタスクとして登録したジョブを確認できます
  • 1つ目のジョブであれば、月〜金の 10:00 に繰り返し実行します
  • Run now を押下するとジョブを即時実行できます

Finished jobs タブ

完了したジョブが一覧で確認できます

Finished jobs 詳細

実行にかかった時間などが確認できます

🛠️ 導入手順

今回導入のために行った手順です。
Solid Queue と Mission Control — Jobs を導入します。
やりたいことによっては設定が異なる可能性があるので、あくまで参考として読んでください。

前提条件

  • Active Record マイグレーションでデータベーススキーマのマイグレーションを行っている
  • Solid Queue のテーブルは既存のDBに投入する。独自のDBは用意しない

Solid Queue

主には Solid Queue の README および README の翻訳記事を参考にしました github.com techracho.bpsinc.jp

  • Gemfile に gem "solid_queue" を追加する
  • bundle install で依存する gem をインストールし、Gemfile.lock を更新する
  • bin/rails solid_queue:install を実行する
# bin/rails solid_queue:install
      create  config/queue.yml
      create  config/recurring.yml
      create  db/queue_schema.rb
      create  bin/jobs
        gsub  config/environments/production.rb
  • Solid Queue 用のマイグレーションファイルを作成する
    • 前手順で Solid Queue で利用するテーブルのスキーマ定義が db/queue_schema.rb ファイルとして作られるが、このファイルができただけでは Active Record マイグレーションでDBにテーブルは作られない
    • マイグレーションを実行するためにファイルを作成する
# bundle exec rails generate migration CreateSolidQueueTables
      invoke  active_record
      create    db/migrate/20250819042726_create_solid_queue_tables.rb
  • 作成した Solid Queue 用のマイグレーションファイルに db/queue_schema.rb ファイルの内容を反映する
    • db/queue_schema.rb のテーブル名やカラム名は文字列で指定されているので文字列を Symbol に置き換え
      • これはマイグレーションファイルは基本、Symbol で定義しているための対応
      • 正規表現にて文字列を置き換えた
  • マイグレーションを実行
    • Solid Queue 用テーブルと外部キーがDBに反映され、合わせて db/schema.rb が更新される
# bin/rails db:migrate

== 20250819042726 CreateSolidQueueTables: migrating ===========================
-- create_table(:solid_queue_blocked_executions, {force: :cascade})
   -> 0.0317s

... 省略 ...

== 20250819042726 CreateSolidQueueTables: migrated (0.3559s) ==================

-- execute("SELECT extversion FROM pg_extension WHERE extname = 'citus'")
Model files unchanged.
  • 動作確認用ジョブを bin/rails generate job で作成する
    • はじめてジョブを生成した場合、 app/jobs/application_job.rb が作られる
# bin/rails generate job FooBar::BazQuxJob

      invoke  rspec
      create    spec/jobs/foo_bar/baz_ qux_job_spec.rb
      create  app/jobs/foo_bar/baz_qux_job.rb
      create  app/jobs/application_job.rb
  • 動作確認用ジョブを修正する
    • queue_as で指定する queue を任意で変更する
    • 実行したことが確認できるようにログ出力だけ実装する
class FooBar::BazQuxJob < ApplicationJob
  queue_as :background # generate したときは default となっている

  def perform(*args)
    Rails.logger.info("FooBar::BazQuxJob called with args: #{args.inspect}")
  end
end
  • 自動生成された ApplicationJob を確認する
    • self.queue_adapter = :solid_queue であること
    • これにより、ApplicationJob を継承したクラスが Solid Queue にて実行される
class ApplicationJob < ActiveJob::Base
  self.queue_adapter = :solid_queue

  # Automatically retry jobs that encountered a deadlock
  # retry_on ActiveRecord::Deadlocked

  # Most jobs are safe to ignore if the underlying records are no longer available
  # discard_on ActiveJob::DeserializationError
end
  • config/recurring.yml にて動作確認用ジョブをスケジューリングする
    • queue は 動作確認用ジョブ の queue_as で指定した queue と一致させる
    • 動作確認しやすいように短い周期の1分ごとに実行する
default: &default
  foo_bar_baz_qux:
    class: FooBar::BazQuxJob
    queue: background
    schedule: every 1 minutes

development:
  <<: *default

production:
  <<: *default
  • config/application.rb を修正
    • # require "active_job/railtie" のコメントアウトを外して、ActiveJob を有効化
    • 死活監視のために supervisor のPIDファイルを作成するようにする
config.solid_queue.supervisor_pidfile = Rails.application.root.join("tmp/pids/solid_queue_supervisor.pid")
  • config/queue.yml を必要に応じて修正する
    • 並列でジョブを実行する場合、threads、processes を調整する

以上で 動作確認用ジョブが定期実行できる状態となったかと思います。
次は動かしてみます。

動かしてみる

bin/jobs で起動します。
Dockerで起動する際、以下のコマンドにて、pid ファイルを削除してから起動するようにしました。

bash -c "rm -f tmp/pids/solid_queue_supervisor.pid && bin/jobs"

実行中のアプリケーションログ

アプリケーション起動〜動作確認用ジョブが2回実行されるまでのログです。
下記の3つのログが2回出力されていることから、ジョブがスケジューリングの周期でエンキューされて実行していることが分かります

  1. Enqueued FooBar::BazQuxJob
  2. Performing FooBar::BazQuxJob
  3. Performed FooBar::BazQuxJob
# tail -1000f log/development.log | grep -E "SolidQueue-1.2.2|ActiveJob"
SolidQueue-1.2.2 Register Supervisor (31.3ms)  pid: 1, hostname: "b9fb9f17bddb", process_id: 27, name: "supervisor-b90acad5c891087512fb"
SolidQueue-1.2.2 Started Supervisor (136.9ms)  pid: 1, hostname: "b9fb9f17bddb", process_id: 27, name: "supervisor-b90acad5c891087512fb"
SolidQueue-1.2.2 Prune dead processes (9.2ms)  size: 0
SolidQueue-1.2.2 Register Dispatcher (47.7ms)  pid: 17, hostname: "b9fb9f17bddb", process_id: 28, name: "dispatcher-fd60cfd986a70188ddad"
SolidQueue-1.2.2 Started Dispatcher (51.5ms)  pid: 17, hostname: "b9fb9f17bddb", process_id: 28, name: "dispatcher-fd60cfd986a70188ddad", polling_interval: 1, batch_size: 500, concurrency_maintenance_interval: 600
SolidQueue-1.2.2 Register Worker (50.4ms)  pid: 21, hostname: "b9fb9f17bddb", process_id: 29, name: "worker-7b1d65c64db3d68e456a"
SolidQueue-1.2.2 Register Scheduler (49.5ms)  pid: 25, hostname: "b9fb9f17bddb", process_id: 30, name: "scheduler-0aa92e643ddec8a6b637"
SolidQueue-1.2.2 Started Worker (54.6ms)  pid: 21, hostname: "b9fb9f17bddb", process_id: 29, name: "worker-7b1d65c64db3d68e456a", polling_interval: 0.1, queues: "*", thread_pool_size: 1
SolidQueue-1.2.2 Started Scheduler (74.4ms)  pid: 25, hostname: "b9fb9f17bddb", process_id: 30, name: "scheduler-0aa92e643ddec8a6b637", recurring_schedule: ["foo_bar_baz_qux"]
SolidQueue-1.2.2 Unblock jobs (17.6ms)  limit: 500, size: 0
[ActiveJob]   TRANSACTION (0.6ms)  BEGIN /*application='****'*/
[ActiveJob]   SolidQueue::Job Create (1.9ms)  INSERT INTO `solid_queue_jobs` (`active_job_id`, `arguments`, `class_name`, `concurrency_key`, `created_at`, `finished_at`, `priority`, `queue_name`, `scheduled_at`, `updated_at`) VALUES ('f061f654-51e9-425d-aa84-2100f4e559dd', '{\"job_class\":\"FooBar::BazQuxJob\",\"job_id\":\"f061f654-51e9-425d-aa84-2100f4e559dd\",\"provider_job_id\":null,\"queue_name\":\"background\",\"priority\":null,\"arguments\":[],\"executions\":0,\"exception_executions\":{},\"locale\":\"en\",\"timezone\":\"Tokyo\",\"enqueued_at\":\"2025-10-26T01:20:00.038662472Z\",\"scheduled_at\":\"2025-10-26T01:20:00.038481305Z\"}', 'FooBar::BazQuxJob', NULL, '2025-10-26 10:20:00.070323', NULL, 0, 'background', '2025-10-26 10:20:00.038481', '2025-10-26 10:20:00.070323') /*application='****'*/
[ActiveJob]   TRANSACTION (0.1ms)  SAVEPOINT active_record_1 /*application='****'*/
[ActiveJob]   SolidQueue::Job Load (0.2ms)  SELECT `solid_queue_jobs`.* FROM `solid_queue_jobs` WHERE `solid_queue_jobs`.`id` = 15 LIMIT 1 /*application='****'*/
[ActiveJob]   SolidQueue::ReadyExecution Create (0.2ms)  INSERT INTO `solid_queue_ready_executions` (`created_at`, `job_id`, `priority`, `queue_name`) VALUES ('2025-10-26 10:20:00.106445', 15, 0, 'background') /*application='****'*/
[ActiveJob]   TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1 /*application='****'*/
[ActiveJob] Enqueued FooBar::BazQuxJob (Job ID: f061f654-51e9-425d-aa84-2100f4e559dd) to SolidQueue(background) with arguments: 
SolidQueue-1.2.2 Enqueued recurring task (117.1ms)  task: "foo_bar_baz_qux", active_job_id: "f061f654-51e9-425d-aa84-2100f4e559dd", at: "2025-10-26T01:20:00Z"
[ActiveJob] [FooBar::BazQuxJob] [f061f654-51e9-425d-aa84-2100f4e559dd] Performing FooBar::BazQuxJob (Job ID: f061f654-51e9-425d-aa84-2100f4e559dd) from (background) enqueued at 2025-10-26T01:20:00.038662472Z with arguments: 
[ActiveJob] [FooBar::BazQuxJob] [f061f654-51e9-425d-aa84-2100f4e559dd] FooBar::BazQuxJob called with args: []
[ActiveJob] [FooBar::BazQuxJob] [f061f654-51e9-425d-aa84-2100f4e559dd] Performed FooBar::BazQuxJob (Job ID: f061f654-51e9-425d-aa84-2100f4e559dd) from SolidQueue(background) in 2.35ms
[ActiveJob]   TRANSACTION (0.2ms)  BEGIN /*application='****'*/
[ActiveJob]   SolidQueue::Job Create (1.3ms)  INSERT INTO `solid_queue_jobs` (`active_job_id`, `arguments`, `class_name`, `concurrency_key`, `created_at`, `finished_at`, `priority`, `queue_name`, `scheduled_at`, `updated_at`) VALUES ('bb5b5ceb-ce02-48d4-9a96-3e9dc91c9bf5', '{\"job_class\":\"FooBar::BazQuxJob\",\"job_id\":\"bb5b5ceb-ce02-48d4-9a96-3e9dc91c9bf5\",\"provider_job_id\":null,\"queue_name\":\"background\",\"priority\":null,\"arguments\":[],\"executions\":0,\"exception_executions\":{},\"locale\":\"en\",\"timezone\":\"Tokyo\",\"enqueued_at\":\"2025-10-26T01:21:00.007415430Z\",\"scheduled_at\":\"2025-10-26T01:21:00.007366555Z\"}', 'FooBar::BazQuxJob', NULL, '2025-10-26 10:21:00.009421', NULL, 0, 'background', '2025-10-26 10:21:00.007366', '2025-10-26 10:21:00.009421') /*application='****'*/   
[ActiveJob]   TRANSACTION (0.2ms)  SAVEPOINT active_record_1 /*application='****'*/
[ActiveJob]   SolidQueue::Job Load (0.6ms)  SELECT `solid_queue_jobs`.* FROM `solid_queue_jobs` WHERE `solid_queue_jobs`.`id` = 16 LIMIT 1 /*application='****'*/
[ActiveJob]   SolidQueue::ReadyExecution Create (0.2ms)  INSERT INTO `solid_queue_ready_executions` (`created_at`, `job_id`, `priority`, `queue_name`) VALUES ('2025-10-26 10:21:00.024004', 16, 0, 'background') /*application='****'*/
[ActiveJob]   TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1 /*application='****'*/
[ActiveJob] Enqueued FooBar::BazQuxJob (Job ID: bb5b5ceb-ce02-48d4-9a96-3e9dc91c9bf5) to SolidQueue(background) with arguments: 
SolidQueue-1.2.2 Enqueued recurring task (34.3ms)  task: "foo_bar_baz_qux", active_job_id: "bb5b5ceb-ce02-48d4-9a96-3e9dc91c9bf5", at: "2025-10-26T01:21:00Z"
[ActiveJob] [FooBar::BazQuxJob] [bb5b5ceb-ce02-48d4-9a96-3e9dc91c9bf5] Performing FooBar::BazQuxJob (Job ID: bb5b5ceb-ce02-48d4-9a96-3e9dc91c9bf5) from (background) enqueued at 2025-10-26T01:21:00.007415430Z with arguments: 
[ActiveJob] [FooBar::BazQuxJob] [bb5b5ceb-ce02-48d4-9a96-3e9dc91c9bf5] FooBar::BazQuxJob called with args: []
[ActiveJob] [FooBar::BazQuxJob] [bb5b5ceb-ce02-48d4-9a96-3e9dc91c9bf5] Performed FooBar::BazQuxJob (Job ID: bb5b5ceb-ce02-48d4-9a96-3e9dc91c9bf5) from SolidQueue(background) in 1.4ms

🧠 プロセスを確認:4つの役割と協調動作

Solid Queue は、DBベースでありながら効率的なジョブ処理を行うため、主に以下の4つのプロセスで構成されています。
これらのプロセスが、共通のデータベースを利用して協調動作することで、外部キュー(Redisなど)なしでのジョブ実行を実現しています。

  • supervisor: 全体の親プロセスとして、dispatcherworkerscheduler の各子プロセスを起動・監視します。
  • dispatcher: キュー内のジョブを監視し、worker がすぐに実行できる状態(Ready)に配置する役割を担います。
  • worker: dispatcher によって配置された Job を実際に取得し、実行します。
  • scheduler: config/recurring.yml に基づき、定期実行の時刻が来たジョブを判断し、dispatcher を介してキューにエンキューします。
# ps -aux | grep solid-queue
root         1  0.3  0.9 1026296 106708 ?      Ssl  10:19   0:02 solid-queue-supervisor(1.2.2): supervising 17, 21, 25
root        17  0.2  1.0 974356 114088 ?       Sl   10:19   0:02 solid-queue-dispatcher(1.2.2): dispatching every 1 seconds
root        21  1.5  1.0 1043072 118048 ?      Sl   10:19   0:12 solid-queue-worker(1.2.2): waiting for jobs in *
root        25  0.0  0.9 965768 110944 ?       Sl   10:19   0:00 solid-queue-scheduler(1.2.2): scheduling foo_bar_baz_qux
root        58  0.0  0.0   3660  1688 pts/0    S+   10:33   0:00 grep solid-queue

Mission Control — Jobs

Solid Queue は Mission Control — Jobs をダッシュボードとして利用することを推奨しています。また、Solid Queue 自体としては Dashboard UI を持っていないため、README に従って導入を進めます。

こちらも Mission Control — Jobs の README および README の翻訳記事を参考にしました。 github.com techracho.bpsinc.jp

  • Gemfile に下記の2行を追加する
    • propshaft は Mission Control — Jobs を導入するサービスが API-only Applications に相当するため、追加した
gem "mission_control-jobs"
gem "propshaft"
  • bundle install で依存する gem をインストールし、Gemfile.lock を更新する
  • config/application.rb に下記の2行を追加する
    • Basic認証がデフォルトだが、別の認証方式を利用するのでOFFにする
config.mission_control.jobs.adapters = [:solid_queue]
config.mission_control.jobs.http_basic_auth_enabled = false
  • config/environments/production.rb などの環境ごとの設定ファイルに下記の行を追加
    • Mission Control — Jobs のURLへのアクセスはこの Controller を経由させる
    • 開発時は認証をスキップしたいため、config/environments/development.rb には行を追加しなかった
  config.mission_control.jobs.base_controller_class = "Foo::JobsDashboardController"
  • Foo::JobsDashboardController を実装する
    • このクラスで認証を行い、認証に失敗した場合、401 Unauthorized のレスポンスを返却する
class Foo::JobsDashboardController < ApplicationController
  before_action :authenticate!

  private

  def authenticate!
    # Implement your authentication logic here.
  rescue AuthenticatorError => e
    head :unauthorized
  end
end
  • config/routes.rb を修正して Mission Control Job のエンジンにアクセスできるようにマウントする
Rails.application.routes.draw do
  # ...
  mount MissionControl::Jobs::Engine, at: "/foo/jobs"

以上で Mission Control — Jobs が表示できるようになったかと思います。
ブラウザから config/routes.rb で指定したパスにアクセスしてみてください。

✨ まとめ:Solid Queue導入で得られたメリット

Solid Queue の導入は比較的容易に行うことができました。
ECS Scheduled Task の設定などが不要で、対象サービスのリポジトリ内のコード修正でほぼ完結するのは後から入ったメンバーの学習コスト低減に繋がり、大きなメリットだと考えます。

この記事をきっかけに Solid Queue に触れてみたという方がいたらうれしいです。

🤝 さいごに

株式会社スタメンでは、プロダクト開発に関わる全ての領域で、プロダクト職種の採用を積極的に行っています。
ご興味をお持ちいただけたなら、ぜひご応募いただけますとうれしいです。
皆さんとお話できることを楽しみにしています。

herp.careers herp.careers herp.careers