開発・技術選定

cron の「日」と「曜日」は AND ではない — 月次バッチが月5回走った話

0 0 1 * 1 は「毎月1日の月曜」ではありません

まず答えから書きます。この cron 式は 「毎月1日」と「毎週月曜」の両方で実行されます

0 0 1 * 1
    │ │ └── 曜日:月曜
    │ └──── 月:毎月
    └────── 日:1日

「1日かつ月曜」を意図して書いたのに、実際は 月に5〜6回動きます。2026年8月なら 1日・3日・10日・17日・24日・31日 の6回です。

月次のバッチをこの書き方で仕込むと、請求処理が月5回走ることになります。私が実際に引き継いだシステムで起きていた事故です。

仕様としてそうなっています

POSIX の crontab 仕様に、こう書かれています。

日(day of month)と曜日(day of week)の両方が制限されている場合、コマンドはいずれか一方に一致したときに実行される

つまり判定はこうなります。

曜日 判定
* * 毎日
1 * 1日のみ(AND のように振る舞う
* 1 月曜のみ(同上
1 1 1日 または 月曜(OR)

片方が * のときだけ、期待どおりに動きます。 両方を指定した瞬間に OR に切り替わる、という非対称な仕様です。

これが厄介なのは、普段は正しく動いてしまう点です。0 3 * * 1(毎週月曜3時)も 0 3 1 * *(毎月1日3時)も期待どおり動くので、両方書けば絞り込めると考えてしまいます。

なぜこうなっているのか

歴史的な経緯です。初期の cron は「毎月15日」と「毎週金曜」のように、どちらかの周期で動かしたい用途を想定していました。両方を満たす日を指定する需要は想定されていません。

AND が必要になったのは、月末処理や「第2火曜」のような業務要件が出てきてからです。仕様が先にあり、要件が後から来た形です。

「毎月第1月曜」をどう書くか

cron 式だけでは書けません。スクリプト側で判定します。

#!/usr/bin/env bash
set -euo pipefail

# 毎週月曜に起動し、その日が第1週でなければ何もしない
day=$(date +%-d)
if [ "$day" -gt 7 ]; then
  echo "第1月曜ではないため終了します(本日 $day 日)"
  exit 0
fi

/usr/local/bin/monthly-report.sh

crontab 側はこうします。

0 3 * * 1  /usr/local/bin/first-monday.sh

曜日だけを指定し、日は * のままにするのが要点です。これで OR 判定を踏みません。

Python なら次のように書けます。

from datetime import date

def is_first_monday(today: date | None = None) -> bool:
    today = today or date.today()
    return today.weekday() == 0 and today.day <= 7

weekday() は月曜が 0 です。isoweekday() だと月曜が 1 になるので、取り違えないでください。

実装によって挙動が違う箇所があります

*/2 のようにステップ付きで全体を指定した場合、実装によって「制限あり」と判定されるものがあります。

Vixie cron(多くの Linux ディストリビューションが採用)は、フィールドが文字どおり * かどうかで判定しています。したがって */1 は「全ての日」を表しますが、* ではないため制限ありとして扱われ、OR 判定に入ります

意図せず OR に入らないよう、制限しない意図のフィールドは * と素直に書くのが安全です。

動作を確認する方法

仕込む前に確認してください。設定を投入してから1ヶ月待って気づく、では遅すぎます。

1. 次の実行日時を計算する

Python の croniter が手軽です。

from croniter import croniter
from datetime import datetime

it = croniter("0 0 1 * 1", datetime.now())
for _ in range(5):
    print(it.get_next(datetime))

2. 実際に流して確かめる

短い間隔の式に置き換え、ログが期待どおり出るかを見ます。本番に入れる前の数分で済みます。

* * * * *  /usr/bin/logger -t crontest "fired"

journalctl -t crontest -f で追えます。確認が終わったら必ず消してください。

そのほか、cron で踏みやすい罠

環境変数が読み込まれません。 cron から起動されるシェルはログインシェルではないため、.bashrc.profile も読まれません。手元で動くのに cron だと動かない原因の大半がこれです。スクリプト内で PATH を明示してください。

多重起動します。 前回の処理が終わる前に次が始まると、二重に走ります。flock で排他してください。

*/5 * * * * /usr/bin/flock -n /tmp/job.lock /usr/local/bin/job.sh

タイムゾーンはサーバー設定に従います。 UTC のサーバーで「毎日9時」と書くと、日本時間の18時に動きます。timedatectl で確認してください。

標準出力がメールで飛びます。 MTA が動いていないと、エラーに気づけません。ログファイルへリダイレクトするか、監視に送ってください。

確認用のツールを置いています

上記を毎回手で確かめるのが面倒なので、cron 式を貼ると日本語の説明と次の実行日時を返すツールを作って公開しています。

日と曜日を両方指定した式には、OR 判定になる旨の警告を出すようにしています。入力内容はサーバーへ送信せず、すべてブラウザ内で処理しています。

同じ場所に、実務で必要になって作ったものを置いています。

まとめ

  • 0 0 1 * 1 は「1日または月曜」。AND ではありません
  • 片方を * にしたときだけ、期待どおりに絞り込めます
  • 「第1月曜」のような条件は、曜日だけ指定してスクリプト側で判定します
  • 制限しない意図のフィールドは、*/1 ではなく * と書いてください
  • 仕込む前に、次の実行日時を必ず確認してください