サイトアイコンmaita tomoya dev io

#89 macOS launchd 入門 — 定期タスクを自動実行する仕組み

はじめに

macOS で「1時間ごとにスクリプトを実行したい」「ファイルが変更されたら自動でビルドしたい」と思ったとき、Linux の cronsystemd timer に相当するのが launchd である。

本記事では、launchd の基本から実践的な使い方、よくあるハマりポイントまでを整理する。


launchd とは

macOS の PID 1 プロセス。システム起動と同時に立ち上がり、すべてのプロセスの親になる。 Linux での systemd + cron を1つにまとめたような存在。

他 OS との対応表

macOSLinuxWindows
launchdsystemd / cronタスクスケジューラ
plist (XML).service / .timer / crontabXML
launchctlsystemctl / crontab -eschtasks

plist の配置場所

パス実行タイミング権限用途
~/Library/LaunchAgents/ユーザーログイン時一般ユーザー個人の定期タスク
/Library/LaunchAgents/任意ユーザーログイン時管理者(sudo)全ユーザー共通タスク
/Library/LaunchDaemons/システム起動時管理者(sudo)バックグラウンドサービス

開発者が使うのはほぼ ~/Library/LaunchAgents/ だけ。


plist ファイルの書き方

最小構成

<?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.example.my-task</string>

    <key>ProgramArguments</key>
    <array>
        <string>/path/to/script.sh</string>
    </array>

    <key>StartInterval</key>
    <integer>3600</integer>
</dict>
</plist>

これだけで「1時間ごとにスクリプトを実行する」設定が完成する。

主要キーの説明

キー説明
Labelstring一意な識別子(逆ドメイン形式推奨)
ProgramArgumentsarray実行するコマンド(第1要素が実行ファイル)
StartIntervalintegerN秒ごとに実行
StartCalendarIntervaldictcron的な時刻指定
WatchPathsarray指定パスの変更を検知して実行
RunAtLoadbooleanload直後に1回実行するか
StandardOutPathstring標準出力のログファイルパス
StandardErrorPathstring標準エラーのログファイルパス
WorkingDirectorystring実行時のカレントディレクトリ
EnvironmentVariablesdict環境変数の指定

トリガー方式の比較

1. StartInterval(インターバル実行)

<key>StartInterval</key>
<integer>1800</integer>  <!-- 30分ごと -->

シンプルで最もよく使う。前回実行からN秒後に次を実行。

2. StartCalendarInterval(カレンダー指定)

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

毎日9:00に実行。crontab の 0 9 * * * に相当。

使えるキー: Month, Day, Weekday(0=日曜), Hour, Minute

3. WatchPaths(ファイル監視)

<key>WatchPaths</key>
<array>
    <string>/path/to/watched/directory</string>
</array>

指定パスに変更があったら実行。ファイル同期やビルドトリガーに便利。


launchctl コマンド

基本操作

# 登録(自動実行を開始)
launchctl load ~/Library/LaunchAgents/com.example.my-task.plist

# 解除(自動実行を停止)
launchctl unload ~/Library/LaunchAgents/com.example.my-task.plist

# 一覧確認
launchctl list | grep example

# 即座に1回実行(テスト用)
launchctl start com.example.my-task

# 詳細状態確認
launchctl print gui/$(id -u)/com.example.my-task

デバッグ

# ログを確認
tail -f ~/Library/Logs/my-task.log

# launchdのシステムログ
log show --predicate 'subsystem == "com.apple.launchd"' --last 1h

# 終了ステータス確認(0=成功)
launchctl list | grep my-task
# PID  Status  Label
# -    0       com.example.my-task  ← Status=0 は正常終了

実践例

例1: 1時間ごとに Git リポジトリを自動 pull

<?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>dev.auto-git-pull</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/git</string>
        <string>pull</string>
        <string>--ff-only</string>
    </array>
    <key>WorkingDirectory</key>
    <string>/Users/me/projects/my-repo</string>
    <key>StartInterval</key>
    <integer>3600</integer>
    <key>StandardOutPath</key>
    <string>/tmp/auto-pull.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/auto-pull.log</string>
</dict>
</plist>

例2: ファイル変更を検知してビルド

<?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>dev.auto-build</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/npm</string>
        <string>run</string>
        <string>build</string>
    </array>
    <key>WorkingDirectory</key>
    <string>/Users/me/projects/app</string>
    <key>WatchPaths</key>
    <array>
        <string>/Users/me/projects/app/src</string>
    </array>
</dict>
</plist>

例3: 毎朝9時にデスクトップ通知

<?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>dev.morning-reminder</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/osascript</string>
        <string>-e</string>
        <string>display notification "タスクを確認しよう" with title "Morning"</string>
    </array>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>9</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>
</dict>
</plist>

よくあるハマりポイント

症状原因対策
実行されないplist が load されていないlaunchctl load を実行
実行されないスクリプトに実行権限がないchmod +x script.sh
実行されないPATH が通っていないProgramArguments にフルパスを使う
環境変数が効かないlaunchd は shell を経由しないEnvironmentVariables で明示指定
ログに何も出ないStandardOutPath が未設定ログパスを明示指定する

PATH 問題の解決

launchd はログインシェルを経由しないため .zshrc の PATH が使えない。

<!-- 方法1: フルパスを使う(推奨) -->
<key>ProgramArguments</key>
<array>
    <string>/Users/me/.nvm/versions/node/v20.20.1/bin/node</string>
    <string>/path/to/script.js</string>
</array>

<!-- 方法2: 環境変数で渡す -->
<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>/usr/local/bin:/usr/bin:/bin:/Users/me/.local/bin</string>
</dict>

cron との比較

launchdcron
macOS での推奨度○(公式推奨)△(非推奨だが動く)
スリープ復帰後の実行○(見逃し分を実行)×(スリープ中はスキップ)
ファイル監視トリガー×
依存関係の記述○(KeepAlive 条件)×

macOS ではノートPCのスリープが多いため、launchd の「見逃し実行」機能が特に重要。cron だとスリープ中のジョブが飛ぶ。


まとめ

  1. ~/Library/LaunchAgents/ に plist を置く
  2. launchctl load で登録
  3. あとは自動で動く

困ったら launchctl list | grep ラベル名 で状態確認、ログファイルを tail で見る。シンプルだが強力な仕組みなので、開発の自動化に積極的に活用しよう。