Cronの悩みを避けるバックグラウンドジョブのスケジューリングパターン
ワークフローとジョブテーブルを使い、リマインダー、日次サマリー、クリーンアップを信頼性高くスケジュールするためのパターンを学びます。

Cronは一見シンプルだが、問題は後から来る
Cronは初日は素晴らしい:1行書いて時間を選べば忘れてよい。サーバが一台でタスクが一つなら、多くの場合それで十分だ。
本当の製品挙動(リマインダー、日次サマリー、クリーンアップ、同期ジョブ)をスケジューリングに頼ると問題が見えてくる。多くの「実行が漏れた」話はCron自体の失敗ではない。サーバ再起動、デプロイでcrontabが上書きされた、ジョブが想定より長く実行された、時刻やタイムゾーンの不一致――そんな周辺の問題だ。複数のアプリインスタンスを動かすと逆の失敗、つまり重複実行(2台が同じタスクを実行してしまう)も起きる。
テストも弱点だ。Cronの1行は「明日9:00に何が起きるか」を再現可能に実行する手段を与えてくれない。結果としてスケジューリングは手動チェックや本番での驚き、ログ追跡に変わる。
アプローチを選ぶ前に、何をスケジュールするのかを明確にしよう。背景処理の大半は次のカテゴリに収まる:
- リマインダー(特定の時刻に1回だけ送る)
- 日次サマリー(データを集約して送る)
- クリーンアップ(削除、アーカイブ、期限切れ処理)
- 定期同期(更新をプル/プッシュ)
イベント発生時に即座に処理できるものは、時間駆動よりイベント駆動のほうが単純で信頼性が高い場合が多い。
時間ベースが必要なとき、信頼性は可視性と制御に尽きる。何が実行されるべきか、何が実行されたか、何が失敗したかを記録する場所が欲しい。加えて、重複を作らず安全にリトライできる仕組みが必要だ。
基本パターン:スケジューラ、ジョブテーブル、ワーカー
Cronの悩みを避ける簡単な方法は責務を分けることだ。
- スケジューラは何をいつ実行するかを決める。
- ワーカーは仕事を実行する。
役割を分離すると2つの利点がある。タイミングをビジネスロジックに触れずに変えられ、ビジネスロジックを変えてもスケジュールを壊さない。
ジョブテーブルは真実のソースになる。状態をサーバプロセスやcrontabの中に隠す代わりに、仕事の単位を行として持つ:何をするか、誰のためか、いつ実行するか、前回何が起きたか。問題が起きたら調べて再実行したりキャンセルしたりできる。
典型的なフローは次の通り:
- スケジューラが実行対象のジョブをスキャンする(例:
run_at <= nowとstatus = queued)。 - ジョブをクレームして1つのワーカーだけが取る。
- ワーカーがジョブの詳細を読み、処理を行う。
- ワーカーが結果を同じ行に記録する。
重要なのは仕事を魔法にしないこと、再開可能にすることだ。ワーカーが途中で落ちても、ジョブ行は何が起きたかと次に何をすべきかを示しているべきだ。
役に立ち続けるジョブテーブルの設計
ジョブテーブルは素早く2つの質問に答えられるべきだ:次に何を実行する必要があるか、前回は何が起きたか。
まずは識別、タイミング、進捗をカバーする最小限のフィールドから始める:
- id, type: 一意のIDと
send_reminderやdaily_summaryのような短いタイプ。 - payload: ワーカーが必要とするだけを検証済みJSONで(例:
user_id、ユーザー全体オブジェクトではなくIDのみ)。 - run_at: ジョブが実行可能になる時刻。
- status:
queued,running,succeeded,failed,canceled。 - attempts: 各試行でインクリメントする。
次に運用上役立つカラムをいくつか追加する。locked_at、locked_by、locked_untilはワーカーがジョブをクレームして二重実行を防ぐのに使う。last_errorは行を膨らませない短いメッセージ(必要ならエラーコード)にして、フルスタックトレースで行を肥大化させない。
最後にサポートとレポートに役立つタイムスタンプを残す:created_at, updated_at, finished_at。これで「今日何件のリマインダーが失敗したか?」のような問いにログを掘らずに答えられる。
インデックスは「次は何?」という問いに頻繁に答えるため重要だ。費用対効果が高い2つ:
(status, run_at):期限到来ジョブを素早く取得するため。(type, status):あるジョブファミリを調査・一時停止するため。
payloadは小さく焦点を絞ったJSONを好み、挿入前に検証する。識別子やパラメータを保存し、業務データのスナップショットは保存しない。ペイロードの形状をAPI契約のように扱い、古いキューイング済みジョブがアプリの変更後も動くようにする。
ジョブのライフサイクル:ステータス、ロック、冪等性
ジョブランナーが信頼できるのは、全てのジョブが小さく予測可能なライフサイクルに従うときだ。二重でワーカーが起動したとき、サーバが途中で再起動したとき、リトライが必要なとき、このライフサイクルが安全網になる。
単純な状態機械で十分なことが多い:
- queued:
run_at以降に実行可能 - running: ワーカーがクレームした
- succeeded: 完了し再実行不要
- failed: エラーで終了し対処が必要
- canceled: 意図的に停止(例: ユーザーがオプトアウト)
二重実行を防ぐクレーム方法
重複を防ぐにはジョブのクレームを原子的に行う必要がある。一般的なアプローチはタイムアウト付きロック(リース)だ:ワーカーはstatus=runningを設定し、locked_byとlocked_untilを書き込んでジョブをクレームする。ワーカーが落ちたらロックは期限切れになり、別のワーカーが再クレームできる。
実用的なクレームルールの例:
run_at <= nowのqueuedジョブのみクレームするstatus、locked_by、locked_untilを同じ更新で書くlocked_until < nowのときだけrunningジョブを再クレームする- リースは短めにして、長時間のジョブなら延長する
冪等性(身につけるべき習慣)
冪等性とは同じジョブが2回実行されても結果が正しいことを意味する。
最も簡単なツールは一意キーだ。日次サマリーなら summary:user123:2026-01-25 のようなキーでユーザーごと日ごとに1つに制約しておくと、重複挿入が起きても別のジョブではなく同じジョブを指す。
副作用が本当に完了したときだけ成功をマークする(メール送信、レコード更新など)。リトライ時に二重メールや重複書き込みが起きないようにリトライ経路を作る。
ドラマを避けるリトライと失敗処理
リトライはジョブシステムが頼れるかノイズの山になるかを決める。目標は単純:一時的な失敗なら再試行し、恒久的な失敗なら止める。
デフォルトのリトライ方針に含めるべき要素は:
- 最大試行回数(例: 合計5回)
- 遅延戦略(固定遅延か指数バックオフ)
- 停止条件(「無効な入力」のようなエラーは再試行しない)
- ジッター(短いランダムオフセットでリトライスパイクを避ける)
リトライ用に新しいステータスを作る代わりに、queuedを再利用することが多い:run_atを次の試行時間に設定してジョブを再度キューに戻す。状態機械が小さいままで済む。
ジョブが部分的に進む可能性があるときは、それを普通のこととして扱え。チェックポイントを保存してリトライが安全に続行できるようにする(ジョブペイロードにlast_processed_idを入すか、関連テーブルに保存する)。
例:日次サマリージョブが500ユーザー分のメッセージを生成する。ユーザー320で失敗したら、最後に成功したIDを保存して321から再開する。ユーザーごと日ごとのsummary_sentレコードを保存しておけば、再実行で既に済んだユーザーはスキップできる。
デバッグに役立つログ
数分で原因を追えるようにログを残す:
- job id、type、attempt番号
- 主要入力(user/team id、日付範囲)
- 時間(started_at、finished_at、次回実行時刻)
- 短いエラー概要(利用できればスタックトレースも)
- 副作用の数(送信したメール数、更新した行数)
ステップバイステップ:シンプルなスケジューラループを作る
スケジューラループは固定リズムで起きて期限到来ジョブを探し渡す小さなプロセスだ。目的は派手さではなく凡庸な信頼性。多くのアプリでは「1分ごとに起床」で十分だ。
ジョブの時間敏感度やDB負荷に応じて起床頻度を決める。リマインダーがほぼリアルタイムである必要があれば30〜60秒、日次サマリーなら5分ごとで十分かつ安価だ。
簡単なループ:
- 起床して現在時刻を取得(UTCを使う)
status = 'queued'かつrun_at <= nowの期限到来ジョブを選択- ジョブを安全にクレームして1つのワーカーだけが取る
- クレームした各ジョブをワーカーに渡す
- 次のティックまでスリープ
クレームステップで多くのシステムが壊れる。ジョブを選択した同じトランザクションで running にマークしてlocked_byとlocked_untilも保存したい。多くのデータベースは SKIP LOCKED のような読み取りをサポートし、複数のスケジューラが干渉せずに動ける。
-- concept example
BEGIN;
SELECT id FROM jobs
WHERE status='queued' AND run_at <= NOW()
ORDER BY run_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE jobs
SET status='running', locked_until=NOW() + INTERVAL '5 minutes'
WHERE id IN (...);
COMMIT;
バッチサイズは小さめ(50〜200程度)を保て。大きすぎるとDBが遅くなり、クラッシュ時の影響が大きくなる。
スケジューラがバッチ途中で落ちてもリースが助けになる。runningに残ったジョブはlocked_until後に再び実行可能になる。ワーカーは冪等であるべきなので、再クレームされたジョブが二重メールや二重課金を生まないようにする。
リマインダー、日次サマリー、クリーンアップのパターン
ほとんどのチームは同じ3種類の背景処理に落ち着く:指定時刻に送るメッセージ、定期的に走るレポート、ストレージと性能を保つクリーンアップ。同じジョブテーブルとワーカーループで全て扱える。
リマインダー
リマインダーにはメッセージ送信に必要なものをすべてジョブ行に保存する:誰に、どのチャネル(email、SMS、Telegram、アプリ内)、どのテンプレート、正確な送信時刻。ワーカーは追加のコンテキストを探さずにジョブを実行できるべきだ。
多くのリマインダーが同時に到来するならレート制限を入れる。チャネルごとの分/秒あたりの上限を決め、余分なジョブは次回に回す。
日次サマリー
日次サマリーは時間窓があいまいだと失敗する。安定したカットオフ時刻(例: ユーザーのローカル時間で08:00)を選び、ウィンドウを明確に定義する(例: “昨日08:00から今日08:00”)。リトライでも同じ結果が出るようにカットオフとユーザーのタイムゾーンをジョブに保存する。
各サマリーは小さく保て。何千件も処理する必要があればチャンクに分割(チームごと、アカウントごと、IDレンジごと)してフォローアップジョブをキューに入れる。
クリーンアップ
クリーンアップは「削除」と「アーカイブ」を分けると安全だ。完全に削除してよいもの(トークン、期限切れセッション)とアーカイブすべきもの(監査ログ、請求書)を決める。長いロックや急激な負荷スパイクを避けるため、予測可能なバッチで実行する。
時刻とタイムゾーン:バグの隠れ家
多くの失敗は時間に関するバグだ:リマインダーが1時間早く送られる、日次サマリーが月曜を飛ばす、クリーンアップが2回走る。
良いデフォルトはスケジュール時刻をUTCで保存し、ユーザーのタイムゾーンを別に保存すること。run_atは1つのUTC時刻であるべきだ。ユーザーが「自分の時間で9:00」と言ったら、スケジューリング時にUTCに変換して保存する。
サマータイム(DST)は素朴な実装を壊すポイントだ。「毎日9:00」は「24時間ごと」と同義ではない。DSTの切り替えで9:00のUTCマッピングが変わり、存在しないローカル時刻(春)や2回起こる時刻(秋)が出る。より安全な方法は、次のローカル発生時刻を都度計算し、それをUTCに変換してスケジュールすることだ。
日次サマリーについては「日」が何を意味するかを先に決める。カレンダーデイ(ユーザーのタイムゾーンの真夜中から真夜中)は人間の期待に合う。24時間ごとは単純だがずれていく。
遅延データは避けられない:イベントが遅れて届く、メモが深夜数分後に追加される。遅延イベントを「昨日」に含めるか(猶予期間を設ける)、それとも「今日」に含めるかを決めて一貫させる。
実用的な余裕策:
- 期限到来から2〜5分遡ってジョブをスキャンする
- ジョブを冪等にして再実行が安全になるようにする
- カバレッジする時間範囲をペイロードに記録してサマリーの一貫性を保つ
見落とされがちなミス(実行漏れや重複の原因)
多くの問題は予測可能な前提から生じる。
最大の誤りは「ちょうど一度だけ実行される」と仮定すること。実際にはワーカーは再起動し、ネットワーク呼び出しはタイムアウトし、ロックは失われる。現実には「少なくとも一度(at least once)」の配達になりがちで、重複は普通だと考えてコードを耐性化する必要がある。
別の誤りは副作用を先に行ってデデュープチェックをしないこと。簡単なガードで解決できる:sent_atタイムスタンプ、(user_id, reminder_type, date) のような一意キー、または保存されたデデュープトークン。
可視性の欠如も致命的だ。「何が詰まっているか、いつから、なぜ」を答えられないと推測に頼ることになる。最小限に保持すべきデータはステータス、試行回数、次回予定時刻、最後のエラー、ワーカーIDだ。
よくあるミス:
- ジョブをちょうど一回実行される前提で設計し、重複に驚く
- 副作用を書き出す前にデデュープチェックをしない
- 全部をやろうとする巨大なジョブを書いて途中でタイムアウトする
- 無制限にリトライする
- キューの可視性を飛ばす(バックログ、失敗、長時間実行の明確なビューがない)
具体例:日次サマリージョブが50,000ユーザーをループし、20,000ユーザーでタイムアウトしたとする。リトライ時に最初からやり直すと、最初の20,000ユーザーに再度サマリーを送ってしまう。ユーザー単位での完了記録を使うか、ユーザーごとのジョブに分割してこれを防ぐ。
信頼できるジョブシステムのクイックチェックリスト
ジョブランナーは「深夜2時に信頼できる」と言えるようになって初めて完成だ。
用意すべきもの:
- キューの可視性:queued、running、failedの数と、最古のqueuedジョブが見えること。
- デフォルトでの冪等性:すべてのジョブは2回実行される前提で設計する。一意キーや「既に処理済み」マーカーを使う。
- ジョブタイプ別のリトライ方針:リトライ回数、バックオフ、明確な停止条件。
- 一貫した時刻保存:
run_atはUTCで保存し、入力と表示時にのみ変換する。 - 回復可能なロック:クラッシュでジョブが永久に実行中にならないようにリースを使う。
またバッチサイズ(一度にクレームするジョブ数)とワーカーの同時実行数の上限を設ける。限界を設けないと、スパイクでDBが過負荷になったり他の処理が枯渇したりする。
現実的な例:小規模チーム向けのリマインダーとサマリー
小さなSaaSは30の顧客アカウントを持つ。それぞれが2つを求めている:9:00に未完タスクのリマインダー、18:00にその日変わったことのサマリー。さらに週次クリーンアップで古いログや期限切れトークンが溜まらないようにする。
彼らはジョブテーブルと期限到来をポーリングするワーカーを使う。新しい顧客がサインアップしたとき、バックエンドはその顧客のタイムゾーンに基づいて最初のリマインダーとサマリーをスケジュールする。
ジョブは共通の瞬間に作られる:サインアップ時(繰り返しスケジュールを作成)、特定イベント時(単発通知をエンキュー)、スケジュールティック時(次回実行分を挿入)、メンテナンス日(クリーンアップをエンキュー)。
ある火曜、メールプロバイダが8:59に一時的な障害を起こした。ワーカーはリマインダー送信を試みタイムアウトになり、バックオフで run_at を再設定して再スケジュールする(例: 2分、次に10分、次に30分)、各回で attempts をインクリメントする。各リマインダージョブに account_id + date + job_type のような冪等キーがあるので、プロバイダが途中で回復しても重複は起きない。
クリーンアップは小さなバッチで毎週実行し、他の処理をブロックしない。百万行を一度に削除する代わりに1回の実行でN行ずつ削除し、完了するまで自分自身を再スケジュールする。
顧客から「サマリーが来ていない」と言われたら、チームはそのアカウントと日付のジョブテーブルを確認する:ジョブのステータス、attempts、現在のロックフィールド、プロバイダが返した最後のエラー。すると「送られるはずだった」状況が「何が起きたか」に変わる。
次の一手:実装して観測し、スケールする
まずは1つのジョブタイプを選び、エンドツーエンドで作ってから増やす。単一のリマインダージョブは出発点として良い。スケジューリング、クレーム、送信、結果記録の全てに触れるからだ。
信頼できるバージョンから始める:
- ジョブテーブルと1種類のジョブを処理するワーカーを作る
- 期限到来ジョブをクレームして実行するスケジューラループを追加する
- 追加の推測をしなくて済むだけのペイロードを保存する
- すべての試行と結果をログに残し、「実行されたか」は10秒で分かるようにする
- 失敗ジョブを手動で再実行できる経路を用意し、復旧にデプロイを要さないようにする
動き始めたら、人が見てわかるようにすること。基本的な管理画面でもすぐに投資対効果が出る:ステータスで検索、時間でフィルタ、ペイロードの確認、詰まったジョブのキャンセル、特定ジョブIDの再実行。
この種のスケジューラとワーカーフローを視覚的なバックエンドロジックで構築したい場合、AppMaster (appmaster.io) はPostgreSQLでジョブテーブルをモデリングし、claim→process→updateループをBusiness Processとして実装でき、しかもデプロイ可能な実際のソースコードを生成します。


