あなたのプロダクトは、各顧客に顧客のタイムゾーンの 09:00 に利用レポートをメールで送り、CRM アカウントを 15 分ごとに同期し、トライアルを深夜 0 時に失効させます。サーバーが 1 台なら cron で足ります。2 台目を追加すると、レポートは 2 回届きます。ロックを追加し、デプロイ中にその保持者がハングすると、ある朝レポートは 1 回も届きません。
この記事では、重複による被害を出さずに数百万のジョブを時刻どおりに開始する分散ジョブスケジューラを設計します。スケジュールの保存とリースから、夏時間、ミスファイア、スケーリング、既存ツールまでを扱います。
要件と重要な数値
マルチテナントの SaaS プロダクトを想定します。
- アクティブなスケジュールは 1,000 万件で、大半は毎時または毎日です。
- 平均的なスケジュールが 24 回発火するなら 1 日 2 億 4,000 万回の実行になり、毎秒およそ 2,800 回です(240,000,000 / 86,400)。
- 実行は期限から数秒以内に始まり、最長 30 分続きます。
- 30 日分の実行履歴:1 日 2 億 4,000 万行を 1 行約 200 バイトで保存すると、1 日 48 GB、1 か月 1.4 TB です。
人は 0 * * * * と書くので、期限は切りのよい分に集中します。300 万件の毎時スケジュールが 0 分を使うと、300 万回の実行が同時に期限を迎え、1 分以内にディスパッチするには毎秒 50,000 回が必要で、平均の 18 倍です。Jenkins はこのために cron の H 記号を追加しました。ジョブ名をハッシュして安定した分に割り当てます。内部ジョブも同じように分散させてください。
データモデル:ジョブと実行
jobs は各定義と次の期限時刻を保持し、job_runs は 1 回の発生ごとに 1 行を保持します。
CREATE TABLE jobs (
job_id uuid PRIMARY KEY,
cron_expr text NOT NULL, -- '0 9 * * *'
time_zone text NOT NULL, -- 'America/New_York'
misfire text NOT NULL DEFAULT 'run_once',
partition_id smallint NOT NULL, -- hash(job_id) % 256
next_run_at timestamptz NOT NULL, -- a UTC instant
paused boolean NOT NULL DEFAULT false
);
CREATE INDEX jobs_due ON jobs (partition_id, next_run_at) WHERE NOT paused;
CREATE TABLE job_runs (
job_id uuid NOT NULL,
scheduled_at timestamptz NOT NULL,
attempt int NOT NULL DEFAULT 0,
status text NOT NULL, -- queued, running, retrying, succeeded, dead
lease_until timestamptz,
PRIMARY KEY (job_id, scheduled_at) -- the run key
) PARTITION BY RANGE (scheduled_at);
(job_id, scheduled_at) は実行キーです。1 回の発生を表し、以降のあらゆる安全策の起点になります。日次パーティションにすれば保持期間の管理はパーティションの削除で済みます。PostgreSQL は、主キーがパーティション列を含んでいるため、この主キーを許可します。
期限のジョブを見つける
各スケジューラは、自分が所有するパーティションをポーリングします。
SELECT job_id, next_run_at
FROM jobs
WHERE partition_id = ANY($1) AND NOT paused
AND next_run_at <= now()
ORDER BY next_run_at
LIMIT 500
FOR UPDATE SKIP LOCKED;
同じトランザクション内で、ジョブごとに job_runs の行を挿入し、next_run_at を先に進めます。SKIP LOCKED は、他のトランザクションが保持している行を待たずにスキップします。PostgreSQL はキューのようなテーブルでの用途としてこれを文書化しています。コミット後は、後続のポーラーはその行を期限切れとして見なくなり、各発生は一度だけディスパッチされます。
コミット後にキューへ発行するのはデュアルライトなので、トランザクショナルアウトボックスパターンのように、新しい実行行をアウトボックスのレコードとして兼用させてください。ポーリングには代替案もあります。
| 方式 | 期限の作業を見つける方法 | 弱点 |
|---|---|---|
| jobs テーブルのポーリング | SKIP LOCKED を使った next_run_at のインデックス |
発火のたびにインデックス付きの列を更新するため、HOT 更新が効かず、バキューム作業が絶えない |
| 時間バケットのパーティション | 現在の分のバケットを読む | すでに読んだバケットへの遅れた挿入にはスイープが必要 |
| ディレイキュー | 期限が来た実行を解放するブローカーやソート済みセット | 遅延の上限(Amazon SQS は 15 分)。信頼できる情報源は引き続きストア |
リース、ハートビート、冪等な実行
キュー以降の配信は at-least-once です。コンシューマーは確認応答の前にクラッシュすることがあり、リレーが二重に発行することもあります。そのため、エグゼキューターは最初に実行を取得します。
UPDATE job_runs
SET status = 'running', attempt = attempt + 1,
lease_until = now() + interval '30 seconds'
WHERE job_id = $1 AND scheduled_at = $2
AND (status IN ('queued', 'retrying')
OR (status = 'running' AND lease_until < now()))
RETURNING attempt;
行が返らなければ、別の場所に有効なリースがあるか、実行がすでに完了しています。そのメッセージは破棄します。エグゼキューターは 10 秒ごとにハートビートを送るため、30 秒のリースならハートビートを 1 回取りこぼしても持ちこたえます。エグゼキューターが死ねばリースが失効し、所有するスケジューラがその実行を再発行します。
一時停止したエグゼキューターが、リース失効後に目を覚まして書き込みを続けることがあります。返された attempt はフェンシングトークンで、Martin Kleppmann による分散ロックの解説と同じ考え方です。結果は WHERE attempt = $3 付きで書き込むので、古い試行が新しい試行を上書きすることはできません。データベース外のターゲットには、実行キーを冪等性キーとして渡します。
これが正直な保証です。ディスパッチは実行キーごとに 1 回、実行は少なくとも 1 回、効果が 1 回になるのは冪等なジョブだけです。Kubernetes CronJob のドキュメントも同じことを述べています。CronJob は Job を 2 つ作ることも、1 つも作らないこともあり、そのコントローラーは各 Job に予定時刻を使った名前を付けるため、重複した作成は失敗します。
バックオフ付きリトライとデッドレター状態
- エラーを分類します。 タイムアウト、5xx、429 はリトライ可能で、バリデーションエラーはそのまま dead にします。
- フルジッター付きでバックオフします。
delay = random(0, min(cap, base * 2^attempt))。基準が 10 秒なら、4 回のリトライはそれぞれ最大 20、40、80、160 秒待ち、5 回目の失敗が最終です。ターゲット全体が落ちているときは、リトライバジェットとサーキットブレーカーでリトライの積み重なりを防ぎます。 - dead 状態を役立つ形で保ちます。 ペイロード、最後のエラー、アラート、そして実行キーを再利用するリプレイを残します。
- テナントごとに同時実行数を制限します。 レートリミッターと同じ考え方で、1 つのテナントのバーストが他を枯渇させないようにします。
09:00 の実行が 10:00 になってもリトライ中なら、重複ポリシーが判断します。スキップ、キューに積む、両方実行する、古い方をキャンセルする、のいずれかです。Kubernetes ではこれを concurrencyPolicy(デフォルトは Allow)と呼び、Temporal のスケジュールはデフォルトで Skip です。
cron のセマンティクス:タイムゾーン、夏時間、ミスファイア
各 cron 式は、固定オフセットではなく America/New_York のような IANA ゾーンとともに保存し、次の実行は UTC に変換する前にローカル時刻で計算してください。24 時間を足す方式は年に 2 回ずれます。インフラ系のジョブには UTC を使い、通知システムのサイレント時間のように、人が時計の時刻を期待する場面ではローカル時刻を使います。
夏時間はローカル時刻を壊します。ニューヨークでは 2026 年 3 月 8 日に 2:00 から 2:59 が存在せず、2026 年 11 月 1 日には 1:00 から 1:59 が 2 回ありました。スケジューラはどちらの扱いも一致しません。
| スケジューラ | 存在しない時刻 | 繰り返される時刻 |
|---|---|---|
| Linux cron(cronie) | 時刻が飛んだ直後に実行する | 1 回実行する |
| Amazon EventBridge Scheduler | 実行をスキップする | 1 回実行する |
timeZone 付きの Kubernetes CronJob |
実行をスキップする | 2 回実行する |
Kubernetes の行は、春の時刻跳躍で該当するジョブは実行されないと文書化している cron ライブラリと、ベルリンの 02:00 のジョブが 2 回実行された issue 121594 に基づいています。ルールを 1 つ選び、テストしてください。
ミスファイアとは、スケジューラが停止または過負荷だったために開始が遅れた実行のことです。スケジューラが遅れた行を取得するときに、ジョブごとのポリシーが適用されます。
| ポリシー | 3 時間の停止後の毎時ジョブ | 向いている用途 | 相当するもの |
|---|---|---|---|
| 1 回だけ実行 | 1 回実行し、その後は通常に戻る | 同期、クリーンアップ | Quartz のスマートポリシー、catchup オフの Airflow(デフォルト) |
| スキップ | 次の枠まで待つ | リマインダー | Quartz の DO_NOTHING、短い Temporal の catchup ウィンドウ |
| 追いつく | 3 回すべてを古い順に実行する | 毎時の課金 | Quartz の IGNORE_MISFIRE_POLICY、Airflow の catchup=True |
Quartz はデフォルトで 60 秒後に実行をミスファイアとみなします。Kubernetes には 2 つの落とし穴があります。コントローラーが 10 秒ごとに確認するため、10 未満の startingDeadlineSeconds は実行をスキップさせることがあり、停止の後は逃した実行のうち最新の 1 回しか開始せず、それより前の分は失われます(100 回を超えて逃すと、TooManyMissedTimes 警告も記録します)。
スケジューラをスケールさせる
複数のスケジューラを動かす方法は 3 つあります。
- 単一のリーダー。 1 つのインスタンスがリース(etcd、ZooKeeper、Kubernetes の Lease)を保持し、すべてをポーリングします。単純ですが 1 ノードが上限で、フェイルオーバーはリースの失効を待ちます。CronJob コントローラーが動く kube-controller-manager ではデフォルトで 15 秒です。
- 競合するポーラー。 1 つのテーブルに対して
SKIP LOCKEDを使います。コーディネーターは不要ですが、ホットなインデックスが 1 つできます。Quartz はデータベースロックでクラスタリングし、およそ 3 ノードを超えると性能が落ちると警告しています。 - パーティションの所有。 ジョブをスケジューラ数よりはるかに多い 256 の論理パーティションに分け、各スケジューラは etcd で自分のパーティションのリースを取り、そこだけをポーリングします。コンシステントハッシュなら、ノードの参加や死亡で動くのは一部だけで、etcd は Raft でリースの一貫性を保ちます。
所有は最適化であって保証ではありません。一時停止していた古い所有者が引き継ぎ後にもう一度ポーリングしても、行ロック、進められた next_run_at、実行キーがそれを止めます。パーティションは、データベースのシャーディングの単位にもなります。
既存ツールとそれぞれの得意分野
| ツール | 得意なこと | 注意点 |
|---|---|---|
| Kubernetes CronJob | 自分で運用するクラスタ上のバッチコンテナ | スケジュールごとに 1 オブジェクト、タイミングは概算 |
| Quartz | JVM アプリ内のスケジューリングで、データベースに保存 | クラスタロックがスケールアウトを制限する |
| Temporal Schedules | 永続的なリトライを持つ複数ステップのジョブ | Temporal を運用するか、そのクラウドに支払う必要がある |
| Apache Airflow | 依存関係とバックフィルを持つデータパイプライン | 数百万の小さなテナントジョブ向けではない |
| Amazon EventBridge Scheduler | AWS 上のマネージドなトリガー | 執筆時点(2026 年 11 月)のデフォルトクォータ:リージョンあたり 1,000 万スケジュール、プライマリリージョンで毎秒 1,000 回の呼び出し。どちらも引き上げ可能 |
自作するのは、テナントが定義する数百万のスケジュールがある場合だけにしてください。
分散ジョブスケジューラの図
ダイアグラムの内容
- Job API
- Schedule store (jobs)
- Coordinator
- Scheduler workers · 256 partitions
- Scheduler 1
- Scheduler 2
- Scheduler 3
- Run queue
- Executor workers
- Target services
- Run history (job_runs)
- Dead letter
- POST /jobs
- due jobs
- partition leases
- runs
- deliver
- call + Idempotency-Key
- lease, result
- after 5 attempts
- retry: backoff + jitter
図の読み方は次のとおりです。
- 左から右へ、実行が処理に向かって進みます。 起点は Job API とスケジュールストアです。
- フレームはスケジューラ層です。 3 つのワーカーが etcd のリースで 256 のパーティションを分担し、期限の実行を発行します。
- エグゼキューターワーカーは各実行をリースします。 リースは実行履歴に記録され、冪等性キー付きでターゲットを呼び出し、結果を記録します。
- 破線のエッジは失敗経路です。 バックオフ付きのリトライと、5 回目の失敗後のデッドレターキューを示します。
- Markdown ブロックには、 ミスファイアポリシーとサイジングの数値を載せています。
ジョブスケジューラの図を分かりやすく描く
- 実行キーをキャンバスに書きます。
(job_id, scheduled_at)は、どんな矢印よりも重複防止をうまく説明します。 - 所有を見せます。 パーティションと、リースの出どころです。スケジューラとだけ書かれた 1 つの箱は、最も難しい部分を隠してしまいます。
- 失敗経路は破線にして、 そのきっかけをラベルに書きます。
- 所要時間をキャンバスに書きます。 リース、ハートビート、リトライの上限が復旧時間を決めます。
要件を設計図に落とし込む方法を解説した記事では、同じ習慣を URL 短縮サービスに適用しています。
Archboard で作る
- Google または GitHub でサインインし、ダイアグラムを作成します。
- Job API 用に C4 モデル パックから コンテナ を、スケジュールストア用に データ パックから Postgres ノードを追加します。
- 基本 パックから、スケジューラ用の 3 つの コンテナ ノードを入れる フレーム を追加し、その上に etcd コーディネーター用の コンテナ を置きます。
- 実行用に データ パックから キュー を、エグゼキューター用に コンテナ を、実行履歴用に 2 つ目の Postgres を、デッドレター用に キュー を、ターゲットサービス用に C4 モデル パックから 外部システム を追加します。
- ラベル付きのエッジを引き、リトライとデッドレターの経路を破線にします。
- ミスファイアポリシーを書いた 基本 パックの Markdown ブロックと、取得クエリや実行キーのような短い テキスト のメモを追加します。
パイプラインの Mermaid スケッチは、Mermaid から編集可能なダイアグラムへの変換ツールで最初のたたき台になります。
この例で使うダイアグラムノードは 13 個で、Free プランの範囲に収まります。
よくある質問
スケジュールされたジョブの二重実行はどう防ぎますか? ジョブ ID と予定時刻を一意の実行キーとして使います。期限のジョブの取得と次回実行時刻の更新を 1 つのトランザクションで行い、実行前に実行をリースし、実行キーを冪等性キーとしてターゲットに渡します。
FOR UPDATE SKIP LOCKED は何をしますか? 返される行をロックし、他のトランザクションが保持している行をスキップするため、複数のワーカーが互いを待たずに 1 つのテーブルをポーリングできます。PostgreSQL では 9.5 から使えます。
ジョブスケジューラは夏時間をどう扱うべきですか? 各スケジュールに IANA タイムゾーンを保存し、次回の実行は UTC で計算し、存在しない時間帯と繰り返される時間帯で何が起きるかを決めます。ツールによって挙動が違い、Kubernetes CronJob は時計が戻るときに 2 回実行されることさえあります。
ジョブスケジューリングにおけるミスファイアとは何ですか? 開始時刻を逃した実行のことで、たいていはスケジューラが停止または過負荷だったことが原因です。ミスファイアポリシーが、今すぐ 1 回実行するか、次の枠までスキップするか、逃したすべての実行に追いつくかを決めます。
分散スケジューラとして Kubernetes CronJob で十分ですか? すでに運用しているクラスタ上の数百のバッチジョブなら十分です。数百万のテナントスケジュールには不十分です。各スケジュールが Kubernetes のオブジェクトになり、各実行は Pod を伴う Job になり、タイミングも概算になります。