作業環境・自動化

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

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

この記事は約 13 分で読めます

📖 目次
  1. 📌 導入
  2. 📌 launchd の基本構造——どこに何を置くか
  3. 📌 plist の最小構成
  4. 📌 絶対に入れておくべきログ設定
  5. 📌 複数時刻に実行したいときの書き方
  6. 📌 詰まりポイント1——exit=19968 の正体
  7. 📌 詰まりポイント2——環境変数が引き継がれない
  8. 📌 詰まりポイント3——ロードとアンロードのタイミング
  9. 📌 実際に動かしているジョブ一覧
  10. 📌 デバッグのフロー
  11. 📌 まとめ
  12. 📌 関連記事

最終更新: 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 が出たらまずパスを疑う。それだけで大体解決する。


📘 この記事のテーマをさらに深掘りした本

Claude Codeで副業自動化——月5万円を目指した全記録

非エンジニアが1年で月10万円に到達した全パイプラインと失敗談

Kindleで読む →


一ノ瀬泰斗のアバター
一ノ瀬泰斗
AI自動化エンジニア / Python個人開発者

Claude Code × Ollama × launchd で SNS・ブログ・KDPを全自動化。実測データと失敗談を軸に、月5万円収益化のリアルな記録を発信中。

💬 自動化の相談・小規模受託も受付中:「launchd で毎朝 AI が動く仕組みを作りたい」「KDP の自動出版を組みたい」など、X (@taito_automate) の DM からお気軽にどうぞ。


関連記事

✨ AUTHOR'S KDP BOOKS

かかる人向ケ、10分でわかるAI自動化入門

Claude Code / Ollama / launchd の実践テクニックをコンパクトにまとめたシリーズ。非エンジニアの会社員向けに書いてます。

Amazonで見る ›

✨ FOLLOW ME

AI自動化の実験・失敗・実測データを毎日発信中

𝕏 フォローする