最終更新: 2026-04-28
導入
cron を使っていた。macOS に移行するまでは。
移行してすぐ気づいたのが「MacBook がスリープ中に実行時間を過ぎると、起動後もスルーされる」という動作だった。毎朝 07:00 に SNS 投稿スクリプトを走らせたいのに、充電しながら蓋を閉めて寝ると朝に投稿ゼロ——という日が2週間続いた。cron の仕様というより macOS のスリープ管理との相性の問題で、調べるのに2日かかった。
launchd に乗り換えたのは去年の秋ごろ。最初の .plist ファイルを書いて実際に動かすまでに丸1日溶かした。今は 30 本以上のスクリプトを launchd で管理していて、SNS 投稿・KDP 原稿生成・ブログ記事生成まで全部自動で回っている。その経験をそのまま書く。
launchd と cron の違い、実際のところ
cron に慣れていると、launchd は最初に面食らう。* * * * * で書けたものを XML で書かなければならない。設定ファイルが冗長で、初見のとき「なんでこんな複雑にした」と思った。
ただ慣れると launchd の方がはるかに信頼できた。スリープ復帰後に「missed」した実行を拾ってくれるし、stdout / stderr を別ファイルに吐けるし、プロセスが落ちたら自動再起動もできる。
私のパイプラインでいうと、毎日 07:00 / 12:30 / 20:30 の3回、BSky と X と note への投稿スクリプトを走らせている。cron 時代は深夜に MacBook を充電しながら放置すると7時の投稿が飛んでいた。launchd に変えてから4ヶ月、一度もそういう取りこぼしは起きていない。
.plist ファイルの基本構造
~/Library/LaunchAgents/ に .plist を置くのが基本。ここに置いたものはログインユーザー権限で動く。サーバーデーモンとして全ユーザー向けに動かしたい場合は /Library/LaunchDaemons/ だが、個人の Python スクリプトなら LaunchAgents で十分だし、ユーザーの環境変数やホームディレクトリに素直にアクセスできる。
<?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>/opt/homebrew/bin/python3</string>
<string>/Users/ichinosetaito/Documents/AI_Automation_Base/01_Scripts/sns/sns_post_launcher.py</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>7</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>WorkingDirectory</key>
<string>/Users/ichinosetaito/Documents/AI_Automation_Base</string>
<key>StandardOutPath</key>
<string>/Users/ichinosetaito/Documents/AI_Automation_Base/04_Config/logs/sns-post.log</string>
<key>StandardErrorPath</key>
<string>/Users/ichinosetaito/Documents/AI_Automation_Base/04_Config/logs/sns-post_err.log</string>
</dict>
</plist>
Label はユニークな逆ドメイン形式で書くのが慣例。ファイル名もこれに合わせる(com.taito.sns-post.plist)。ラベルとファイル名がズレていると launchctl で管理するときに混乱する——一度やった。
詰まったポイント3つ
python3 のパスが通らない
一番最初に踏んだ。ターミナルで which python3 すると /opt/homebrew/bin/python3 が返ってくるのに、launchd から実行すると err.log に -bash: python3: command not found が出た。スクリプト自体は全く問題ない——のに動かない。30分溶かした。
原因は launchd の環境変数 PATH が、ターミナルの PATH と別物だから。launchd が起動するプロセスは .zshrc を読まない。Homebrew も pyenv も最初から存在しない扱いになる。
EnvironmentVariables セクションに明示的に書く必要がある。
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
加えて ProgramArguments の Python 指定も絶対パスにする。/usr/bin/python3 はシステム付属の古い Python なので、Homebrew 経由のものを使っているなら /opt/homebrew/bin/python3 を直に書く。
仮想環境の pip モジュールが読み込まれない
私はスクリプトごとに .venv を切っている。launchd はその仮想環境の存在を知らないので、import anthropic の行で ModuleNotFoundError: No module named 'anthropic' が出た。ターミナルから手動実行すると動く——launchd からだけ落ちる——という状況で、最初は原因がわからなかった。
解決策は2つある。shell スクリプトをラッパーにして source .venv/bin/activate してから呼ぶ方法と、仮想環境の Python インタープリターを ProgramArguments に直接指定する方法。私は後者にしている。ログが分散しないし、plist の見通しもいい。
<key>ProgramArguments</key>
<array>
<string>/Users/ichinosetaito/Documents/AI_Automation_Base/.venv/bin/python3</string>
<string>/Users/ichinosetaito/Documents/AI_Automation_Base/01_Scripts/sns/sns_post_launcher.py</string>
</array>
作業ディレクトリが違う
スクリプト内で相対パスを使っていると、launchd から実行したときに FileNotFoundError: [Errno 2] No such file or directory: 'output/result.json' が出る。launchd のデフォルト作業ディレクトリは / だから。
WorkingDirectory キーを plist に追加するか、スクリプト側で Path(__file__).parent を基準にした絶対パスに統一するか——私は両方やっている。plist 側に書いておくと、スクリプトの書き方に依存しなくていい。
<key>WorkingDirectory</key>
<string>/Users/ichinosetaito/Documents/AI_Automation_Base</string>
読み込みと確認コマンド
.plist を書いたら launchctl で登録する。macOS Monterey 以降は load より bootstrap が推奨されている。
# 登録
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.taito.sns-post.plist
# 確認
launchctl list | grep com.taito
# 手動で今すぐ実行(テスト用)
launchctl kickstart -k gui/$(id -u)/com.taito.sns-post
# アンロード
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.taito.sns-post.plist
launchctl list | grep com.taito の出力の見方。
# PID Status Label
- 0 com.taito.sns-post # 正常終了(PID なし = 実行完了済み)
1842 - com.taito.sns-post # 実行中(PID あり)
- 2 com.taito.sns-post # エラー終了(Status が非ゼロ)
PID が - で終了コードが 0 なら正常完了。PID に数値が出ていれば今実行中。終了コードが -1 や 2 になっていたらエラー——err.log を見に行く。127 は「コマンドが見つからない」なので、だいたいパス問題。
複数スクリプトを管理するときの整理
今は 30 本以上あるので、命名規則を決めている。Label とファイル名を一致させること、スコープ(sns / kdp / blog / system)を2番目のセグメントに入れること——この2点だけ守れば管理はだいぶ楽になる。
com.taito.sns-post.plist # SNS 定時投稿(07:00 / 12:30 / 20:30)
com.taito.kdp-generate.plist # KDP 原稿生成(毎日 02:00)
com.taito.blog-generate.plist # ブログ記事生成(毎日 03:30)
com.taito.discord-bot.plist # Discord Bot(常駐)
com.taito.system-cleanup.plist # ログローテーション(毎週日曜 04:00)
常駐型(KeepAlive)と定時型(StartCalendarInterval)を混在させても問題ない。Discord Bot は KeepAlive true で動かしていて、クラッシュしても数秒以内に launchd が再起動してくれる。✨
<key>KeepAlive</key>
<true/>
これだけ追加すれば常駐型になる。プロセス監視ツールを別途立てる必要がなくて、構成がシンプルに保てる。
ログ設計の話
ログを雑に設計すると、何が起きているか追えなくなる。私の設計は stdout を 04_Config/logs/{name}.log(正常出力)、stderr を 04_Config/logs/{name}_err.log(エラー)に分けている。
スクリプト側でも Python の logging モジュールを使い、実行日時・処理件数・エラー内容を必ず出力している。launchd は実行のたびにログを追記してくれるが、スクリプト側で ---- のセパレーターを入れて区切りを明示している——これをやらないと複数回の実行が混ざって、どの実行でエラーが出たか追えなくなる。3回それで時間を溶かしてから徹底した。
import logging
import sys
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger(__name__)
print("----", flush=True) # 実行ごとのセパレーター
logger.info("開始: sns_post_launcher.py")
# 処理...
logger.info("完了: 投稿 3 件 / スキップ 0 件")
tail -f で眺めると、どのスクリプトがいつ動いてどれだけ処理したか一目でわかる。障害対応の速度が全然違う。
まとめ
cron より設定が面倒なのは本当で、最初の1本を動かすまでが一番しんどい。でも動き出したら安定していて、スリープ復帰後の取りこぼしもなく、ログも綺麗に管理できる。
「手で実行したら動くのに launchd から動かない」という問題のほぼ全部は、環境変数かパスかディレクトリの問題。この3点を最初に確認する癖をつけると解決が早い。err.log に 127 が出ていたらパス、ModuleNotFoundError が出ていたら venv、FileNotFoundError が出ていたら WorkingDirectory——この3択で大体片がつく。
今は朝7時に起きると BSky・X・note への投稿が全部済んでいて、KDP の原稿も生成されている。ここまで来るのに半年かかったけど、launchd の設定で詰まっていた時間が一番長かった気がする。誰かの1時間節約になれば。