cron で詰まった日のこと
去年の秋、Pythonスクリプトを夜中に自動実行させようとして、crontabに書いてみた。
30 7 * * * /usr/bin/python3 /Users/taito/scripts/post_bsky.py
動かない。ログもない。何も起きない。
原因を調べると「macOSはスリープ中にcronが動かない」「PATHが違う」「ログインシェルじゃないから.zshrcが読まれない」——という地雷が三段重ねで待っていた。結局その日は3時間溶かした。
launchdに書き直したら30分で動いた。それ以来、Macで定期実行するときはlaunchd一択になっている。
そもそも launchd と cron は何が違うのか
Macにおけるcronは「動く場合もある」程度の存在だと思っておいたほうがいい。Apple的にはlaunchdが正式な代替で、macOS 10.4 Tigerの時代から推奨されている。cron自体は後方互換で残っているだけだ。
launchdの何がいいかというと、まずログが標準出力・標準エラーを .plist に書くだけで捕捉できる。これだけでデバッグのストレスが半分になる。
<key>StandardOutPath</key>
<string>/tmp/my_job.log</string>
<key>StandardErrorPath</key>
<string>/tmp/my_job_err.log</string>
cronでこれをやろうとすると >> /tmp/log 2>&1 をスクリプトごとに書くか、スクリプト内でloggingを仕込むかの二択になる。めんどくさい。
実際に使っているplistの構造
僕が今動かしているパイプラインのひとつ、note記事の自動投稿はこんな形になっている。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.taito.note-poster</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/taito/Documents/AI_Automation_Base/01_Scripts/sns/post_note_from_queue.py</string>
</array>
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key><integer>7</integer>
<key>Minute</key><integer>0</integer>
</dict>
<dict>
<key>Hour</key><integer>12</integer>
<key>Minute</key><integer>30</integer>
</dict>
<dict>
<key>Hour</key><integer>20</integer>
<key>Minute</key><integer>30</integer>
</dict>
</array>
<key>StandardOutPath</key>
<string>/Users/taito/Documents/AI_Automation_Base/04_Config/logs/note_poster.log</string>
<key>StandardErrorPath</key>
<string>/Users/taito/Documents/AI_Automation_Base/04_Config/logs/note_poster_err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>/Users/taito</string>
</dict>
</dict>
</plist>
StartCalendarInterval に配列で複数時刻を書けるのが地味に便利。cronだと 0 7,12,20 * * * で書けるといえば書けるが、可読性は下がる。
cronでは絶対ハマる PATH 問題
cronで最初に詰まるのはほぼここだ。
ターミナルで打つと動くのに、cron経由だと command not found になる——というやつ。原因はcronがlogin shellを起動しないため、~/.zshrc も ~/.zprofile も読まれないことにある。homebrew で入れたツール(python3、node、各種CLI)は /opt/homebrew/bin にあるが、cronの実行環境のPATHにそこは入っていない。
launchdでも同じ問題は起きる。ただ解決策が明示的で分かりやすい。上のplistの EnvironmentVariables に書くだけ。一度書けば全ジョブで同じテンプレを流用できる。
cronで同じことをやろうとすると SHELL=/bin/zsh や PATH=... をcrontabの先頭に書くか、スクリプトの頭に source ~/.zshrc を書くかになる。後者は副作用もあるし、CI環境で壊れたりもする。
スリープ中に実行できるかどうか
MacBook使いには特に関係する話。
cronはスリープ中に指定時刻を通過すると、そのジョブは実行されない。起きてから動くわけでもない。静かにスキップされるだけだ。
launchdも厳密にはスリープ中は動かないが、ひとつ大きな違いがある。「スリープ中に通過した時刻のジョブを、復帰後に実行する」という挙動が設定できる。
<key>RunAtLoad</key>
<true/>
これ単独では「ロード時に一度実行する」という意味だが、StartInterval と組み合わせると「起動後に経過時間で実行する」という動き方になる。完璧ではないが、cronの「黙ってスキップ」よりはるかにマシだ。
launchd の使い方——3コマンド覚えれば十分
設定ファイルの置き場所はここ一択(ユーザースコープの場合):
~/Library/LaunchAgents/
あとは3つのコマンドだけ覚えればいい。
# 登録(起動)
launchctl load ~/Library/LaunchAgents/com.taito.note-poster.plist
# 停止・解除
launchctl unload ~/Library/LaunchAgents/com.taito.note-poster.plist
# 動いているか確認
launchctl list | grep com.taito
launchctl list の出力は PID\t終了コード\tLabel の3列。PIDが数字ならいま動いている、0なら停止中、終了コードが0以外なら異常終了した、という読み方をする。
実際に詰まったポイント
移行してから半年、失敗もいくつかあった。
ひとつはplistのXML構文ミス。閉じタグを忘れただけでlaunchctlがエラーも出さずにロードを無視する。plutil -lint で事前チェックする習慣をつけてから詰まらなくなった。
plutil -lint ~/Library/LaunchAgents/com.taito.note-poster.plist
もうひとつはスクリプト内でカレントディレクトリを仮定していたケース。launchd経由の実行は / がカレントディレクトリになる。スクリプト内で open('data.json') みたいな相対パスを書いていると当然壊れる。絶対パスか os.path.dirname(__file__) で解決する。
cronを使う場面が残るとしたら
正直、今の構成でcronを使う場面はほぼない。
強いて言えば、サーバー(Linux)との互換性を持たせたいスクリプトを書くとき。ローカルMacとVPSの両方で動かしたいジョブがあれば、cronで書いておくほうが移植コストが下がる。
ただMac専用のパイプラインであれば、launchd一択だと思っている。ログが取れる、PATH問題が明示的に解決できる、plistで設定が自己文書化される——この3点だけで十分な理由になる。
まとめ
cron → launchd の移行で変わったことを振り返ると、「自動化が壊れていることに気づける」環境になった、というのが一番大きい。
以前はcronがスキップしてもサイレントで、気づかず1週間投稿が止まっていたこともあった。launchdになってからはエラーログを拾って、Discord Botが朝イチで「昨夜の実行でエラー出てます」と知らせてくれる構成になっている。
自動化は動いて終わりじゃなくて、壊れたときに気づける仕組みまで込みで完成だ——と今は思っている。✨