コンテンツへスキップ
Toova
すべてのツール

実際の本番環境で使われる 5 つの Cron 式パターン

Toova

Cron 式は単純に見えます — 5 つのフィールドと一握りの演算子 — しかし本番環境のチームは同じ失敗を繰り返します: 特定の月で静かにスキップされるジョブ、共有状態を破壊する重複実行、すべて同じ秒に目覚めてデータベースを圧倒する何百ものジョブ。式自体が問題であることはまれです。問題は、エッジケースを十分に考えないことにあります。

このガイドでは、エンジニアリングチームが実際に本番環境で使用する 5 つの cron パターンと、それぞれの背景にある理由および回避すべき罠を取り上げます。デプロイする前に Toova Cron パーサーを使って任意の式を検証し、次の 10 回の実行時刻を確認してください。

Cron 式の構造

標準的な cron 式は、スペースで区切られた 5 つのフィールドを持ちます:

*/15  *  *  *  *
  |   |  |  |  |
  |   |  |  |  +---- 曜日 (0-6, 0=日曜)
  |   |  |  +------- 月 (1-12)
  |   |  +---------- 日 (1-31)
  |   +------------- 時 (0-23)
  +----------------- 分: 15 分ごと (0, 15, 30, 45)

各フィールドは次のものを受け入れます: 特定の値 (5)、ワイルドカード (*)、範囲 (1-5)、リスト (1,3,5)、ステップ値 (*/15)。一部の実装は秒のための 6 番目のフィールドを追加します。他(GitHub Actions など)は正確に 5 つを期待します。複雑な式を書く前に、スケジューラーが使用している方言を必ず確認してください。

パターン 1: 15 分ごと — */15 * * * *

*/15  *  *  *  *
  |   |  |  |  |
  |   |  |  |  +---- 曜日 (0-6, 0=日曜)
  |   |  |  +------- 月 (1-12)
  |   |  +---------- 日 (1-31)
  |   +------------- 時 (0-23)
  +----------------- 分: 15 分ごと (0, 15, 30, 45)

キュープロセッサー、ハートビートチェック、頻繁に実行する必要があるが連続実行は避けたいポーリングジョブの定番表現です。毎時 :00、:15、:30、:45 に毎日発火します。

crontab では:

# crontab エントリ — バッチ処理を 15 分ごとに実行
*/15 * * * * /opt/app/bin/process-queue.sh >> /var/log/queue.log 2>&1

Node.js では:

// node-cron を使った Node.js
import cron from 'node-cron';

cron.schedule('*/15 * * * *', async () => {
  const batch = await db.query(
    'SELECT * FROM jobs WHERE status = $1 LIMIT 100',
    ['pending']
  );
  await processBatch(batch);
});

サンダリングハード問題。同じ */15 式を持つ数百のワーカーを実行すると、すべて :00、:15、:30、:45 に同時に発火します。各ワーカーが共有データベースを叩くと、滑らかな負荷ではなく時間あたり 4 つのスパイクが発生します。修正方法は、各実行前にランダムなジッターを追加することです:

// 分散システムでのサンダリングハード問題を避けるためにジッターを追加
cron.schedule('*/15 * * * *', async () => {
  const jitterMs = Math.floor(Math.random() * 30_000); // 最大 30 秒
  await sleep(jitterMs);
  await processBatch();
});

最大 30 秒のジッターは、予想される 15 分のサイクル内にジョブを保ちながら、スパイクを 30 秒の窓に分散します。より高度な分散については、パターン 5(Jenkins H 構文)を見てください。

ジョブのタイミングをデバッグする際は、タイムスタンプ変換ツールを使って次のスケジュール実行時刻を Unix エポックから人間が読める時刻に変換できます。

パターン 2: 平日業務時間 — 0 9 * * 1-5

0   9   *   *   1-5
|   |   |   |   |
|   |   |   |   +-- 曜日: 月曜-金曜 (1-5)
|   |   |   +------ 月: 毎月
|   |   +---------- 日: 毎日
|   +-------------- 時: 9
+------------------ 分: 0 (正時)

この式は月曜から金曜まで、正確に 9 時 0 分に 1 回発火します。次のような場合の標準パターンです: 日次ダイジェストメールの送信、業務時間レポートの実行、業務日にヒューマンレビューが必要なワークフローのトリガー、エンジニアリング日の開始時にステージング環境にデプロイ。

Kubernetes CronJob では:

# Kubernetes CronJob — 平日 9 時 (UTC) のダイジェストメール
apiVersion: batch/v1
kind: CronJob
metadata:
  name: digest-email
spec:
  schedule: "0 9 * * 1-5"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: mailer
            image: myapp/mailer:latest
            command: ["/app/send-digest"]
          restartPolicy: OnFailure

タイムゾーンの罠。Kubernetes CronJob スケジュールはクラスターのタイムゾーンで実行され、デフォルトは UTC です。「UTC の 9 時」は冬の米国東部時間では 5 時、米国太平洋時間では 4 時です。ユーザーは「朝のダイジェスト」を真夜中に受け取ることになります。IANA タイムゾーン文字列を受け入れる cron ライブラリを使用します:

// タイムゾーン処理 — 「UTC の 9 時」はユーザーが期待するものとは異なる
// IANA タイムゾーンを理解するライブラリを使用する
import { CronJob } from 'cron';

const job = new CronJob(
  '0 9 * * 1-5',
  () => sendDailyDigest(),
  null,
  true,
  'America/New_York' // UTC の 9 時ではなく、米国東部時間の 9 時に実行
);

タイムゾーン変換ツールは、任意の IANA タイムゾーン全体で指定したローカル時刻の UTC 相当を見つけるのに役立ちます。

曜日の番号付け。標準 cron 定義では日曜に 0 または 7 を使います。一部の実装(Quartz、cron4j)は月曜に 0 ではなく 1 を使います。スケジューラーのドキュメントを確認し、デプロイする前に Cron パーサーでテストしてください。

パターン 3: 夜間バッチ — 0 22 * * *

0   22   *   *   *
|    |   |   |   |
|    |   |   |   +-- 曜日: 任意
|    |   |   +------ 月: 任意
|    |   +---------- 日: 任意
|    +-------------- 時: 22 (午後 10 時)
+------------------- 分: 0

1 日 1 回、午後 10 時に実行します。夜間バッチは、データエンジニアリングで最も一般的なパターンの 1 つです: その日のイベントを集計、検索インデックスを再構築、レポートを生成、ログをアーカイブ、終業時通知を送信。

なぜ深夜ではなく午後 10 時なのか?2 つの理由があります。第一に、午後 10 時は日が変わる前のバッファを提供し、遅れて到着するイベントを拾う可能性を減らします。第二に、深夜に重いジョブを実行すると、毎月 1 日に月末請求(パターン 4)と競合することを意味します。

日次ジョブを書く 3 つの最も一般的な方法:

# 「毎日午後 10 時」と書く 3 つの方法:
0 22 * * *    # 標準 cron
@daily        # 深夜 — 午後 10 時とは異なる
0 22 * * *    # 深夜以外の時間には常に @macros よりも明示的な時刻を優先

重複防止。通常 45 分かかる夜間ジョブが、たまに 2 時間かかることがあります — そして次の夜のトリガーが発火しているときにまだ実行中だと、両方のジョブが同じデータを修正します。分散ロックを使用します:

// 冪等性チェック — ジョブが重複する場合の二重処理を防ぐ
async function runNightlyBatch() {
  const lock = await redis.set(
    'nightly-batch-lock',
    process.pid,
    'EX', 3600,   // TTL: 1 時間
    'NX'          // 存在しない場合のみセット
  );

  if (!lock) {
    console.log('バッチが既に実行中、スキップします。');
    return;
  }

  try {
    await processNightlyData();
  } finally {
    await redis.del('nightly-batch-lock');
  }
}

Redis の NX フラグは、一度に 1 つのプロセスのみがロックを保持できることを保証します。TTL は、クラッシュしたジョブがロックを永遠に保持することを防ぎます。

パターン 4: 月次請求 — 0 0 1 * *

0   0   1   *   *
|   |   |   |   |
|   |   |   |   +-- 曜日: 任意
|   |   |   +------ 月: 任意
|   |   +---------- 日: 1 (毎月 1 日)
|   +-------------- 時: 0 (深夜)
+------------------ 分: 0

毎月 1 日の深夜に発火します。次の典型的なパターンです: 月次請求書の生成、使用量クォータのリセット、月末調整の実行、前月のデータのアーカイブ。

月次ジョブの cron の罠:

# 毎月 1 日の UTC 深夜
0 0 1 * *

# ---- 避けるべき罠 ----

# これは「毎月」確実に実行されない:
0 0 29-31 * *   # 日数の少ない月は静かにスキップ

# 四半期請求 (1 月、4 月、7 月、10 月):
0 0 1 1,4,7,10 *

# 月末は厄介 — 標準 cron に「最終日」はない
# @reboot とアプリケーションロジック、または毎日実行する cron で
# コード内で今日が月末かどうかをチェックする方法を使う。

月末請求。各月の最終日(最初の日ではなく)に請求する必要がある場合、クリーンな cron 式はありません。回避策:

// アプリケーションレベルの「月末日」チェック
cron.schedule('0 23 28-31 * *', async () => {
  const today = new Date();
  const tomorrow = new Date(today);
  tomorrow.setDate(today.getDate() + 1);

  // 明日が 1 日なら、今日が月末日
  if (tomorrow.getDate() === 1) {
    await runEndOfMonthBilling();
  }
});

これは毎月 28〜31 日に発火し、コードで今日が実際に最終日かどうかをチェックします。31 日未満の月では最大 4 回不要に実行されますが、チェックは安価で、ロジックは明確です。

四半期初の請求には、月フィールドでリスト演算子を使用します: 0 0 1 1,4,7,10 *(1 月、4 月、7 月、10 月)。

パターン 5: ハッシュ化スケジュール (Jenkins H 構文) — H/30 * * * *

H/30 * * * *
|
+--- H は Jenkins がジョブごとにランダムな分を選びます。
     H/30 = H から始まる 30 分ごと。
     ジョブごとに H が異なり、負荷を分散します。

H トークンは、cron 構文に対する Jenkins 特有の拡張です。H をジョブ名から派生した安定した擬似乱数で置き換えます。分フィールドの H/30 は「ジョブ名のハッシュによって決定された分から始まる 30 分ごと」を意味します。

Jenkinsfile では:

// Jenkinsfile — 30 分ごとにトリガー、負荷分散済み
pipeline {
  triggers {
    cron('H/30 * * * *')
  }
  stages {
    stage('Deploy') {
      steps {
        sh './deploy.sh'
      }
    }
  }
}

なぜこれが重要なのか。Jenkins のインストールには、しばしば数百のパイプラインがあります。すべてのパイプラインが */30 * * * * を使用すると、すべてが :00 と :30 にトリガーされ、エージェントプールと下流システムに巨大な負荷スパイクを生み出します。H/30 では、各ジョブが異なるオフセットを取得します — 1 つは :03/:33、別のは :17/:47 — 時間全体にわたって負荷を均等に分散します。

GitHub Actions は H をサポートしません。複数の Actions ワークフローを分散させるには、異なる固定分オフセットを手動で選びます:

// GitHub Actions — H はサポートされていない; 手動でオフセット付きスケジュールを使用
// 2 つのジョブを分散させる: 異なる固定分を選択
name: Job A
on:
  schedule:
    - cron: '5 * * * *'   # 毎時 :05 に実行

---
name: Job B
on:
  schedule:
    - cron: '35 * * * *'  # :35 に実行 — Job A の 30 分後

これは Jenkins H ほどエレガントではありませんが、少数のワークフローでは同じ効果を達成します。多数のワークフローには、分散スケジューリングをネイティブにサポートする外部オーケストレーターを検討してください。

横断的な懸念事項

冪等性は譲れない

すべての cron ジョブは 2 回実行しても安全であるべきです。インフラストラクチャは故障し、スケジューラーは再起動し、分散ロックは時々タイムアウトします。同じ入力で同じ時刻に 2 回目を実行しても同じ結果を生成するようにジョブを設計します。insert の代わりに upsert を使用し、check-before-act パターンを使用し、外部 API 呼び出しに冪等性キーを使用します。

デッドレターアラート

静かに失敗する cron ジョブは、実行されないジョブよりも悪いです。ジョブが失敗したり、予想される時間枠内に完了しなかった場合、デッドレターキューや監視システムにアラートを送ります。Sentry Crons、Healthchecks.io、Cronitor のようなツールはハートビートチェックを追加します: ジョブが開始時と終了時に URL を ping します。終了の ping が期限内に到着しないと、アラートが発火します。

スケジュールをログに記録する

すべての cron 実行の開始時と終了時に、式、トリガー時刻のタイムスタンプ(現在時刻ではなく、ジッターによりわずかに異なる場合があります)、所要時間を含む構造化ログ行を出力します。これにより、下流の事故 —「データベースが午後 10 時に遅くなった」— を特定のバッチジョブ実行と関連付けるのが容易になります。

クイックリファレンス

Cron パーサーで任意の式を解析し、検証します。タイムゾーン変換ツールでスケジュールされた時刻をタイムゾーン間で変換します。ジョブが Unix タイムスタンプをログに記録する場合、タイムスタンプ変換ツールがすぐに読める日付に変換します。

cron 構文と一般的な例の包括的なリファレンスとして、crontab.guru が標準的なクイックリファレンスです。Jenkins H 構文とその完全仕様については、Jenkins Pipeline cron 構文ドキュメントを参照してください。