夜間の自動ループを作業マシンから常駐サーバへ引っ越した話
今回やったこと
記事の起稿・公開・公開後レビューを自動化するループが、当初は開発マシン上の launchd で動いていた。その設計では機械を閉じている夜間に全ジョブが止まる問題があり、常駐サーバへ主要なレッグを移設した。移設で踏んだ落とし穴と、無人ジョブに上限時間を付けるに至った経緯をまとめる。
開発マシン上で動かすことの限界
各種スクリプトを開発しながらテストするには開発マシンが便利だが、それを「毎晩動かすもの」として使い続けると問題が出る。
- 蓋を閉じると止まる: ノートを持ち出したり充電のために寝室に持ち込んだりすると、launchd のスケジュールジョブが起動しない。毎晩 21:30 の自動起稿がいつ飛んでいるか分からない状態になっていた
- 前回の実行と競合する: 前のジョブが何らかの原因で長時間残っていると、次回のジョブが起動しないか、2本が並走する
- 障害に気づかない: 開発中のマシンのメモリ・CPU 使用率は仕事中に変動するため、ジョブが遅延しても原因が特定しにくい
常駐サーバに移すと「機械が常に起きていること」が保証され、これらがまとめて解消する。
移設で踏んだ落とし穴
1. Python のパスがマシンごとに違う
開発マシンでは /usr/local/bin/python3 が実体だった箇所が、常駐サーバでは ~/.pyenv/shims/python3 に変わっていた。plist の ProgramArguments に絶対パスを書く launchd では、パスのハードコードが移設時に問題になる。
現在は PATH に ~/.pyenv/shims:/opt/homebrew/bin:/usr/local/bin を全部列挙して、どれかが通れば動く設計にしている。なお plist 内ではチルダは展開されないため、実際の記述はコード例のように絶対パスを使う。
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/.../.pyenv/shims:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
2. 認証トークンの置き場が違う
GitHub の Personal Access Token や各種 API キーを開発マシンの ~/.zshrc に書いていた場合、launchd の実行環境には shell の設定が読み込まれない。~/.zshrc に書いた環境変数は launchd から見えない。
対策として、トークン類は ~/code/<project>/.env に集約し、スクリプト側が dotenv で読み込む形に統一した。plist からは EnvironmentVariables でリポジトリのパスだけを渡し、実際の値はスクリプトが .env から引く。
3. ホームディレクトリ名が違うと plist のパスが全滅する
開発マシンと常駐サーバでユーザー名(ホームディレクトリ名)が違うと、plist に書かれた絶対パスが全部壊れる。/Users/username/code/nt_media/... という形のパスが 1 か所でも残っていると、そのジョブは起動に失敗する。
移設の際に plist を全文検索して /Users/ で始まる全パスを確認し、常駐サーバ上の正しいパスに置き換えた。launchctl list | grep <project> で登録済みジョブを確認し、launchctl load ~/Library/LaunchAgents/<plist> で再登録する手順を 1 本ずつ踏んだ。
4. パッケージのインストール済みライブラリが揃っていない
開発マシンで pip install した内容が常駐サーバには入っていない。特に requests、google-analytics-data、Pillow 等のライブラリを前提とするスクリプトが「モジュールが見つからない」で静かに落ちた。launchd のジョブはエラー時に出力がないため、ログファイルを確認するまで失敗に気づけなかった。
~/Library/Logs/<ジョブ名>.log にエラー出力を向けておくことを全 plist の標準にした。
93.5時間のハングと watchdog の追加
移設後しばらくして、自動起稿が 3 晩連続で飛んでいたことが判明した(2026-07-19〜23)。調べると、起稿 AI が許可されていないディレクトリ(ホームディレクトリ配下のドキュメントフォルダ)にアクセスしようとして承認プロンプトが出たまま止まっていた。無人実行では誰も承認できず、launchd は「前回の実行がまだ生きている」と判断して次回起動をスキップした。
エラーログは 0 バイト、Discord 通知もゼロで、外からは正常稼働に見えていた。結果として同じセッションが 93.5 時間生き続けた。
この経験から、各レッグに上限時間を設けることにした。現在の起稿レッグは 90 分(3本起稿 + 画像生成を含む長め設定)で、上限を超えると処理を強制終了して Discord に通知する。
# run-autodraft.sh の核心部分(概念)
TIMEOUT=5400 # 90分
run_with_watchdog "$TIMEOUT" "zash-autodraft" \
claude -p --permission-mode acceptEdits "$(cat "$PROMPT")"
agent_watchdog.sh は共通ライブラリとして切り出してあり、watchdog 機能が必要な他のレッグでも同じ仕組みを流用している。
現在の構成(常駐サーバ)
| 時刻 | 役割 | 上限時間 |
|---|---|---|
| 21:30 | 自動起稿(最大3本) | 90分 |
| 21:55 | 公開前審査 | 60分 |
| 22:30 | 公開(1日1本) | 60分 |
| 23:15 | 公開後レビュー | 60分 |
GPU が必要な工程だけは別機に残す
画像生成(ローカル LLM を使うもの)は GPU が必要なため、常駐サーバには移せなかった。現在は「GPU があるマシン」に画像生成の launchd ジョブだけを置き、常駐サーバと役割分担している。
常駐サーバ側は「今夜の公開候補に画像が必要かどうか」を判定して、必要なら GPU マシン側のキューにリクエストを積む設計になっている。
やってみてわかったこと
- 最初から「無人で何日でも動く」ことを想定して設計すべきだった。開発中は自分が隣にいてログを見られるので許容できた設計上の甘さが、常駐後に全部牙を剥く
- ログの出力先を全ジョブで統一しておくと、原因特定が速い。
~/Library/Logs/<ジョブ名>.logに stdout と stderr の両方を書き出す設定を plist の標準にした - 「ジョブが動いているかどうか」と「ジョブが期待通りに完了したかどうか」は別。前者は
launchctl listで確認できるが、後者は Discord 通知か完了フラグで確認するしかない
試した環境
- macOS + launchd(常駐マシン)
- macOS(GPU を持つ別マシン)
- Claude Code CLI(エージェント実行)
- Python 3.12 /
~/.pyenv/shims/python3
更新履歴
- 2026-07-27: 初稿(常駐サーバ移設完了・内部ドキュメントの整理後)