sidekiq-schedulerで定期ジョブが重複実行される原因を調査してみた

はじめに

こんにちは、イタンジ株式会社でエンジニアをしている小林です。不動産仲介会社向けの営業支援システムであるITANDI 賃貸仲介の開発をしています。 ITANDI 賃貸仲介では、Sidekiqを用いてバックグラウンドジョブの処理を行っています。その中で、定期的に実行したいジョブにはsidekiq-schedulerを利用しています。

最近、sidekiq-schedulerを用いた定期実行ジョブで予期しない動作に遭遇しました。定期実行についての設定ファイルで実行タイミングの指定にeveryを使用した場合、複数の実行環境(Sidekiqプロセス)すべてで定期実行ジョブが起動してしまうという動作です。当初の認識では、Sidekiqの実行環境が複数あっても、1回だけジョブがキューに積まれ実行されるものと思っていました。実際の挙動と私の認識に乖離があり、問題の原因を調査しようと考えたのがこの記事を書こうと思ったきっかけです。

結果として、定期実行の設定をeveryからcron構文に変更することで問題が解決しました。原因を調査したところ、sidekiq-schedulerのREADMEにも記載されている仕様でした。croneveryでは実行時刻の計算方法が異なり、cronはcron定義に基づいた絶対的な時刻に正規化されるのに対し、everyは各実行環境の起動時刻を基準とした相対的な時刻を使用します。そのため、複数の実行環境が異なるタイミングで起動している場合、それぞれが異なる実行時刻を計算してしまい、Redis上で同一ジョブとして認識されずに重複登録されてしまうようでした。 本記事では、この原因を深掘りし、sidekiq-schedulerのソースコードを読んで理解した仕様を解説します。

起こった事象

問題の状況

当初、以下のような設定で定期実行ジョブを実装していました。

# config/sidekiq.yml
:schedule:
  notify_job:
    every: "10m"
    class: NotifyJob

NotifyJobでは新着物件情報のブラウザ通知やネイティブアプリのプッシュ通知を行っています。これを検証環境で起動すると、同じ内容の通知が同時に2回送られてきました。ログを調査するとNotifyJobが短い間隔で2回実行されているログが見つかりました。このときの実行環境の数は2並列でした。

cronを使った場合

一方、cron に変更すると以下のようになります。

:schedule:
  # cronを使った設定(正常)
  notify_job:
    cron: '0 */10 * * * *'
    class: NotifyJob

実行環境の数が複数ある場合でも、cronの設定ではジョブがキューに積まれるのは1回だけになります。どの実行環境のワーカーがそのジョブを実際に実行するかは、Sidekiqのワーカーによるキューの取り合いで決まります。

なぜeveryとcronで動作が異なるのか

sidekiq-schedulerの動作を理解するためには、Redisを活用した仕組みを知る必要があります。 sidekiq-schedulerは、複数実行環境であってもスケジューリングされたジョブを重複実行しないように制御するために、Redis sorted setを利用しています。

Redis sorted setとは

Redis sorted setは「スコア付きの重複しない集合」です。同じ要素(member)を追加しようとすると、新しく追加されず既存のものが更新されるという特性があります。 sidekiq-schedulerはこの「重複を自動で検出できる」特性を使い、複数実行環境間でのジョブ重複を防いでいます。 具体的には、sidekiq-schedulerは実行予定時刻とそのタイミングで実行してほしい処理をrufus-schedulerに渡すことでスケジューリングします。実行タイミングになると、渡された処理が実行されます。この処理の中で、ジョブの実行予定時刻(Unixタイムスタンプ)をmemberとしてRedisに登録しようと試みます。このとき、同じ時刻が既に登録されていれば登録に失敗し、先に登録した実行環境だけがジョブをキューに積みます。後から登録しようとした実行環境は「既に登録済み」として登録をスキップし、ジョブのキューイングを行いません。

以下では、croneveryでどのように動作が異なるのかをソースコードを読みながら確認します。

ソースコードを読んでみる

sidekiq-schedulerのソースコードを読んで、croneveryでどのように動作が異なるのかを確認します。 その前に、sidekiq-schedulerの内部で使われているrufus-schedulerについて簡単に説明します。

(※この記事で扱う内容は、執筆時点(2026年1月)におけるsidekiq-schedulerおよびrufus-schedulerのGitHubリポジトリ上の実装を元にしています。今後これらのgemのバージョンが上がると、内部実装や挙動が変わる可能性がある点にご注意ください。具体的には、こちらのコミット時点での実装を参照しています)

rufus-schedulerとは

rufus-schedulerは、sidekiq-schedulerの内部で実行タイミング管理に使われているgemです。

以下のような形式での設定が可能です。

  • cron形式: '0 */10 * * * *' のようなcron記法でスケジュール指定
  • every形式: '10m' のような相対的な間隔でスケジュール指定
  • at/in形式: 特定の時刻や期間後の一度だけの実行

sidekiq-schedulerは、設定ファイルに書かれたcroneveryの指定と、実行してほしい処理をrufus-schedulerに渡し、実際の実行タイミングの管理を委譲しています。そして、rufus-schedulerが「今実行すべき」と判断したタイミングで渡された処理が実行され、その処理の中でsidekiq-schedulerがSidekiqのキューにジョブを投入する、という流れになっています。

# lib/sidekiq-scheduler/scheduler.rb
require 'rufus/scheduler'

# 中略...

def rufus_scheduler
  @rufus_scheduler ||= SidekiqScheduler::Utils.new_rufus_scheduler(rufus_scheduler_options)
end

この関係を理解した上で、ソースコードを読んでみます。

1. 設定ファイルの読み込みとスケジューリング

まず、sidekiq-schedulerがどのようにsidekiq.ymlを読み込み、ジョブをスケジューリングするかを見ます。

設定ファイルの読み込みは、lib/sidekiq-scheduler/scheduler.rbload_schedule!メソッドから始まります。

# lib/sidekiq-scheduler/scheduler.rb
def load_schedule!
  if enabled
    Sidekiq.logger.info 'Loading Schedule'

    # 省略...

    @scheduled_jobs = {}
    queues = scheduler_config.sidekiq_queues

    # sidekiq.ymlの:scheduler:セクションを読み込み、初期化時に`Sidekiq.schedule = config.schedule`で設定
    Sidekiq.schedule.each do |name, config|
      if !listened_queues_only || enabled_queue?(config['queue'].to_s, queues)
        load_schedule_job(name, config)
      else
        # 省略...
      end
    end
  end
end

Sidekiq.scheduleに格納された各ジョブの設定がload_schedule_jobメソッドに渡され、Rufus::Schedulerに登録されます。

2. ジョブの登録処理

load_schedule_jobメソッドでは、croneveryなどの実行タイミング指定を読み取り、Rufus::Schedulerにジョブを登録します。

# lib/sidekiq-scheduler/scheduler.rb
def load_schedule_job(name, config)
  if config['rails_env'].nil? || rails_env_matches?(config)
    Sidekiq.logger.info "Scheduling #{name} #{config}"
    interval_defined = false
    interval_types = %w(cron every at in interval)
    
    interval_types.each do |interval_type|
      config_interval_type = config[interval_type]

      if !config_interval_type.nil? && config_interval_type.length > 0
        schedule, options = SidekiqScheduler::RufusUtils.normalize_schedule_options(config_interval_type)

        rufus_job = new_job(name, interval_type, config, schedule, options)
        return unless rufus_job

        @scheduled_jobs[name] = rufus_job
        SidekiqScheduler::Utils.update_job_next_time(name, rufus_job.next_time)

        interval_defined = true
        break
      end
    end
  end
end

ここで重要なのは、new_jobメソッドの中でジョブが実行される際の処理です。

3. ジョブ実行時の処理:cronとeveryの分岐点

new_jobメソッドでは、Rufus::Schedulerにジョブを登録し、実行時のコールバックを定義します。

# lib/sidekiq-scheduler/scheduler.rb
def new_job(name, interval_type, config, schedule, options)
  options = options.merge({ :job => true, :tags => [name] })

  rufus_scheduler.send(interval_type, schedule, options) do |job, time|
    if job_enabled?(name)
      conf = SidekiqScheduler::Utils.sanitize_job_config(config)

      if job.is_a?(Rufus::Scheduler::CronJob)
        idempotent_job_enqueue(name, SidekiqScheduler::Utils.calc_cron_run_time(job.cron_line, time.to_t), conf)
      else
        idempotent_job_enqueue(name, time.to_t, conf)
      end
    end
  end
end

ここがcroneveryの動作の違いを生む最も重要な部分です。

  • cronの場合: job.is_a?(Rufus::Scheduler::CronJob)trueになり、calc_cron_run_timeメソッドで実行時刻を計算します
  • everyの場合: else節に入り、time.to_tがそのまま使われます

4. calc_cron_run_timeの役割

calc_cron_run_timeメソッドは、cronジョブの正確な実行予定時刻を計算します。

# lib/sidekiq-scheduler/utils.rb
def self.calc_cron_run_time(cron, time)
  time = time.floor # サブ秒を削除して丸め誤差を防ぐ
  return time if cron.match?(time) # 時刻が完全に一致すればそのまま返す

  next_t = cron.next_time(time).to_t # Rubyの`Time`オブジェクトへ変換
  previous_t = cron.previous_time(time).to_t

  # timeがprevious_tとnext_tの間のどちらに近いか判定
  next_diff = next_t - time
  previous_diff = time - previous_t

  if next_diff == previous_diff
    cron.rough_frequency == next_diff ? time : previous_t
  elsif next_diff > previous_diff
    previous_t  # 前回の実行時刻に近い
  else
    next_t      # 次回の実行時刻に近い
  end
end

このメソッドは、Rufusから渡される実際の実行時刻(time)ではなく、cronの定義に基づいた理想的な実行時刻を返します。

例えば、0 */10 * * * *(10分ごと)の場合:

  • 10:00:00、10:10:00、10:20:00... というように、分の値まで0で揃った時刻が返されます
  • 実際の実行が10:10:01になっても、このメソッドは10:10:00を返します

5. Redisへの登録:重複防止の仕組み

計算された時刻は、idempotent_job_enqueueメソッド経由でregister_job_instanceに渡されます。

# lib/sidekiq-scheduler/scheduler.rb
def idempotent_job_enqueue(job_name, time, config)
  registered = SidekiqScheduler::RedisManager.register_job_instance(job_name, time)

  if registered
    Sidekiq.logger.info "queueing #{config['class']} (#{job_name})"
    handle_errors { enqueue_job(config, time) }
    SidekiqScheduler::RedisManager.remove_elder_job_instances(job_name)
  else
    Sidekiq.logger.debug { "Ignoring #{job_name} job as it has been already enqueued" }
  end
end

そして、Redis sorted setへの登録が行われます。

# lib/sidekiq-scheduler/redis_manager.rb
def self.register_job_instance(job_name, time)
  job_key = pushed_job_key(job_name)
  registered, _ = Sidekiq.redis do |r|
    r.multi do |m|
      m.zadd(job_key, time.to_i, time.to_i)  # スコアと値の両方にtime.to_iを使用
      m.expire(job_key, REGISTERED_JOBS_THRESHOLD_IN_SECONDS)
    end
  end

  registered.instance_of?(Integer) ? (registered > 0) : registered
end

ここでRedis sorted setのZADDコマンドが使われます。同じmember(要素)が既に存在する場合、新しい要素は追加されず、registered0になります(このコードではmemberにもtime.to_iを渡しているため、「同じtime.to_iが既に登録されているか」を判定していることになります)。

6. 具体例で理解する

cronの場合(正常動作)

NotifyJobがcron: '0 */10 * * * *'で設定されているとします。

10:10:00の実行タイミング:

[実行環境1]
1. rufus-schedulerから time = 2026-01-26 10:10:00.123 が渡される
2. calc_cron_run_time で 2026-01-26 10:10:00.000 に正規化
3. time.to_i = 1769389800 でZADD
4. 成功(registered = 1)→ ジョブをキューイング

[実行環境2]
1. rufus-schedulerから time = 2026-01-26 10:10:00.456 が渡される(わずかに遅い)
2. calc_cron_run_time で 2026-01-26 10:10:00.000 に正規化
3. time.to_i = 1769389800 でZADD
4. 失敗(registered = 0)→ ジョブをキューイングしない(Already enqueued)

実行環境1も2も同じ時刻(1769389800)でZADDを試みるため、先に実行した実行環境1だけが成功します。

cronの場合

everyの場合(問題のある動作)

NotifyJobがevery: "10m"で設定されているとします。

実行タイミング(各実行環境で起動時刻がずれている):

[実行環境1(9:58:00起動)]
1. Rufusから time = 2026-01-26 10:08:00.000 が渡される
2. calc_cron_run_timeは呼ばれず、time.to_tがそのまま使用
3. time.to_i = 1769389680 でZADD
4. 成功(registered = 1)→ ジョブをキューイング

[実行環境2(10:01:30起動)]
1. Rufusから time = 2026-01-26 10:11:30.000 が渡される
2. calc_cron_run_timeは呼ばれず、time.to_tがそのまま使用
3. time.to_i = 1769389890 でZADD
4. 成功(registered = 1)→ ジョブをキューイング

everyでは各実行環境の起動時刻を基準にスケジュールされるため、異なる時刻(1769389680と1769389890)でZADDが実行され、両方とも成功してしまいます。

everyの場合

なぜ動作が異なるのか - まとめ

sidekiq-schedulerにおけるcroneveryの違いは以下の通りです:

cron

  • 時刻の正規化: calc_cron_run_timeにより、cron定義に基づく理想的な実行時刻に正規化される
  • 複数実行環境での挙動: 全実行環境で同じ時刻(秒単位)でRedisに登録されるため、重複が防止される
  • 実行タイミング: 絶対的な時刻(例:毎時0,10,20,30,40,50分)

every

  • 時刻の正規化なし: rufus-schedulerから渡される実際の時刻がそのまま使用される
  • 複数実行環境での挙動: 実行環境ごとに異なる時刻でRedisに登録されるため、重複実行しやすい
  • 実行タイミング: 相対的な間隔(例:起動から10分ごと)

おわりに

sidekiq-schedulerの定期実行ジョブでeveryを使用した場合とcronを使用した場合での動作の違いについて見てきました。

本記事では触れませんでしたが、sidekiq-schedulerの設定がどちらであろうと、複数回実行されようと、同じ結果が返るような冪等性の高い設計も重要です。 本番環境では複数のサーバー上で動作することは当たり前であるため、その部分も見越して設計するべきだったという学びがありました。 また、sidekiq-schedulerに限らず、利用しているライブラリのソースコードを読んだり仕様を把握したうえで利用することの重要性を再認識しました。

今回の経験を踏まえ、GitHub上でPRをopenすると自動で起動するようにしているCopilotによるコードレビュー時に読み込まれるcopilot-instructions.mdにsidekiq-schedulerの利用に関する注意点を追加し、チーム内でのsidekiq.ymlのレビュー時に、cronとeveryの違いを確認するようにしました。

最後までお読みいただき、ありがとうございました。