「エラーが出る障害」は見つけやすいものです。ログを見ればわかります。本当に怖いのは「エラーが出ないのに機能が動いていない」サイレント障害です。
本記事では、本番システムで発生した2つのサイレント障害——16日間のリソース監視無効化とplist 30ファイル破損——を分析し、AIが生成したコードで特に起きやすいサイレント障害のパターンと対策を記録します。
事例1:16日間のリソース監視無効化
何が起きたか
2026年3月8日にClaude Codeで実装した新機能(自動スケーリング変換)を本番にデプロイしました。テストは92件全PASS。CIも緑でした。
しかし3月24日に偶然ログを精査したとき、リソース監視プログラムが16日間にわたって全ての監視機能が無効化されていたことが発覚しました。
根本原因
1class ResourceMonitor:2 def __init__(self):3 api_key = os.getenv("API_KEY") # launchd環境では None4 self.client = APIClient(api_key) # api_key=None でも初期化成功5
6 def monitor(self):7 if self.client is None: # ← この条件分岐がなかった8 return # 静かにreturn9 # ... 監視ロジック問題は3層に分かれていました。
Layer 1: 環境変数の不在
launchd(macOSの定時実行デーモン)経由で起動されるプログラムは、シェルプロファイル(~/.zshrcなど)を読み込みません。os.getenv("API_KEY")は、plistファイルのEnvironmentVariablesに設定されているか、プログラム内でload_dotenv()を呼ばない限り、Noneを返します。
1<!-- plistファイル -->2<key>EnvironmentVariables</key>3<dict>4 <key>PROJECT_ROOT</key>5 <string>/Users/htada/myproject</string>6 <!-- API_KEY の設定が漏れていた -->7</dict>Layer 2: Noneで初期化が成功する
APIクライアントのコンストラクタがapi_key=Noneでもエラーを出さない設計でした。APIを呼ぶ時点で初めて認証エラーが発生します。しかし——
Layer 3: エラーハンドリングが障害を隠す
1def fetch_resources(self):2 try:3 return self.client.get_resources()4 except Exception:5 return [] # ← 空リストを返して「リソースなし」として扱う認証エラーがExceptionでキャッチされ、空リストが返されます。呼び出し元は「リソースがない」と判断して正常終了します。
なぜ気づけなかったか
| 検出手段 | 結果 |
|---|---|
| ユニットテスト | PASS(テスト時はAPI_KEYがモックされている) |
| CI/CD | PASS(GitHub Actionsにも環境変数が設定されている) |
| launchctl list | ジョブは表示される(実行自体はされている) |
| ログファイル | エラーなし(Exceptionがキャッチされている) |
| Slack通知 | なし(エラー時のみ通知する設計) |
全ての検出手段をすり抜けました。「正常終了しているが正しく動いていない」状態です。
修正
1# 修正1: load_dotenvを明示的に呼ぶ2from dotenv import load_dotenv3load_dotenv(Path(os.getenv("PROJECT_ROOT", ".")) / ".env")4
5# 修正2: クライアント初期化時にNoneチェック6api_key = os.getenv("API_KEY")7if api_key is None:8 logger.error("API_KEY is not set. Monitoring will be disabled.")9 raise EnvironmentError("API_KEY is required")CLAUDE.mdへの追加ルール
1#### launchd plist の環境変数と .env の不一致 ⚠️2launchd から起動されるスクリプトはシェルプロファイルを読まない。3os.getenv() で取得する環境変数は plist の EnvironmentVariables か、4スクリプト内の load_dotenv() でしか設定されない。事例2:plist 30ファイル破損
何が起きたか
Pre環境のセットアップ時に、30個のplistファイルを自動生成するスクリプトをClaude Codeで作成しました。しかし生成されたファイルはplist形式(XML)ではなくJSON形式でした。
1# AIが書いたコード2import json3
4def generate_plist(config: dict, output_path: str) -> None:5 with open(output_path, "w") as f:6 json.dump(config, f, indent=2) # ← JSON形式で出力1// 生成されたファイル(JSON形式 = 無効なplist)2[3 {4 "Hour": 8,5 "Minute": 5,6 "Label": "jp.myproject.pre.batch_sync_data"7 }8]なぜ気づけなかったか
launchdにはキャッシュ機構があります。一度launchctl loadで読み込んだジョブは、キャッシュから実行されます。plistファイルが壊れていても、キャッシュが残っている限りジョブは動き続けます。
1$ launchctl list | grep myproject2# → 30ジョブ全て表示される(キャッシュから実行中)3
4$ plutil -lint ~/Library/LaunchAgents/jp.myproject.pre.*.plist5# → 30ファイル全て Invalid property listlaunchctl listで「動いている」と確認しても、plistファイル自体は壊れています。macOSを再起動するとキャッシュがクリアされ、全30ジョブが消失するリスクがありました。
修正
1# 正しいplist生成2import plistlib3
4def generate_plist(config: dict, output_path: str) -> None:5 with open(output_path, "wb") as f: # バイナリモードで開く6 plistlib.dump(config, f)7
8 # 生成後に必ず構文検証9 import subprocess10 result = subprocess.run(11 ["plutil", "-lint", output_path],12 capture_output=True, text=True13 )14 if result.returncode != 0:15 raise ValueError(f"Invalid plist: {result.stderr}")なぜAIがjson.dump()を使ったか
AIにとって「設定ファイルを生成する」タスクでは、JSONが最も一般的な出力形式です。plistというmacOS固有のフォーマットの知識は、明示的に指示しないと適用されません。
CLAUDE.mdに以下を追加しました。
1#### plist 生成時の形式ルール ⚠️2plist ファイルの生成は plistlib.dump() のみ使用すること。3json.dump() や手動文字列生成は禁止。4
5> 教訓(2026-03-24 本番30ファイル破損事故): launchdはキャッシュから6> ジョブを実行するため、壊れたplistでも launchctl list にジョブが7> 表示され問題が発覚しなかった。サイレント障害のパターン
2つの事例から、AI生成コードにおけるサイレント障害の共通パターンを整理しました。
パターン1:寛容すぎるコンストラクタ
1# 危険:NoneやInvalid値でも初期化成功2client = APIClient(api_key=None) # エラーなし3
4# 安全:初期化時点で検証5client = APIClient(api_key=api_key) # api_key is NoneならValueErrorAIは「柔軟な設計」を好む傾向があり、Noneを許容するコンストラクタを書きがちです。
パターン2:広すぎるExceptionキャッチ
1# 危険:全てのエラーを飲み込む2try:3 result = api_call()4except Exception:5 result = default_value # 認証エラーもネットワークエラーも同じ扱い6
7# 安全:想定するエラーのみキャッチ8try:9 result = api_call()10except ConnectionError:11 result = default_value # ネットワークエラーのみリトライ12# AuthenticationError は呼び出し元に伝播させるパターン3:実行環境の差異を考慮しない
1# ターミナルでは動くがlaunchdでは動かない2api_key = os.getenv("API_KEY") # ターミナル: 値あり, launchd: NoneAIは「ターミナルから実行する」前提でコードを書きます。macOS launchd、Docker、systemd、cronなどの実行環境固有の制約は、CLAUDE.mdで明示する必要があります。
対策:検出の仕組み
1. 「正常終了」を疑うヘルスチェック
1# ログ鮮度チェック:最終更新が閾値を超えていないか2check_log_freshness "/tmp/resource_monitor/stdout.log" 3 # 3時間以内3
4# 出力量チェック:空ファイルや極端に少ない出力を検出5file_size=$(wc -c < "/tmp/resource_monitor/stdout.log")6if [ "$file_size" -lt 100 ]; then7 echo "⚠️ 出力量が異常に少ない"8fi2. plistの定期構文検証
1# 全plistの構文を検証(毎朝自動実行)2for plist in ~/Library/LaunchAgents/jp.myproject.*.plist; do3 if ! plutil -lint "$plist" > /dev/null 2>&1; then4 notify_slack "plist破損検出: $(basename $plist)"5 fi6done3. 「動いていること」の証拠を要求する
「エラーがない」ではなく「期待する出力がある」ことを検証します。
1# 悪い検証:エラーがないことの確認2assert not errors3
4# 良い検証:期待する結果があることの確認5assert len(monitored_resources) > 0, "リソース監視結果が空"6assert last_heartbeat > threshold, "ハートビートが古い"学んだこと
1. エラーが出ない障害が最も怖い
16日間気づけなかったのは、エラーが出なかったからです。「エラーがない = 正常」という前提は危険です。
2. AIはlaunchd/cron/Docker固有の制約を知らない
AIは「環境変数は設定されている」「ファイルシステムはアクセス可能」「ネットワークは接続されている」という暗黙の前提でコードを書きます。実行環境の制約はCLAUDE.mdで明示的に伝える必要があります。
3. launchctl listを信用しない
ジョブが「登録されている」ことと「正しく動いている」ことは別です。plistが壊れていてもキャッシュで動きます。ログが空でもジョブは正常終了します。「動いていること」を証明する仕組みが必要です。
まとめ
サイレント障害対策で重要なのは以下の3点です。
- 「エラーがない≠正常」: ログ鮮度・出力量・ハートビートで「動いていること」を積極的に証明します
- AI生成コードの環境依存バグ: 寛容すぎるコンストラクタ、広すぎるException、実行環境の差異——3つのパターンをCLAUDE.mdで予防します
- launchdキャッシュの罠: plistが壊れていてもジョブは動き続けます。
plutil -lintによる定期検証が必須です
サイレント障害は「見えない」のが本質です。見えない障害を見えるようにする仕組みこそが、安定運用の鍵です。