launchd

macOSのlaunchdで自動化パイプラインを組んで詰まりまくった話——plistの書き方から実務の罠まで全部書く

Ichinose Taito — MBTI × 心理学 × AI

心理学で、
人間関係を
ちょっとラクにする。

MBTIとアドラー心理学を軸に、
自分と他者を理解するヒントを発信しています。

記事を読む ›
ちのくん
ちのくん
— こんな活動をしています —
macOSのlaunchdで自動化パイプラインを組んで詰まりまくった話——plistの書き方から実務の罠まで全部書く

導入

launchd を使い始めたのは、Pythonスクリプトを「毎日7時に自動で動かしたい」というだけの動機だった。

cron でいいじゃないかという話もある。実際、最初はそう思っていた。でも macOS は cron より launchd の方が親和性が高くて、スリープ復帰後のジョブ補完とか、標準出力のログ管理とか、細かいところで差が出てくる。一度慣れると cron には戻れない。

ただ、慣れるまでが地獄だった。plist の書き方を間違えて exit=19968 が出続けたり、ログが全然見えなくて原因がわからなかったり。今はSNS投稿・noteコメント返信・KDP記事生成・BlueSky投稿を全部 launchd で回しているけど、ここまで来るのに相当な試行錯誤があった。

その全部を書く。


launchd の基本構造——どこに何を置くか

launchd のジョブ定義は plist ファイルで書く。macOS には3つのディレクトリがある。

[コード略 — 詳細は元記事参照]

個人の自動化であれば ~/Library/LaunchAgents/ 一択。ここに plist を置いて launchctl load するだけで動く。

ファイル名の命名規則は com.{名前}.{ジョブ名}.plist が慣例。僕は com.taito.sns-post.plist みたいな形でつけている。


plist の最小構成

最初に動かした plist はこんな感じだった。

[コード略 — 詳細は元記事参照]

これで「毎日7時に実行」が実現できる。シンプル。

でも、これだけだとログが全然見えなくて、失敗しているのか成功しているのかすらわからない。


絶対に入れておくべきログ設定

最初にログ設定を入れていなくて、かなり後悔した。

[コード略 — 詳細は元記事参照]

この2行を dict 内に追加するだけで、スクリプトの標準出力・エラー出力がファイルに書き出される。デバッグが格段に楽になる。

ログディレクトリは事前に作っておく必要がある。launchd 自体はディレクトリを作ってくれない。

[コード略 — 詳細は元記事参照]

複数時刻に実行したいときの書き方

SNS投稿は7:00・12:30・20:30の3回動かしている。複数の StartCalendarInterval を使うには <array> で囲む。

[コード略 — 詳細は元記事参照]

この書き方を知るまで、スクリプト側で時刻チェックしていた。明らかに冗長だった。


詰まりポイント1——exit=19968 の正体

一番ハマった。スクリプトが起動直後に落ちて、ログには何も出ない。launchctl list | grep taito で確認すると exit コードが 19968。

[コード略 — 詳細は元記事参照]

19968 という数字は 0x4E00。これはプロセスが起動できなかったことを示すエラーコードで、シェルレベルで落ちている。

原因を切り分けると、大抵どれかに該当する。

  • ProgramArguments に書いたパスが存在しない
  • 実行権限がない(chmod +x を忘れている)
  • シェルスクリプトの1行目(shebang)が間違っている
  • 絶対パスではなく相対パスを使っている

shebang の問題が一番多かった。#!/bin/bash と書くべきところを #!/usr/bin/env bash にしていたり、逆だったり。launchd は環境変数 PATH が通常のシェルセッションと違うので、env 経由で解決できないケースがある。

解決策は単純で、「全部絶対パスで書く」。

[コード略 — 詳細は元記事参照]

これだけで直った。


詰まりポイント2——環境変数が引き継がれない

launchd のジョブは、ターミナルで手動実行するときと環境変数が異なる。.zshrc.bashrc で設定した変数は引き継がれない。

APIキーを .env ファイルで管理していたのに、launchd から起動すると読み込めていなかった。スクリプト内で dotenv を使っていたのだが、パスの指定が相対パスになっていたのが原因だった。

[コード略 — 詳細は元記事参照]

plist 側で環境変数を直書きする方法もある。

[コード略 — 詳細は元記事参照]

ただ、APIキーを plist に書くのはセキュリティ的にやりたくない。スクリプト側で絶対パス読み込みにする方が管理しやすい。


詰まりポイント3——ロードとアンロードのタイミング

plist を修正したのに変更が反映されない、というのも初期によくやった。

launchd は一度ロードしたジョブをキャッシュする。修正後は必ず unload → load の順でやり直す必要がある。

[コード略 — 詳細は元記事参照]

launchctl bootoutlaunchctl bootstrap という新しい構文もあるが、ユーザーランドの LaunchAgents であれば上記の unload/load で問題ない。

修正のたびにこれをやるのが面倒になって、シェル関数にまとめた。

[コード略 — 詳細は元記事参照]
[コード略 — 詳細は元記事参照]

これで一発。


実際に動かしているジョブ一覧

現時点で launchd に登録しているジョブはこれだけある。

[コード略 — 詳細は元記事参照]

全部合わせると1日に20回以上のジョブが動いている。これが全部自動で回っていると思うと、ちゃんと組んだ甲斐があったなと感じる。


デバッグのフロー

何かおかしいと感じたら、この順番で確認している。

[コード略 — 詳細は元記事参照]

step 3 が地味に便利で、ターミナルから直接スクリプトを叩くとパスが通っているように見えてしまう問題を発見できる。launchd は全部絶対パスで動く前提なので、手動実行でも絶対パスを使ってテストする習慣をつけた。


まとめ

launchd は最初の30分で挫折しそうになるが、一度動かせる状態になると本当に便利。スリープからの復帰後に実行してくれるし、ログも残るし、cron より macOS との相性が良い。

詰まりポイントをまとめると、「絶対パス」「ログ設定」「unload/load の順番」この3つさえ押さえれば大体の問題は解決できる。

exit=19968 が出たら「パスが悪い」と思って確認するのが一番の近道。


[コード略 — 詳細は元記事参照]