てぶら

cronで毎月末に実行する方法 — 「L」が使えない環境での書き方

公開日:

締め処理や月次レポートの作成など、「毎月末日に実行したい」ジョブはよくあります。ところが月末は月によって28日・29日・30日・31日と変わるため、cronでどう書けばよいか迷いがちです。この記事では、Linuxの標準的なcronで月末に実行する定番の方法と、そのときにハマりやすい落とし穴、systemdタイマーやクラウドのスケジューラーでの書き方をまとめます。

標準のcronには「月末」を表す記法がない

Linuxで広く使われているcron(Vixie cron系のcronieなど)は「分 時 日 月 曜日」の5フィールドで、各フィールドに書けるのは数値・*・範囲(-)・列挙(,)・間隔(/)だけです。ネットでよく見かける「L」(月の最終日)や「W」(最寄りの平日)、「#」(第n曜日)は、Quartzなど一部のスケジューラーが独自に拡張した記法です。標準のcronに書くと、crontabの保存時にエラーになるか、意図しない動作になります。

また「0 0 31 * *」と書くと、31日がない2月・4月・6月・9月・11月には実行されません。「0 0 30 * *」なら2月は実行されず、31日まである月では月末の前日に動いてしまいます。月末に実行するには、もう一工夫が必要です。

定番の方法: 28〜31日に起動して「明日が1日か」を判定する

最もよく使われるのが、28日から31日まで毎日起動し、コマンド側で「明日が1日かどうか」を判定する方法です。明日が1日なら今日は月末なので、そのときだけ本来の処理を実行します。

crontab
# 毎月末日の23:55に実行する(GNU date の場合)
55 23 28-31 * * [ "$(date -d tomorrow +\%d)" = "01" ] && /path/to/monthly-job.sh

ポイントは「%」の前のバックスラッシュです。crontabのコマンド部分では、エスケープしていない % は改行として扱われ、それ以降の文字列はコマンドの標準入力に渡されてしまいます。dateコマンドの書式指定のように % を含む場合は、必ず \% と書いてください。

なお date -d tomorrow はGNU coreutils(一般的なLinuxディストリビューション)の書き方です。macOSやFreeBSDのdateでは -d が使えないため、date -v+1d +%d のように書きます。BusyBoxを使う軽量なコンテナイメージ(Alpine Linuxなど)では、tomorrow のような指定を解釈できない場合もあります。

判定をスクリプトに移す・翌月1日に実行する

crontabの1行が長くなると、エスケープ漏れなどのミスが起きやすくなります。判定はスクリプトの先頭に書くのもおすすめです。スクリプトの中では % をエスケープする必要はありません。

bash
#!/bin/sh
# 明日が1日でなければ(今日が月末でなければ)何もせず終了する
if [ "$(date -d tomorrow +%d)" != "01" ]; then
  exit 0
fi
# ここから月末の処理

そもそも「月末の夜」に実行する必要がなければ、「翌月1日の0時に前月分を処理する」と考え方を変えるのが最もシンプルです。0 0 1 * * なら標準のcronだけで確実に書け、月の日数を気にする必要もありません。締め処理のように「その月のデータがそろってから実行したい」ジョブでは、こちらのほうが安全な場合もあります。

cronはサーバーのタイムゾーン設定で時刻を解釈します。サーバーがUTCで動いている場合、日本時間の月末とは日付の切り替わりが9時間ずれる点にも注意してください。

systemdタイマーなら月末を直接指定できる

systemdが動いているLinuxでは、cronの代わりにsystemdタイマーを使う方法もあります。OnCalendarの書式では、月と日の区切りの「-」の代わりに「~」を書くと、月末から数えた日を指定できます。

ini
[Timer]
# 毎月の最終日 23:55:00
OnCalendar=*-*~01 23:55:00
Persistent=true

「*-*~01」は毎月の最終日、「*-*~02」なら最終日の前日を表します。日の前の「-」を残したまま「~」を足すと書式エラーになるので注意してください。「Fri *-*~07/1」のように曜日と組み合わせると「月の最後の7日間のうちの金曜日」、つまり毎月最終金曜日も表現できます。書いた式が次にいつ実行されるかは systemd-analyze calendar '*-*~01 23:55:00' で確認できます。

Persistent=true を指定すると、電源が切れていて実行できなかった場合に、次の起動時に実行されます。なお「~」の記法はsystemd 233(2017年)で追加されたもので、それより古いsystemdでは使えません。たとえばCentOS 7のsystemdはバージョン219のため、この書き方は使えません。

GitHub Actions・Quartz・AWSでの書き方

GitHub Actionsの schedule トリガーも5フィールドのcron式で、L は使えません。時刻はUTCで解釈されるため、日本時間(UTC+9)で考える場合は9時間ずらします。月末に実行したい場合は、28〜31日に起動してステップの中で判定するのが定番です。

yaml
on:
  schedule:
    - cron: "0 12 28-31 * *" # UTC 12:00 = 日本時間 21:00
jobs:
  monthly:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: 月末だけ実行する
        run: |
          if [ "$(TZ=Asia/Tokyo date -d tomorrow +%d)" != "01" ]; then
            echo "月末ではないためスキップします"
            exit 0
          fi
          ./scripts/monthly-job.sh

スケジュール実行は混雑時に遅れて開始されることがあり、指定した時刻ちょうどの実行は保証されていません。日付が変わる直前に設定すると、遅れて翌日に実行されたときに判定が外れてしまうため、この例のように時刻に余裕を持たせておきましょう。

一方、JavaのQuartz Schedulerやその書式を採用したスケジューラーでは L が使えます。Quartzの式は先頭に秒のフィールドがあり、「0 55 23 L * ?」で毎月末日の23時55分です(日と曜日のどちらかは「?」にする決まりです)。AWSのEventBridgeのcron式は「分 時 日 月 曜日 年」の6フィールドで、cron(55 14 L * ? *) のように L を使えます。EventBridgeのルールはUTCが基準なので、この例は日本時間の23時55分にあたります。

落とし穴: 日と曜日を両方指定するとOR条件になる

「毎月最終金曜日」のつもりで 0 0 25-31 * 5 と書くのはよくある間違いです。標準のcron(Vixie cron系)では、日と曜日の両方を * で始まらない値で指定すると「どちらか一方を満たせば実行」というOR条件になります。どちらかが * で始まる場合(*/2 なども含む)は、両方を満たす日だけが対象です。この式は「25〜31日の毎日」と「毎週金曜日」の両方で実行されてしまいます。

最終金曜日のような条件は、曜日だけを指定して起動し、日付はコマンド側で判定します。今日が金曜日で、7日後の日付が7以下(翌月)なら、今日が最終金曜日です。

crontab
# 毎月最終金曜日の0:00に実行する(GNU date の場合)
0 0 * * 5 [ "$(date -d '+7 days' +\%d)" -le 7 ] && /path/to/job.sh

Cron式解析ツールで実行予定を確認する

このサイトのCron式解析ツールでは、5フィールドの式を入力すると次回以降の実行予定を5件表示します。例えば 55 23 28-31 * * を入力すると、28日・29日・30日・31日のそれぞれに実行予定が並ぶため、cronの式だけでは月末を判定できず、コマンド側の判定が必要なことがひと目でわかります。

ツールは範囲(28-31)・間隔(*/15 や 1-10/2)・列挙・曜日の7(日曜日)に対応し、日と曜日の両方を * で始まらない値で指定したときは、標準のcronと同じOR条件で計算します。L・W・# のような拡張記法や、秒・年を含む6フィールド以上の式には対応していません。実行予定はブラウザのタイムゾーンで表示されるので、サーバーやGitHub ActionsがUTCで動いている場合は時差を読み替えてください。

関連ツール