最終更新: 2026-04-28
cron で詰まった日のこと
去年の秋、BlueSky自動投稿スクリプトを夜中に動かそうとして、crontabを書いた。
30 7 * * * /usr/bin/python3 /Users/taito/scripts/post_bsky.py
動かない。ターミナルで叩くと普通に動く。cron越しだと何も起きない。ログもない。本当に、何も。
3時間調べた結果、地雷が3つ同時に踏まれていた——「macOSはスリープ中にcronが実行されない」「PATHが違う」「.zshrcが読まれないので ollama のパスが通っていない」。どれか1つなら30分で直せた。3つ同時は堪えた。
その日launchdに書き直したら、30分で動いた。それ以来、Macで定期実行するものはlaunchd一択にしている。今は9本のパイプラインが全部launchdで回っている。
そもそも launchd と cron は何が違うのか
Macにおけるcronは「動く場合もある」という存在だと思ったほうがいい。Apple的にはlaunchdが正式な後継で、macOS 10.4 Tigerのころから推奨されている。cronは後方互換で残っているだけ——という立ち位置だ。
launchdで何が変わるかというと、ログ取得が .plist に2行書くだけで終わる。
<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を仕込むかの二択になる。設定ファイルに書き忘れて、ジョブが死んでも何のエラーも出ない状態を1週間放置したことがある。launchdに移ってから、そういう事故はなくなった。
実際に使っている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 に配列で複数時刻を渡せるのが地味に使いやすい。7:00 / 12:30 / 20:30の3回投稿を、1つのplistだけで管理できる。cronだと 0 7,12,20 * * * で書けるといえば書けるが、3ヶ月後に見直したとき「これ何時だっけ」となる。plistは冗長だが読める。
cronでは絶対ハマる PATH 問題
ターミナルで打つと動くのに、cron経由だと command not found になる——これが最初の壁だ。
原因はcronがlogin shellを起動しないこと。~/.zshrc も ~/.zprofile も読まれない。Homebrewで入れたツール(python3、node、各種CLI)は /opt/homebrew/bin にあるが、cronの実行環境のPATHにそこは含まれていない。私が詰まったのも ollama コマンドが見つからないというエラーだった。ターミナルでは動く、cron経由では死ぬ。典型的なやつ。
launchdでも同じ問題は起きる。ただ解決が明示的でわかりやすい。plistの EnvironmentVariables に書くだけで、そのジョブの環境変数が固定できる。一度テンプレを作れば、9本のパイプライン全部に流用できる。
cronで同じことをやるには、crontabの先頭に SHELL=/bin/zsh や PATH=... を書くか、スクリプトの冒頭で source ~/.zshrc を呼ぶかになる。後者はCI環境で予期しない挙動を引き起こすことがあって、やりたくない。
スリープ中に実行できるかどうか
MacBook Pro M5で運用しているので、これは実際に問題になった。
cronはスリープ中に指定時刻を過ぎると、そのジョブを静かにスキップする。起きてから実行されることはない。気づかないまま1週間、BSky投稿が止まっていたことがある。ログが一切なかったので、止まっていること自体に気づくまで3日かかった。
launchdも厳密にはスリープ中は動かない。ただ RunAtLoad と StartInterval を組み合わせると、「起動・復帰後に経過時間ベースで実行する」という動きをさせられる。
<key>RunAtLoad</key>
<true/>
完璧な解決ではないが、cronの「黙ってスキップ」よりはずっとマシだ。今はMacが復帰したタイミングで遅れ分を流す構成にしている。
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以外なら異常終了——という読み方をする。終了コードが78で詰まったこともあるが、それは別の記事に書いた。
実際に詰まったポイント
半年運用して、詰まったポイントは主に2つだった。
ひとつはplistのXML構文ミス。閉じタグを1つ忘れただけで、launchctl load がエラーも出さずに無視する。静かに失敗するので、しばらく気づかなかった。plutil -lint で事前チェックする習慣を付けてからは、このパターンでは詰まらなくなった。
plutil -lint ~/Library/LaunchAgents/com.taito.note-poster.plist
もうひとつはカレントディレクトリの問題。launchd経由で実行すると、カレントディレクトリが / になる。スクリプト内で open('data.json') のような相対パスを書いていると、FileNotFoundError で死ぬ。エラーログを見て「ファイルがない?さっきターミナルで確認したのに」と首をかしげていた。原因がカレントディレクトリだとわかったのは30分後。以来、絶対パスか os.path.dirname(__file__) しか使わない。
cronを使う場面が残るとしたら
正直、今の構成でcronを使う場面はほぼない。
強いて言えば、LinuxのVPSとMacの両方で同じスクリプトを動かしたい場合だ。launchdはMac専用なので、同一の定期実行設定を両環境に持ち込みたいならcronで書いておくほうが移植コストが下がる。ただそういうケースは私の環境ではほとんど発生していない。
Mac専用のパイプラインであれば、launchd一択だと思っている。ログが取れる、PATH問題を明示的に解決できる、plistで設定が自己文書化される——この3点だけで、移行する理由として十分だった。
まとめ
cron → launchdの移行で一番変わったのは、「自動化が壊れていることに気づける」環境になったことだ。
以前はcronがスキップしてもログが何もなくて、気づかず1週間投稿が止まっていた。launchdになってからはエラーログをDiscord Botが拾って、朝イチで「昨夜の実行でエラー出てます」と通知が来る構成になっている。9本のパイプラインが毎朝ちゃんと動いているかどうかを、コーヒーを飲みながら確認できる。
自動化は動いて終わりじゃない——壊れたときに気づける仕組みまで込みで、完成だと思っている。✨