Mac を買い替えて旧 Mac から環境を移行した際、ついでに cron から launchd に乗り換えた。改めて cron・launchd・systemd timer の仕様差を整理し、変換ツールも作ったのでその記録。

3形式の仕様差

まず、同じ「平日9時に実行」を3形式で書くとこうなる。

cron

# 平日9時に実行
0 9 * * 1-5

# 5分ごと
*/5 * * * *

# 毎月1日と15日の0時
0 0 1,15 * *

5フィールド(分 時 日 月 曜日)で表現する。レンジ(1-5)、ステップ(*/5)、リスト(1,15)が書けるので、たいていのパターンは1行で収まる。

launchd(macOS)

単純なケースは StartCalendarInterval で素直に書ける。

<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key>
  <integer>9</integer>
  <key>Minute</key>
  <integer>0</integer>
</dict>

問題は「平日9時」のような指定。launchd の CalendarInterval はレンジもステップも受け付けないため、曜日を1つずつ列挙する必要がある。

<key>StartCalendarInterval</key>
<array>
  <dict>
    <key>Weekday</key>
    <integer>1</integer>
    <key>Hour</key>
    <integer>9</integer>
    <key>Minute</key>
    <integer>0</integer>
  </dict>
  <dict>
    <key>Weekday</key>
    <integer>2</integer>
    <key>Hour</key>
    <integer>9</integer>
    <key>Minute</key>
    <integer>0</integer>
  </dict>
  <!-- ... 水・木・金も同様 -->
</array>

一方「5分ごと」のような等間隔は StartInterval(秒数指定)で書く。CalendarInterval とは別の仕組み。

<key>StartInterval</key>
<integer>300</integer>

systemd timer

OnCalendar はかなり柔軟で、cron に近い感覚で書ける。

[Timer]
OnCalendar=Mon..Fri *-*-* 09:00:00
Persistent=true

等間隔の場合は OnUnitActiveSec を使う。

[Timer]
OnBootSec=5min
OnUnitActiveSec=5min

変換ロジックの設計

3形式を相互変換するにあたって、内部表現を cron の5フィールド(minute, hour, day, month, weekday)に統一した。フォームからの入力もこの形式にまとめて、出力時にそれぞれの形式に変換する。

変換の方針は大きく2つに分かれる。

  • 純粋な等間隔*/5 * * * * のように、1フィールドだけステップで他が全ワイルドカード): launchd は StartInterval、systemd は OnUnitActiveSec で出力
  • それ以外: cron のフィールドを数値の配列に展開して、launchd は CalendarInterval のエントリ群に、systemd は OnCalendar の式に変換

この「等間隔かどうか」の分岐を最初に入れたおかげで、後の処理がだいぶ整理できた。

フィールド展開の実装

変換の核になるのは、cron フィールド(*/59-171,3,5)を数値の配列に展開する関数。

function expandField(field: string, min: number, max: number): number[] {
  if (field === '*') {
    const arr: number[] = [];
    for (let i = min; i <= max; i++) arr.push(i);
    return arr;
  }

  const results = new Set<number>();
  for (const part of field.split(',')) {
    // ステップ: */5 や 9-17/2
    const stepMatch = part.match(/^(\*|\d+-\d+)\/(\d+)$/);
    if (stepMatch) {
      const step = parseInt(stepMatch[2], 10);
      let start = min, end = max;
      if (stepMatch[1] !== '*') {
        const range = stepMatch[1].split('-');
        start = parseInt(range[0], 10);
        end = parseInt(range[1], 10);
      }
      for (let i = start; i <= end; i += step) results.add(i);
      continue;
    }
    // レンジ: 9-17
    const rangeMatch = part.match(/^(\d+)-(\d+)$/);
    if (rangeMatch) {
      for (let i = parseInt(rangeMatch[1], 10); i <= parseInt(rangeMatch[2], 10); i++)
        results.add(i);
      continue;
    }
    // 単一値
    const num = parseInt(part, 10);
    if (!isNaN(num)) results.add(num);
  }
  return Array.from(results).sort((a, b) => a - b);
}

ステップ・レンジ・リスト・単一値を正規表現で判定して、Set に追加してからソートする。カンマ区切りの各パートを独立に処理するので、1-5,15,*/10 のような混合指定にも対応できる。

cron → launchd 変換の勘所

展開した数値の配列を組み合わせて CalendarInterval のエントリを生成する。ここが一番ややこしかった。

// 各フィールドを展開して組み合わせを生成
const mList = isAllMinutes ? [undefined] : minutes;
const hList = isAllHours ? [undefined] : hours;
// ...

for (const h of hList) {
  for (const m of mList) {
    for (const w of wList) {
      const entry = {};
      if (m !== undefined) entry.Minute = m;
      if (h !== undefined) entry.Hour = h;
      if (w !== undefined) entry.Weekday = w;
      entries.push(entry);
    }
  }
}

ポイントは *(毎回)のフィールドをどう扱うか。cron で * のフィールドは「すべての値にマッチ」だが、launchd では CalendarInterval のキーを省略することで同じ意味になる。たとえば Minute を省略すると「毎分」を意味する。これを undefined で表現して、値がある場合だけ XML のキーを出力するようにした。

フィールドが多い式(0 9-17 * * 1-5 など)を展開すると、9時間 × 5曜日 = 45エントリになる。plist としては冗長だが、launchd の仕様上これ以外に正確な表現方法がない。ツールとしては正確さを優先して全展開する方針にした。

cron → systemd 変換

systemd の OnCalendar は cron よりも表現力があるので、変換は比較的素直。Mon..Fri のようにレンジが書けるし、カンマ区切りのリストも使える。

フォーマットは 曜日 年-月-日 時:分:秒。曜日は Mon,Tue,Wed のように英語略称で並べ、日時部分は * をそのまま使える。cron のフィールドをほぼそのまま埋め込めるので、launchd ほどの展開は必要なかった。

つまずいたところ

launchd の曜日は 0=日曜

cron も launchd も 0 が日曜なので問題ないと思っていたが、一部の cron 実装では 7 も日曜として扱う。今回は 0-6 に正規化する方針にしたが、7 を入力されたときの処理を入れ忘れて最初はおかしな曜日が出ていた。

「次回実行日時」の算出が意外に重い

次の実行タイミングを5件表示する機能をつけた。単純に現在時刻から1分ずつ進めてマッチするか判定するブルートフォースで実装したが、0 0 29 2 *(2月29日、つまりうるう年だけ)のようなレアケースだと数年分を回す必要がある。上限を1年分(525,600回)に設定して、見つからなければ「1年以内に実行予定がありません」と表示するようにした。

等間隔検出の判定

*/5 * * * * は「5分ごと」なので StartInterval: 300 にしたいが、*/5 9-17 * * * は「9時台〜17時台の5分ごと」なので等間隔ではない。最初は minute フィールドだけ見て判定していたが、他のフィールドに制約があるケースを見落としていた。「ステップ指定のフィールドが1つだけ、かつ他が全て *」の場合のみ等間隔と判定するように修正した。

できあがったもの

cron / launchd / systemd スケジュール変換ツールとして公開した。フォームで月・日・曜日・時・分を選ぶと3形式をタブで切り替えて確認でき、手元の cron 式を貼り付けて launchd・systemd に変換することもできる。

今回の Mac 移行では、旧 Mac の crontab にあった5つのジョブを全部このツールで launchd に変換した。レンジやステップがあると plist が長くなるのは避けられないが、手で展開するよりは漏れがない。Linux サーバー側の systemd timer もここから生成している。

関連記事