20枠中12枠が不発火 launchd自動化の3つの罠
広告レポートの自動送信、日予算の自動計算、Slackへの異常通知。Pythonで書いたスクリプトをmacOSのlaunchdに登録し、そこで安心してしまう運用者は多い。だが登録した時刻に「静かに動かない」事故が起きる。
厄介なのはエラーも例外も出ないことだ。ログに何も残らず、翌朝そこに成果物だけが無い。実際に運用中のX投稿自動化では、2026年9月4日から8日の5日間で、1日4枠×5日=20枠のうち12枠が不発火した。原因はコードのバグではなく、launchdの仕様とプロセス設計の噛み合わせである。ここでは実測ログから特定した3つの罠と、取りこぼしを埋める追い実行ジョブの実装を示す。cronから移ってきた人ほど踏みやすい。
罠1: スリープ中の枠は発火せず、復帰後に1回へ丸められる
StartCalendarIntervalは指定時刻にMacが起きていなければ発火しない。スリープからの復帰時には、未実行のジョブを1回だけまとめて実行する。つまり3枠ぶん眠っていても、走るのは1回だ。シャットダウンしていた場合は次回起動時に回る。この挙動はAppleのScheduled Jobsのドキュメント(https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html)に明記されている。cronのように「その時刻に居なければ捨てる」でも、anacronのように「全部後で流す」でもない中間の挙動が誤解を生む。
実測はこうだった。9月4日から8日にかけて、X投稿の公開枠は20枠。実際に公開されたのは8本で、12枠が消えていた。消えた枠は夜間にMacをシャットダウンしていた日と一致した。スクリプト側のログには失敗の記録が1行も無い。呼ばれていないのだから当然である。
この時点で設計方針を変えた。「指定時刻の正確さ」を守るのをやめ、「1日にN回実行された事実」を担保する方向に切り替える。時刻厳守が要るのは配信停止やレポート提出の締切がある処理だけで、大半の運用自動化は回数さえ満たせば実害が無い。
罠2: 長寿命プロセスと多重起動ガードが「正常なゼロ実行」を作る
2つ目はlaunchdではなくスクリプト側の設計に起因する。X自動リプライの処理は、1プロセスが3時間おきにループしながら1日ぶんを回す作りだった。二重実行を防ぐため、ロック用ディレクトリを作る多重起動ガードも入れていた。
これが噛み合って事故になる。前日のプロセスが24時間を超えて生き残ると、翌日の起動はガードに弾かれてすべてSKIPになる。ログに残るのは「SKIP」の一語だけで、例外は出ない。監視から見れば正常終了である。結果として配布量が半分になっていた。
修正は短命プロセス化だ。1起動あたり最大3件だけ処理し、2件目以降の間隔を6分にして数分で終了させる。起動回数を1日5回に増やし、5起動×3件=15件で従来の日次上限と同じ本数を確保した。DRY_RUNでの実測は1起動111秒である。ロックは保険として残したが、プロセスが数分で死ぬためガードが翌日に干渉しない。
常駐に近いプロセスとガードの組み合わせは危険だ。「動いていないのに正常」という最悪の状態を作る。1起動=1仕事で終わる設計にすると、失敗が失敗として観測できる。複数媒体をまたぐバッチ処理でも同じで、広告3媒体レポート統合をPythonで実装した手順のような長い処理ほど、媒体単位に分割して起動する方が復旧が楽になる。
罠3: 環境変数の無い状態で起動し、認証だけが落ちる
3つ目は認証まわりだ。launchdから起動されたプロセスは、ログインシェルの初期化ファイルを読まない。.bash_profileにexportしたAPIトークンやPATHは存在しない状態で走る。ターミナルで手動実行すると通るのに、定時実行だけ失敗するパターンの大半がこれだ。
症状は分かりにくい。Meta広告APIならcode:190、Google広告APIならinvalid_grantが返る。トークンが期限切れに見えるため、認証の再取得を疑って時間を溶かす。実際には変数が空文字で渡っているだけだった。
対策は3点セットで固定する。(1)plistのEnvironmentVariablesに変数を明示する。またはスクリプト冒頭で設定ファイルを読み込む。(2)python3やgcloudなどの実行ファイルはすべて絶対パスで書く。(3)処理に入る前に必須変数の存在をチェックし、欠けていれば非ゼロで終了してSlackに通知する。3番目が最重要で、これが無いと「認証に失敗したまま0件で正常終了」する。異常を検知して飛ばす仕組みは広告の消化異常をSlackに自動通知する実装の型がそのまま使える。この手順の完全版はnoteの実践ガイドにまとめている。
追い実行ジョブで取りこぼしを埋める
3つの罠を潰したうえで、それでも残る不発火を吸収するのが追い実行(catchup)ジョブだ。定時ジョブとは別に、毎時起動するジョブを1本置く。「経過した枠数」と「本日実行された回数」の差を数える。不足していれば1回につき1本だけ実行する。
実装のポイントは冪等性である。実行回数は当日ログの該当行を数えて求める。1回の起動で埋めるのは必ず1本までにして、上限を超えないようにする。差がゼロなら何もせず終了する。RunAtLoadを付けておくと、Macを起動した直後にも1回判定が走り、朝いちばんの取りこぼしを拾える。
週次レポートのように1週間に1回しか動かない処理は、失敗すると1週間丸ごと消えるため優先度が高い。実際に日曜20時のKPIレポートが不発火した件では、22時と23時30分に追い実行を追加した。当日ログに開始行が無いときだけ本体を1回走らせ、日曜以外は何もしない。これで週次の欠落はゼロになった。
コピペして使える点検チェックリスト
ブックマークして、自分の環境に合わせて書き換えて使ってほしい。定時実行が疑わしいときはこの順で潰す。
- (1)launchctl list で対象ジョブが登録され、直近の終了コードが0かを見る
- (2)ジョブのログに「起動した記録」があるか確認する。無ければ呼ばれていない=launchd側の問題
- (3)StandardOutPathとStandardErrorPathを別ファイルで指定する(未指定だと原因が消える)
- (4)スクリプト冒頭に「必須変数が無ければ非ゼロ終了」を入れる
- (5)1起動=1仕事で終わるか確認する。ループで居座る設計なら分割する
- (6)本数や件数の上限を、起動側ではなく実行履歴ファイル側で判定する
- (7)毎時起動の追い実行ジョブを1本置き、不足時に1回だけ埋める
判定式は「実行すべき回数 − 当日の実行済み回数 > 0 なら1回実行」だけでよい。時刻ではなく回数で管理するのが要点で、これならスリープでもシャットダウンでも自動的に帳尻が合う。
[PR] 検証結果を社内やクライアントに共有する資料まで作るなら、AIスライド作成ツール「イルシル」を試してみるのも手だ。
まとめ
定時実行の自動化が壊れるとき、壊れているのは大抵コードではない。スリープ中の枠は復帰時に1回へ丸められ、長寿命プロセスは翌日の起動をガードで殺し、環境変数の欠落は認証だけを静かに落とす。いずれもエラーを出さないのが共通点だ。まず自分のジョブに「起動した記録」が残っているかを確認する。次に時刻管理から回数管理へ切り替え、毎時の追い実行ジョブを1本足す。
実務でそのまま使いたい人へ
本記事の手法の完全版(実際のコード・テンプレート・つまずき対処つき)は、運営者のnote(note.com/ryo_ai_hack)で公開している。実データに基づく実践ガイドをまとめて読める。

コメント
コメントを投稿