macOSのlaunchdでローカル開発ツールを常駐化する(load/unloadは非推奨、bootstrap/bootoutを使う)

前回のふりかえり

以前「PlaywrightとPixelmatchで差分チェックを自動化する」という記事の最後で、作った確認ツールをmacOSのLaunchAgentでログイン時に自動起動させている、という話をサラッと書きました。今回はそのLaunchAgentをちゃんと掘ってみます。

LaunchAgentの位置づけ

macOSには、システム起動時の処理からアプリの自動起動、cronのような定期実行まで、いわゆる「起動系のしくみ」をまとめて管理しているlaunchdという仕組みがあります。昔からある起動スクリプトやcronは、今はこのlaunchd用の設定ファイル(plistというXMLファイル)に置き換わっているイメージです。

このlaunchdが管理するジョブは、大きく2種類に分かれます。

  • LaunchAgent(~/Library/LaunchAgents/に置く):自分がMacにログインしている間だけ動く。ログアウトすると一緒に終了する
  • LaunchDaemon(/Library/LaunchDaemons/に置く):ログインしているかどうかに関係なく、システムが起動している間はずっと動く。root権限が必要な処理向け

「自分がログインしているときだけ、ローカルサーバーを立てておきたい」という今回のようなケースは、まさにLaunchAgentの出番です。

plistを書く

実際にdiff-tools-ui(複数URLの差分チェックをまとめて実行できるローカルのWeb UI)を常駐化させるために書いた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>local.dev.diff-tools-ui</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/xxx/.volta/bin/node</string>
        <string>/Users/xxx/dev/diff-tools-ui/server.mjs</string>
    </array>
    <key>WorkingDirectory</key>
    <string>/Users/xxx/dev/diff-tools-ui</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>DIFF_TOOLS_UI_NO_OPEN</key>
        <string>1</string>
    </dict>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/xxx/dev/diff-tools-ui/runs/server.out.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/xxx/dev/diff-tools-ui/runs/server.err.log</string>
</dict>
</plist>

細かい書き方はさておき、押さえておきたいポイントは3つです。

  • ProgramArgumentsに書くコマンドは、ふだん使っているシェルのPATHを一切引き継いでくれません。なのでnodeも、which nodeで調べた絶対パスをそのまま書く必要があります
  • EnvironmentVariablesもシェルの環境変数とは無関係です。.zshrcで設定している内容は、ここには一切反映されません
  • KeepAliveは今回trueにして「落ちたら常に再起動する」という設定にしていますが、実は辞書形式で細かく条件を指定することもできます。たとえば{"SuccessfulExit": false}と書くと「正常終了(exit 0)した時は再起動しない、異常終了した時だけ再起動する」という指定になります

このXMLをコピーして、ProgramArguments・WorkingDirectory・ログの出力先パスを自分の環境に書き換えたら、~/Library/LaunchAgents/にファイルとして保存します(ファイル名はlocal.xxx.yyy.plistのようにLabelと揃えておくのが慣例です)。

登録・管理する(load/unloadは非推奨)

LaunchAgentの使い方をネットで調べると、launchctl load / launchctl unloadというコマンドを使っている記事がたくさん出てきます。でもこれ、実はmacOS El Capitan(10.11)以降ではすでに非推奨になっているコマンドなんです。今の書き方は、対象がどのドメイン(どのユーザーのセッションか)まで指定するbootstrap / bootoutです。

# 登録(読み込み+即時起動)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.dev.diff-tools-ui.plist

# 登録解除
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/local.dev.diff-tools-ui.plist

# 状態確認(launchctl listの後継。PID・最終終了コードまで見える)
launchctl print gui/$(id -u)/local.dev.diff-tools-ui

登録できていれば、launchctl print gui/$(id -u)/local.dev.diff-tools-uiを実行するとPIDや状態が表示されます。何も表示されない、またはエラーになる場合は次の「デバッグする」を参照してください。

今回のdiff-tools-uiのようにWebサーバーを常駐化する場合は、実際にブラウザで開いてみるのが一番わかりやすい確認方法です。diff-tools-uiのポートはserver.mjs内で固定されているので、http://localhost:4787を開けば起動しているかどうかが一目で分かります(ポート番号はツールによって異なるので、自分が常駐化したいツールの実際のポートを確認してください)。

plistの内容を変えた時は、ファイルを保存するだけでは反映されない点にも注意してください。一度bootoutで外して、bootstrapで読み込み直す必要があります。

cron代替として使う場合

今回のように「ずっと立ち上げておきたい」という使い方ならRunAtLoadとKeepAliveだけで十分ですが、LaunchAgentにはもう一つの顔があります。cronの完全な代わりとして「定期的に実行する」という使い方もできるんです。

  • StartInterval:〇秒おきに繰り返し実行する。厳密な周期ではなく、あくまで目安の間隔
  • StartCalendarInterval:Minute/Hour/Day/Weekday/Monthを指定する、cronのcrontabに近い書き方。配列にすれば複数の時刻を指定することもできます

(以下は実際に運用しているわけではなく、書き方のイメージを伝えるための一例です)

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

cronと大きく違うのは、Macがスリープしていて実行タイミングを過ぎてしまった場合、スリープから復帰した後にちゃんと実行してくれるという点です。昔からのcronだと、anacronのような仕組みを別に用意しないと、寝ている間に取り逃した実行はそのまま消えてしまいます。ノートPCで動かすツールだと、この違いは地味に助かります。

デバッグする

うまく起動してくれない時は、まずlaunchctl printで状態を見てみます。最後にどんな終了コードで終わったか、プロセスがちゃんと動いているかが分かります。

launchctl print gui/$(id -u)/local.dev.diff-tools-ui | grep -E "state|last exit"

ただ、StandardOutPath / StandardErrorPathに指定したログファイルは、プロセス自体が立ち上がった後の出力しか記録してくれません。plistの書き方が間違っていて、そもそも起動できていないような場合は、macOSの統合ログ(unified logging)まで見に行く必要があります。

log show --predicate 'eventMessage contains "local.dev.diff-tools-ui"' --last 1h

自分がハマったのは、ProgramArgumentsに書いたnodeのパスが、うっかり絶対パスになっていなかったケースでした。launchctl printを見てもプロセスがすぐ終了扱いになるだけで理由が分からず、統合ログまで確認してようやく「No such file or directory」というエラーに気づけました。

まとめ

  • ログイン時にローカルサーバーを自動起動したいなら、cronやシェルスクリプトよりLaunchAgentの方が筋が良い
  • load/unloadは非推奨。今はbootstrap / bootout / printを、ドメイン指定(gui/$(id -u))付きで使う
  • 常駐だけでなく、StartInterval / StartCalendarIntervalでcron代替の定期実行としても使える。スリープ復帰後に取り逃した実行を動かしてくれる点がcronと違う
  • ハマった時はStandardOutPathのログだけでなく、統合ログ(log show)まで見てみる

それでは、また次の記事で。

この記事を気に入ったら

この記事を書いた人

こーでィ

こーでィ

2021年入社です。

この人が書いた記事を見る >>