DevKitLab Logo DevKitLab
Cron / crontab / スケジューリング / DevOps

Cron 式の読み方(そして思ったとおりに動く式の書き方)

Cron は決まった位置に並ぶ五つのフィールドと、四つの記号にすぎない。どんなスケジュールもひと目で読めるようになり、正しく見える式を誤った時刻に動かす二つの罠を避けよう。

プルリクエストに */15 9-17 * * 1-5 の一行が追加され、レビューは誰も声に出して答えられない問いで止まる。これは結局いつ動くのか。「15 分ごと?」と誰かが当て、「日中の、平日、たぶん」と別の誰かが足す。どちらも近いが、確信は誰も持っていない。

Cron 式は暗号のように見えるが、覚えることはほとんどない。決まった位置に並ぶ五つのフィールドと、四つの記号だ。位置さえ読めれば、*/15 9-17 * * 1-5 は同じ cron 方言の中ではただ一つの意味しか持たず、しかも毎回そのとおりに動く。人がつまずくのは構文ではない。正しく読めるスケジュールが誤った時刻に動く、二つの罠のほうだ。どちらも後で扱う。

五つのフィールド、位置がすべて

標準の cron 行は、スペースで区切った五つのフィールドだ。意味は置かれた位置だけで決まる。ラベルはないので、30 66 30 は別の時刻であって、同じ時刻の二通りの書き方ではない。

左から右へ:

位置フィールド範囲
10–59
20–23
31–31
41–12
5曜日0–7(0 と 7 はどちらも日曜)

だから 30 6 * * * は「6:30」だ。分 30、時 6、あとの三つの星は任意の日・任意の月・任意の曜日を表す。cron を読むコツは、この五つの枠を順にたどり、一つずつ声に出すことだ。何分・何時・何日・何月・何曜日に動くのか、と。

このうち二つのフィールドは意外な形で干渉し合い、それが最初の大きな罠になる。ひとまず覚えておいて、下の「日付フィールド」の節で詳しく見る。

たいていのパーサーでは、月と曜日は三文字の名前も受け付ける。JANDECSUNSAT だ。0 9 * * MON0 9 * * 1 より読みやすく、名前で書けばプラットフォーム間の番号の違いも避けられる(後述)。

四つの記号がすべてを担う

どのフィールドも同じ四つの記号を使う。まずは、この四つを押さえておけば基本形は読める。

  • *(星):任意の値。時フィールドの * は毎時、五つの星なら毎日毎分動く。
  • ,(カンマ):値の並び。分フィールドの 0,30 は毎時の 0 分と 30 分。MON,WED,FRI はその三日だ。
  • -(ハイフン):範囲。時フィールドの 9-17 は 9 時から 17 時まで、17 時も含む。MON-FRI は平日だ。
  • /(スラッシュ):ステップ。分フィールドの */15 は 15 分ごと、つまり 0、15、30、45。範囲の中でも刻める。9-17/2 は 9 時から 17 時まで、2 時間おきだ。

最後の一つがいちばん誤解を生むので、はっきりさせておく。

5   * * * *   # 毎時 5 分、1 時間に 1 回
*/5 * * * *   # 5 分ごと、1 時間に 12 回

5 は一つの点だ。5 分、それも 5 分だけ。*/5 はすべての値をステップで進む。0、5、10……。片方は点、片方はリズムだ。取り違えると、「5 分ごと」が気づかぬうちに「1 時間に 1 回」に化ける。

同じ間違いの、もっと分かりにくい版もある。ステップはフィールドの範囲の先頭から数える。「今」からではない。しかもその範囲は毎時リセットされる。だから分フィールドの */35 は「35 分ごと」ではない。0 分と 35 分に動き、次の時に入るとまた 0 から始まる。つまり「35 分あけて、次は 25 分」がずっと交互に続く。ステップが範囲を割り切れなければ必ずこうなる。本当に 35 分間隔がほしいなら、一つの cron フィールドでは表せない。

冒頭の例もこれで読める。*/15 9-17 * * 1-5:15 分ごと、9 時から 17 時の間、任意の日、任意の月、曜日 1 から 5、標準 cron では月曜から金曜だ。17 時は含まれる点に注意。だから 17:45 まで動き続ける。09:00 から 17:45 までであって、17:00 で止まるわけではない。頭の中で換算したくなければ、Cron 式ツール に貼ればいい。平易な説明を出し、各フィールドを有効範囲つきで分解し、選んだタイムゾーンで次に動く時刻を並べてくれる。既定は 5 件、最大 50 件だ。

数値の曜日は移植できない。標準 cron と GitHub Actions は 0 から数え、0 が日曜だ。ところが Cloudflare Workers は 1 から数え、1 が日曜から 7 が土曜まで、Kubernetes は 0 が日曜だ。だから同じ 1-5 でも、Linux crontab では月曜から金曜、Cloudflare では日曜から木曜になる。プラットフォームが受け付けるなら曜日は名前で書けばいい。MON-FRIFRI のように。そうすれば番号の違いは気にしなくてよい。

日付フィールドの罠:「日」と「曜日」が同時に出るとき

多くの人が一度ははまる落とし穴だ。次の行はいつ動くだろうか。

0 0 13 * FRI

0 分、0 時、13 日、任意の月、金曜。「毎月 13 日の深夜、しかも金曜なら」に見える。違う。標準の Unix cron、つまり多くの Linux crontab が使う Vixie/ISC 系では、「日」と「曜日」の両方が制限されている、どちらも * ではないとき、cron はそのどちらかが一致した時点で動く。だから 0 0 13 * FRI は毎月 13 日の深夜に動き、さらに毎週金曜の深夜にも動く。「かつ」ではなく「または」だ。

一文でいうと、片方の日付フィールドが * ならもう片方がそのまま効く。だが両方に値を書いた瞬間、両者は「かつ」ではなく「または」で結ばれる。だから「13 日かつ金曜」を表す簡単な cron 行は存在しない。二つの条件を同時には要求できないのだ。

この二つのフィールドは実装ごとの食い違いが最も大きい場所でもある。だから「または」は普遍の保証ではなく、よくある既定とみなしておくのがよい。両方の日付フィールドに値があるときは、対象プラットフォームのドキュメントを確認しよう。ツール は両フィールドが制限されているのを見つけると専用の注意を出すので、噛まれる前に気づける。

もう一つの罠:どのタイムゾーンで動くのか

cron 式の中にタイムゾーンは入っていない。0 9 * * * は「9 時」だが、どこの 9 時だろうか。答えは式の外で決まる。ここを間違えると、「朝 9 時」のジョブが深夜 2 時に人を叩き起こす。

昔ながらの crontab はサーバーのローカルタイムゾーンで動く。Cronie 互換の crontab なら、ファイルの先頭に CRON_TZ を置いてスケジュールのタイムゾーンを指定できる。ただ、普通の TZ 環境変数を設定すればスケジュールのタイムゾーンが変わる、と思い込んではいけない。そしてどちらの接頭辞も Kubernetes の spec.schedule の中には入れないこと。Kubernetes はそれを拒否し、別の spec.timeZone フィールドを使う。サーバーを引っ越すか、データセンターの既定が UTC なら、同じ行が違う時刻に動く。多くのマネージドスケジューラはこの曖昧さを避けるため、すべてを UTC に固定する。GitHub Actions の schedule: トリガーも Cloudflare Workers の cron トリガーも、式を UTC で解釈する。例外はない。

夏時間はもう一層を加える。ただしそれは「ローカルの壁時計時刻で照合する」実装、つまり Vixie/Cronie 系の crontab に限る。そこでは深夜 2:30 に設定したジョブは、春の切り替え日には動かない。その壁時計時刻が存在しないからだ。秋の切り替え日には二度動きうる。UTC ベースのプラットフォームにはそもそもローカルの夏時間の飛びがなく、変わるのは同じ実行を自分のゾーンに換算して表示したときの時刻だけだ。これが実際に影響しそうなら、頭の中で計算しないこと。ツール でタイムゾーンを選べば、次の各実行をそのゾーンと UTC の両方で並べてくれるので、夏時間のずれもひと目で分かる。そもそも UTC・オフセット・ゾーンの違いについては、UTC、GMT、ISO 8601、Unix タイムスタンプの違いは? を見てほしい。

五つのフィールド、六つのフィールド、そして @daily などの短縮形

伝統的な Unix cron は五つのフィールドだ。スケジューラによっては先頭にのフィールドを足す。Spring や多くのプログラミング言語の cron ライブラリは六フィールド形式を使い、Quartz は六つの必須フィールドに加えて任意の七つ目「年」を持つ。だから */30 * * * * *(六フィールド)は「30 秒ごと」、*/30 * * * *(五フィールド)は「30 分ごと」だ。何よりまずフィールド数を数えること。先頭に一つ増えるだけで、後ろのフィールドの意味がすべてずれる。

Cronie や Vixie 系の crontab は、五つのフィールドをまるごと置き換える名前つきの短縮形も理解する。

@yearly    # 0 0 1 1 *   年に一度、1 月 1 日の深夜
@monthly   # 0 0 1 * *   毎月 1 日の深夜
@weekly    # 0 0 * * 0   毎週日曜の深夜
@daily     # 0 0 * * *   毎日深夜(@midnight も同じ)
@hourly    # 0 * * * *   毎時ちょうど
@reboot    #             起動時に一度

便利だが万能ではない。一部の crontab 実装だけが対応する拡張だ。とりわけ @reboot は「起動」というライフサイクルを前提にするので、それを持たないマネージドスケジューラはたいてい受け付けない。拒否されるか、そもそも対応していない。短縮形をツールで調べたいときは、まず五フィールド形式に展開しよう。ツールが解析するのは五・六フィールドの数値構文なので、0 0 * * * ならフィールド分解と次回実行の一覧が出るが、@daily 単体では出ない。

? L W # とそのほかの方言拡張

?LW# を見かけたら、それは汎用 cron ではなく方言ごとの拡張構文だ(? は「指定なし」、L は「最後」、W は「最も近い平日」、# は「その月の第何何曜日」)。Quartz と結びつけて語られることが多いが、Quartz 専用ではない。たとえば Cloudflare Workers は LW# の一部に対応する。しかも同じ文字でも意味が違いうる。Kubernetes は ? を受け付けるが、単に * として扱い、Quartz の「指定なし」ではない。だから意味を一つに決めてかからず、使う予定のプラットフォームのドキュメントに従うこと。

これこそ本稿の本当の要点だ。構文はやさしい。だが意味を決めるのは「方言」のほうだ。一行を投入する前に、実際に動く場所と突き合わせておこう。

対象フィールド数タイムゾーン曜日番号拡張
Linux / Cronie5サーバー、または CRON_TZ0–707 は日曜@ 短縮形
GitHub Actions5UTC0–60 は日曜秒なし
Cloudflare Workers5UTC1 は日曜……7 は土曜L W # の一部
Kubernetes CronJob5.spec.timeZone0 は日曜?* として扱う
Quartz6–7スケジューラ設定方言による? L W #

表に収まらない実用的な注意が二つ。GitHub Actions のスケジュールはベストエフォートで、負荷が高いと遅れることがあるので、精密なタイマーとしては使わないこと。そして Windows には cron が存在しない。タスクスケジューラか schtasks を使う。

読み方チェックリスト

cron の一行が目の前に来たら、次のとおり。

  1. まずフィールドを数える。五つが標準、六つなら先頭は秒、@daily のような短縮形は別物だ。
  2. 五つの位置を順に読む。分、時、日、月、曜日を一つずつ声に出す。
  3. 記号を展開する* は毎回、, は並び、- は範囲、/ はステップ。5(点)は */5(リズム)ではない。
  4. 二つの日付フィールドを確認する。「日」と「曜日」の両方に値があれば、標準 cron は「または」で扱い、どちらか一致すれば動く。実際は対象プラットフォーム次第だ。
  5. 実行環境がどのタイムゾーンを使うか確かめる。サーバーローカルか UTC か。式そのものは何も言わないからだ。

これだけで、どの cron 行もひと目で読めるようになる。計算せず確かめたいとき、とくに日付フィールドとタイムゾーンの罠は、式を Cron 式ツール に入れればいい。平易な説明、各フィールドの範囲、そして選んだタイムゾーンでの次回実行時刻が出る。

そして、正しいと確かめた行が、それでも何もしないなら、それは別の問題だ。構文ではなく、環境、crontab の置き場所、あるいはホストそのもの。その半分の実地ガイドが「なぜ cron ジョブが動かないのか」だ。