この記事は Zenn からの転載です。
launchd(macOSのcron相当)で自動化ジョブを常駐させているのに、ある日から静かに止まっていた。それなのにログにエラー行が1行も出ていない——この記事は、2026-07-14にその状態を実測ログで特定し、「エラーが出ていない」を二度と信用しない仕組みに変えた記録である。M1 Max 64GBのmacOS上でn8nをlaunchd常駐させて自動投稿パイプラインを回している、まさに同じ構成の人向け。
症状:エラーゼロのまま自動化が止まる
自動投稿ワークフロー(n8n)をlaunchdの KeepAlive で常駐させていたはずが、ある時点から一切動かなくなっていた。launchctl にエラーは出ない。標準エラー出力ログも空。「プロセスが落ちた形跡がない」状態が最も厄介で、監視対象が「起動していない」のか「起動しているが仕事をしていない」のか、ログだけでは区別がつかなかった。
真犯人①:plistがJSON配列のスタブだった
実際に壊れていたのは ~/Library/LaunchAgents/ 配下のplistファイルだった。中身を見ると、こうなっていた(再現用に一般化した例):
{"ProgramArguments": ["node", "n8n", "start"]}
これはlaunchdが要求する妥当なXML plistではなく、ProgramArguments の中身だけを切り出したJSON配列だった。launchdはこれを読み込もうとしても構文エラーとして報告せず、黙って何もしない。プロセスは起動すらしていないのに、起動エラーとしてどこにもログが残らない——これが「エラーゼロで自動化が止まる」の正体だった。
この種の壊れ方は、コマンド1つで機械的に検出できる。
$ plutil -lint ~/Library/LaunchAgents/com.example.myjob.plist
com.example.myjob.plist: OK
$ plutil -lint ~/Library/LaunchAgents/com.example.broken.plist
com.example.broken.plist: Unexpected character c (0x7b) at line 1
実機で全plistに対してこれを回すと、当時は15件が非妥当だった(後述のhealthcheckログの初回実行値、2026-07-14)。原因は同時期に行った全社的なサービス名のリネーム作業で、7本のplistは正規XML化されたが、n8n本体を含む残りが直し漏れになっていたこと。
真犯人②:監視自体が同じ理由で死んでいた
さらに厄介だったのが、「自動化が止まっていないか」を見張るはずの死活監視ジョブ自体のplistも、まったく同じJSON配列スタブ形式で壊れていたこと。つまり見張り役が最初から起動していなかった。誰も気づけないまま止まっていたのは偶然ではなく、障害の種類(非妥当plist=サイレント無視)が監視対象と監視役の両方を同時に殺せる種類の障害だったからだ。
もう一つの構造的な原因は場所にある。~/Library/LaunchAgents/ はOSレベルの設定置き場であり、日常的にドキュメントやコードを検索・棚卸しする対象フォルダの外にある。ナレッジ管理やコード検索の仕組みがどれだけ整っていても、この場所は最初から点検動線に乗らない。「動いているはずのものが実は動いていない」に気づく唯一の手段は、この場所自体を能動的に見に行くジョブを別に持つことだった。

healthcheck実行10回分の異常plist件数の推移。初回15件→7回横ばいで14件(対応保留ぶん)→直近の実行で0件。
恒久対策:「エラーが出ていない」を信用しない仕組みにする
直った2本(n8n本体と死活監視自体)を正規XML plistに書き直して復旧させたあと、同じ障害を二度と見逃さないために、次の3つだけを機械的にやるスクリプトを1本、毎朝定時にlaunchd常駐させた。
-
~/Library/LaunchAgents/*.plist全件にplutil -lintを通し、妥当性を判定する -
launchctl listの出力と突き合わせ、妥当だが未ロードのものも拾う - 結果を内部の台帳ファイルに毎回全書き換えで反映し、異常が1件でもあるときだけ通知を送る(異常なしの日は何も送らない=通知疲れを起こさない)
今この記事を書いている時点で実機に対して同じチェックを走らせると、結果はこうなる。
$ ls ~/Library/LaunchAgents/*.plist | wc -l
13
$ for f in ~/Library/LaunchAgents/*.plist; do plutil -lint "$f"; done | grep -c ": OK"
13
$ tail -1 ~/Library/Logs/.../healthcheck.log
healthcheck done: anomalies=0
現存する13本のplistは全件が妥当(plutil -lint が13/13でOK)。healthcheckログは10回分記録されており、異常件数は初回15件→7回連続で14件(対応保留分が残っていた期間)→直近の実行で0件、という推移をたどっている。平均ではなく両端(15と0)と、途中で7回止まっていた事実(14)の3点セットで見るのが実態に近い。「そのうち直る」ではなく、対応保留分の処理が終わった回で一気に0に落ちている。
隣接の罠:TCCとNode.jsのバージョン
この構成にはもう2つ、launchd経由の自動化特有の落とし穴があった。
1つ目はmacOSのTCC(プライバシー保護のアクセス制御)。launchdから起動した子プロセスが ~/Documents 配下の保護されたフォルダを読もうとすると、GUIから許可したはずのフルディスクアクセスが継承されず Operation not permitted で弾かれることがある。launchdが使う実行バイナリ(今回はnode本体)自体にフルディスクアクセスを付与する必要があり、シェルラッパを一枚噛ませても子プロセスには許可が継承されない。
2つ目はNode.jsのメジャーバージョン起因の罠。ネイティブアドオン(今回は isolated-vm)を含む依存関係は、Node.jsのバージョンによってビルドの可否が変わる。手元の環境ではNode 26系でこう失敗した。
serializer_nortti.o Error 1
Node 22系(keg-only、既定のNode 26には無干渉)に切り替えることでビルドが通った。「最新版に上げたら動かなくなった」ではなく「最新版だとそもそもビルドが通らない」という、エラーメッセージだけでは原因の層(TCC / launchd / Node.js)を切り分けにくい種類の不具合だった。
再現できる学び
同じ構成(launchd常駐+定期実行の自動化)を組んでいるなら、次の4つはそのまま流用できるチェックリストになる。
-
今すぐ手元の全plistに
plutil -lintを通す。1本でも非妥当なら、それはエラーを出さずに黙って死んでいる。 - 死活監視そのものの生死を、監視対象とは別の経路(別プロセス・別ログ)で確認できるようにしておく。監視が監視対象と同じ障害モードで一緒に死ぬ設計は、監視として機能しない。
- OSレベルの設定置き場(launchd plist、cron table、systemdのunitファイルなど)は、日常のコード・ドキュメント検索の対象フォルダの外にあることが多い。点検動線に乗らない場所ほど、能動的に見に行くジョブを別に持つ。
- 「動いている」の証拠を「エラーが出ていないこと」に置かない。「最後に成功した時刻が新しいこと」(ログの末尾やハートビートファイルのタイムスタンプ)で持つ。起動すらしていない場合と、正常に何もせず待機している場合は、エラーログだけでは区別できない。