2026年6月9日
macOSlaunchdAutomationDevOps

Mac で cron を使うのはもうやめよう、launchd を使え

cron はスケジュールに沿ってポーリングし、ノートPCがスリープしている間のジョブを取りこぼす。launchd の WatchPaths はポーリングではなくファイルシステムのイベントに反応する。これが cron を完全に置き換えた自動化だ。

2 分で読めます

自分のマシンには、ポーリングではなく変化に反応してほしかった。答えは、ほとんど誰も使っていない20年前からある macOS の機能だった。そしてそれは、重要なあらゆる点で cron を上回った。

先週、マシンの設定を自動でコミットする仕組みを作った。Homebrew のパッケージをインストールすると、8秒後には GitHub にそれを記録した git コミットができている。

作り始めたとき、体に染み付いた反射神経はどの開発者とも同じことを言っていた。

とりあえず cron に入れておけばいい。

そうはしなかった。その理由が、ここ数年で学んだ中で最も役に立つ macOS の教訓になった。

⏰ ポーリングの問題

自分の自動化を cron 版で書くと、こうなる。

*/5 * * * * /Users/me/sync.sh

5分おきにポーリングする cron と、ファイルシステムのイベントで発火する launchd

5分おきに、永遠に、自分のマシンはプロセスを起こしてこう尋ねる。何か変わった?

ほとんど毎回、答えは「いいえ」だ。

これが cron の根本的な形だ。ポーリング。イベントのことは知らない。時間のことしか知らない。だから悪いトレードオフを強いられる。

  • 頻繁にポーリングする → 念のためだけに、1日に何百回も無意味な実行が発生する
  • たまにしかポーリングしない → 変更が1時間もコミットされずに放置され、目的そのものが台無しになる

そしてノートPC上の cron には、もう一つの厄介な問題がある。cron はマシンが起動していることを前提としている。11時58分にふたを閉じれば、正午のジョブは単純に実行されない。リトライもキャッチアップも何もない。サーバーは常に起動しているが、ノートPCはスリープする。cron はサーバー向けに設計されたものだ。

自分が本当に欲しかったものは、言葉にすると単純だった。

ファイルシステムが変化したら、スクリプトを実行する。それ以外は、何もしない。

これはスケジュールではない。イベントだ。

⚙️ すでに手元にあったデーモン

macOS には launchd が最初から入っている。文字通りプロセス1、システム上のあらゆるサービスを起動しているものだ。Apple は何年も前に内部的に cron を launchd に置き換えている(Mac の cron ジョブ自体、実は launchd によって実行されている)。

launchd の存在は多くの開発者が知っている。だがその一番の機能を使っている人はほぼいない。

WatchPaths、launchd は OS レベルでファイルシステムパスを監視し、それが変化した瞬間にジョブを発火させる。

<key>WatchPaths</key>
<array>
    <string>/opt/homebrew/Cellar</string>
    <string>/opt/homebrew/Caskroom</string>
</array>

これがトリックのすべてだ。Homebrew はすべてのパッケージを /opt/homebrew/Cellar にインストールする。つまり、どのシェルから、どのスクリプトから、どのツールからであれ、インストールやアンインストールが起きればそのディレクトリが変化する。OS がそれに気づく。自分のスクリプトが実行される。

ポーリングなし。無駄な実行なし。アイドル中の CPU 使用はゼロ。ジョブは登録された意図として存在し、監視するのはカーネル自身だ。

ジョブ定義の全体は一つの plist ファイルに収まる。

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.paurushrai.dotfiles-sync</string>
 
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/paurushrai/Developer/infra/dotfiles/sync.sh</string>
    </array>
 
    <key>WatchPaths</key>
    <array>
        <string>/opt/homebrew/Cellar</string>
    </array>
 
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key><integer>12</integer>
        <key>Minute</key><integer>0</integer>
    </dict>
 
    <key>ThrottleInterval</key>
    <integer>60</integer>
 
    <key>StandardOutPath</key>
    <string>/Users/paurushrai/Library/Logs/dotfiles-sync.log</string>
</dict>
</plist>

~/Library/LaunchAgents/ に置いて、一度だけ登録する。

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.paurushrai.dotfiles-sync.plist

再起動しても生き残る。ターミナルが開いているかどうかにかかわらず動く。もう OS の一部だ。

正直な方法でテストした。パッケージをインストールして、それ以外は何も触らない。

$ brew install jq
... 8秒後、シェルの関与ゼロで:
67ba27e chore: auto-sync .Brewfile  → pushed to GitHub ✅

brew install jq が8秒で GitHub への自動コミットとプッシュを引き起こす様子

🧩 重労働をこなす3つのキー

上の plist には3つの設計判断が組み込まれていて、それぞれが本来なら自分で書かねばならなかったコードを肩代わりしている。

launchd の3つのキー: ThrottleInterval、StartCalendarInterval、StandardOutPath

ThrottleInterval: 60 は無料のデバウンサーだ。 パッケージを5つ連続でインストールすると、ファイルシステムは5つのイベントを発火させる。だが launchd は60秒に1回しかジョブを実行しないので、5つ全部をまとめた1回のコミットになる。cron の世界では、こういうバッチ処理は自分で書いてテストするロジックだ。ここでは整数一つで済む。

StartCalendarInterval は cron のスケジュールだ、ただし cron のノートPC問題は抜きで。 監視が取りこぼすものの受け皿として、正午の毎日実行を用意してくれる。そして cron と違い、正午にマシンがスリープしていてもlaunchd は目覚めたときにジョブを実行する。取りこぼしたスケジュールは、消えるのではなく遅れて実行される。ノートPCにとって、この一点だけで比較は終わる。

StandardOutPath は無料で手に入る可観測性だ。 実行のたびに出力が、自分で選んだログファイルに追記される。何かが発火しなかった理由を疑ったとき、答えはすでに書き留められていた。

🔁 誰もが引っかかる罠

さて、告白の時間だ。自分のスクリプトは git リポジトリにコミットする。そして最初の直感は、リポジトリのディレクトリ自体を監視することだった。監視エントリ一つで、全ファイルをカバーできる。エレガントだ。

それは同時に無限ループでもある。

フィードバックループ: コミットが .git を変化させ、それがエージェントを発火させ、それがまたコミットする

git のコミットはすべて**.git/ を変化させる**。つまり、変化がエージェントを発火させる → エージェントがコミットする → そのコミットが .git/ を変化させる → それは監視対象ディレクトリの中にある → 再びエージェントを発火させる。永遠に。

この修正は、記憶しておく価値のあるルールだ。

自分のジョブが書き込む先のディレクトリを監視してはいけない。

代わりに個々のファイルを監視している。追跡対象の dotfile 一つひとつが自前の WatchPaths エントリを持ち、リポジトリのルートは意図的に外してある。トレードオフ:深い階層にある真新しいファイルはすぐには気づかれない。正午の定期実行がそれを拾い上げる。自分の尻尾を追いかけないシステムのための、公正な代償だ。

同じ午後にはまり込んだ、もう二つの罠。これで自分の授業料を払わずに済むように。

  • plist をシンボリックリンクしてはいけない。 plist は dotfiles リポジトリに置き、~/Library/LaunchAgents/ にはコピーする。launchd は現代の macOS では、シンボリックリンクされたジョブ定義に対して信頼できない挙動をする。リポジトリが真実の源であり、インストーラーがそのコピーをデプロイする。
  • launchd のジョブは最小限の環境しか持たない。 自分のシェルの PATH も、エイリアスも、.zshrc も何もない。自分のスクリプトは1行目で独自の PATH を設定している。ジョブが「ターミナルでは動くのに launchd では動かない」なら、ほぼ間違いなくこれが原因だ。

🔔 まず話させ、それから待たせる

見えないバックグラウンドジョブは、信頼されなくなるバックグラウンドジョブだ。

自分のスクリプトは、macOS ネイティブの一行でコミットのたびに知らせてくる。

osascript -e 'display notification "Committed & pushed: .Brewfile" with title "dotfiles sync"'

最初のテスト:一度も見なかった。バナー通知は5秒で消える。

修正はコードではなく、ほとんど誰も変えない設定だった。システム設定 → 通知 → スクリプトエディタ → 通知スタイル「通知」。アラート形式の通知は、消去するまで画面に残り続ける。これで同期のたびに、変更されたファイル名を添えたメッセージが、隅で自分が確認するのを待つようになった。

ここで効いてくる設計上の細部:スクリプトは実際に何かがコミットされたときだけ通知する。何も起きなかった実行は静かなままだ。常に鳴る通知は、すでに無視することを学んでしまった通知だ。アラート疲れは、アラートなしよりも速く可観測性を殺す。

🛠 チートシート

自分でも動かすために必要なものすべて。

# 登録(初回、または plist を編集した後)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.myjob.plist
# 登録解除
launchctl bootout gui/$(id -u)/com.example.myjob
# ロードされているか?
launchctl list | grep myjob
# 何が起きた?
tail -f ~/Library/Logs/myjob.log

そして、あればよかったと思う判断表。

  • ジョブがファイルやディレクトリの変化に反応すべきなら → WatchPaths
  • ジョブがスケジュール通りに、ノートPC対応で動くべきなら → StartCalendarInterval
  • ジョブがバーストで発火し、まとめるべきなら → ThrottleInterval
  • ジョブが登録された瞬間に実行される必要があるなら → RunAtLoad
  • Mac 上で crontab -e を打とうとしている → やめて、plist を書こう

🎯 教訓の下にある教訓

自分が作った自動化は小さい。だが原則は小さくない。

cron に手を伸ばしそうになったのは、それが自分の知っているものだったからだ。そしてファイル監視用の npm パッケージに手を伸ばしそうになったのは、検索結果がそう勧めてくるからだ。どちらもうまく動いただろう、ただし出来は悪い。一方はタイマーでポーリングし、もう一方は OS がネイティブにやることのために依存関係を一つ増やす。

退屈で、組み込み済みで、20年前からあるツールの方が、その両方より優れていた。ポーリングではなくイベント駆動。サーバー前提ではなくノートPC対応。次のマシンにインストールするものが一つ増えるのではなく、依存関係ゼロ。

ツールを追加する前に、OS がすでに何を出荷しているか尋ねよう。

答えは、思っているより多くの場合こうだ。必要なものは全部、プロセス1の中に、2005年からずっと待っている。

あなたの LaunchAgents フォルダには何が動いていますか?

他の人が OS レベルで何を自動化しているのか、そしてそれをやる過程でどんな罠にはまったのか、興味がある。WatchPaths のフィードバックループが唯一の罠であるはずがない。