最終更新: 2026-04-28
導入
動機は単純だった。Pythonスクリプトを毎日7時に自動で動かしたかっただけ。
cron でいいじゃないかというのはわかる。私も最初そう思って cron で書いた。でも MacBook Pro がスリープから復帰したあとにジョブが抜けること、標準エラーのキャプチャが面倒なこと、この2点で3日で限界が来た。launchd に切り替えたら両方解決した——が、切り替え当日に exit=19968 という見慣れないエラーコードに2時間潰された。
今は SNS投稿(7:00・12:30・20:30)・note コメント返信・KDP記事生成・BlueSky投稿・コンテンツスコアリング、合計7本のジョブを launchd で回している。1日に20回以上のジョブが無人で動く状態になるまで、詰まったポイントが山ほどあった。全部書く。
launchd の基本構造——どこに何を置くか
launchd のジョブ定義は plist ファイルで書く。macOS にはジョブを置くディレクトリが3つある。
~/Library/LaunchAgents/ # ログイン中のユーザーとして動く(← 個人利用はここ一択)
/Library/LaunchAgents/ # 全ユーザー対象・root不要
/Library/LaunchDaemons/ # root権限で動くシステムデーモン
個人の自動化であれば ~/Library/LaunchAgents/ 以外を使う理由はない。ここに plist を置いて launchctl load するだけ。root も sudo も要らない。
ファイル名は com.{名前}.{ジョブ名}.plist が慣例で、私は com.taito.sns-post.plist のように命名している。ドット区切りで階層感が出るので、 launchctl list | grep taito したときに自分のジョブがすぐ絞り込める。
plist の最小構成
最初に動かした 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.taito.sns-post</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/ichinosetaito/Documents/AI_Automation_Base/01_Scripts/sns/sns_post_launcher.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>7</integer>
<key>Minute</key><integer>0</integer>
</dict>
</dict>
</plist>
毎日7時に実行、それだけ。構造はシンプルで、Label(ジョブの識別子)・ProgramArguments(何を実行するか)・StartCalendarInterval(いつ実行するか)の3点セット。
ただこれだけだと、動いているのか落ちているのかが全然わからない。ログが出ない。失敗しても沈黙したまま。この状態で1週間運用して、ジョブが全部止まっていたのに気づかなかった、という痛い経験をした。
絶対に入れておくべきログ設定
最初からログ設定を入れておけばよかった、と今でも思っている。
<key>StandardOutPath</key>
<string>/Users/ichinosetaito/logs/sns-post.log</string>
<key>StandardErrorPath</key>
<string>/Users/ichinosetaito/logs/sns-post-error.log</string>
この2行を <dict> 内に追加するだけで、スクリプトの標準出力とエラー出力がファイルに書き出される。 tail -f ~/logs/sns-post-error.log を流しながら動作確認できるようになって、デバッグ時間が体感で半分以下になった。
注意点が一つ。launchd はログディレクトリを自動で作ってくれない。plist をロードする前にディレクトリを手動で作っておく必要がある。
mkdir -p ~/logs
これを忘れると、ログパスが存在しないせいでジョブ自体が起動しないことがある。実際にやらかした。
複数時刻に実行したいときの書き方
SNS投稿は7:00・12:30・20:30の3回動かしている。最初はスクリプト側に「今が何時か判定する」ロジックを書いていた。1つの plist で1回起動させて、スクリプト内で時刻チェックして分岐する——という構成。後から考えると完全に無駄だった。
StartCalendarInterval を <array> で囲めば、複数の時刻を1つの plist に書ける。
<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>
この書き方を知ってから、スクリプト側の時刻チェックコードを全部削除した。100行近くあった分岐ロジックが消えた。
詰まりポイント1——exit=19968 の正体
一番ハマった。plist を書いてロードしたのに、スクリプトが起動した形跡がない。ログファイルも空のまま。
launchctl list | grep taito で確認すると、こう出る。
- 19968 com.taito.sns-post
19968 は 16進数で 0x4E00。これはプロセスがシェルレベルで起動できなかったことを示すコードで、スクリプトの中身の問題ではなく、そもそも実行ファイルにたどり着けていない状態。
原因の候補はいくつかある——パスが存在しない、実行権限がない、shebang が間違っている、相対パスを使っている——私の場合は shebang だった。
具体的には #!/usr/bin/env bash と書いていた。ターミナルから手動で叩けば動く。でも launchd のジョブは通常のシェルセッションと PATH が違うので、env bash の解決に失敗してプロセスごと落ちる。
直し方は単純。shebang も含めて全部絶対パスで書く。
#!/bin/bash
# ↑ /usr/bin/env bash にしない
/usr/bin/python3 /Users/ichinosetaito/Documents/AI_Automation_Base/01_Scripts/sns/generate_realtime_bsky_post.py
これで exit=19968 が消えた。それだけだった。2時間返してほしい。
詰まりポイント2——環境変数が引き継がれない
launchd のジョブは .zshrc や .bashrc で設定した環境変数を引き継がない。これを知らずに1週間くらい苦しんだ。
スクリプト内で python-dotenv を使って .env ファイルから API キーを読んでいた。ターミナルから手動実行すると動く。launchd から起動すると KeyError: 'ANTHROPIC_API_KEY' で落ちる——という状態が続いた。
原因は load_dotenv() に渡すパスが相対パスになっていたこと。launchd のジョブはワーキングディレクトリが / になるため、相対パスで .env を探しに行っても見つからない。
# ダメなパターン
load_dotenv() # カレントディレクトリから探す → / 以下を探して見つからない
load_dotenv(".env") # 同上
# 動くパターン
load_dotenv("/Users/ichinosetaito/Documents/AI_Automation_Base/.env")
plist 側で環境変数を直書きする方法もある。
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
</dict>
ただ API キーを plist に書くのは管理上やりたくない。plist は ~/Library/LaunchAgents/ に平文で置くファイルなので。スクリプト側で絶対パス読み込みにしておく方が、セキュリティと可搬性の両方で楽だった。
詰まりポイント3——ロードとアンロードのタイミング
plist を修正したのに動きが変わらない、というのも初期に何度かやった。
launchd は一度ロードしたジョブの定義をメモリにキャッシュする。ファイルを書き換えても、ロードされているジョブは古い定義のまま動き続ける。修正を反映させるには unload してから load し直す必要がある。
launchctl unload ~/Library/LaunchAgents/com.taito.sns-post.plist
launchctl load ~/Library/LaunchAgents/com.taito.sns-post.plist
macOS Ventura 以降は launchctl bootout / launchctl bootstrap という新しい構文も使える。でもユーザーランドの LaunchAgents であれば unload/load で問題なく動いている。
毎回これを手打ちするのが面倒になって、 .zshrc にシェル関数を書いた。
# ~/.zshrc に追記
relaunch() {
local plist="$HOME/Library/LaunchAgents/${1}.plist"
launchctl unload "$plist" 2>/dev/null
launchctl load "$plist"
echo "reloaded: ${1}"
}
# 使い方
relaunch com.taito.sns-post
引数にラベル名を渡すだけで unload → load が走る。これにしてからストレスが消えた。
実際に動かしているジョブ一覧
現時点で ~/Library/LaunchAgents/ に登録しているジョブはこれだけある。
com.taito.sns-post 7:00 / 12:30 / 20:30 SNS一括投稿(BSky・X・note)
com.taito.note-reply 9:00 / 21:00 noteコメント自動返信
com.taito.kdp-generate 3:00 KDP記事生成(Ollama qwen2.5:3b)
com.taito.bsky-engage 8:00 / 19:00 BSkyエンゲージメント処理
com.taito.content-scorer 2:00 生成コンテンツ品質スコアリング
com.taito.ollama-health */30min(RunAtLoad有) Ollama死活監視・自動再起動
com.taito.blog-thumb 4:00 ブログサムネイル生成キュー処理
1日に20回超のジョブが無人で動く。これを全部手動でやっていたことを考えると、正直ぞっとする。
デバッグのフロー
何かおかしいと感じたとき、私が必ずやる順番がある。
# ステップ1: ジョブの現在状態とexitコードを確認
launchctl list | grep taito
# ステップ2: エラーログの末尾を流す
tail -50 ~/logs/sns-post-error.log
# ステップ3: plist の構文チェック(書き間違いはここで出る)
plutil -lint ~/Library/LaunchAgents/com.taito.sns-post.plist
# ステップ4: launchd と同じ条件で手動実行——絶対パスで叩く
/bin/bash /Users/ichinosetaito/Documents/AI_Automation_Base/01_Scripts/sns/sns_post_launcher.sh
ステップ4が地味に大事で、ターミナルから bash sns_post_launcher.sh と相対パスで叩くと「動いた」と錯覚しやすい。launchd は全部絶対パスで動くので、デバッグ時も絶対パスで手動実行する癖をつけてから、「手動ではOKなのにlaunchdで落ちる」という謎の挙動がほぼなくなった。
まとめ
launchd は最初の30分でやめたくなる。plist の XML 構文、exit コードの難読さ、環境変数の罠——どれも cron にはない学習コストだった。
ただ一度ちゃんと動かせると、スリープ復帰後のジョブ補完・ログの自動記録・細かいスケジューリングと、cron では面倒だったところが全部解決する。私のように「毎日3回SNSに投稿したい」「夜中にKDP記事を自動生成したい」という用途には過不足がない。
詰まりポイントを振り返ると、「絶対パスを徹底する」「ログ設定を最初から入れる」「修正後は必ず unload → load する」——この3点に尽きる。exit=19968 が出たらまずパスを疑う。それだけで大体解決する。