45395 - シコウサクゴ -

サイレント障害との戦い:16日間気づけなかった機能無効化とplist30ファイル破損の教訓

2026-04-03
AI駆動開発
AI駆動開発
Claude Code
サイレント障害
インシデント分析
launchd
Last updated:2026-04-05
11 Minutes
2138 Words

「エラーが出る障害」は見つけやすいものです。ログを見ればわかります。本当に怖いのは「エラーが出ないのに機能が動いていない」サイレント障害です。

本記事では、本番システムで発生した2つのサイレント障害——16日間のリソース監視無効化plist 30ファイル破損——を分析し、AIが生成したコードで特に起きやすいサイレント障害のパターンと対策を記録します。


事例1:16日間のリソース監視無効化

何が起きたか

2026年3月8日にClaude Codeで実装した新機能(自動スケーリング変換)を本番にデプロイしました。テストは92件全PASS。CIも緑でした。

しかし3月24日に偶然ログを精査したとき、リソース監視プログラムが16日間にわたって全ての監視機能が無効化されていたことが発覚しました。

根本原因

monitor_resources.py
1
class ResourceMonitor:
2
def __init__(self):
3
api_key = os.getenv("API_KEY") # launchd環境では None
4
self.client = APIClient(api_key) # api_key=None でも初期化成功
5
6
def monitor(self):
7
if self.client is None: # ← この条件分岐がなかった
8
return # 静かにreturn
9
# ... 監視ロジック

問題は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: エラーハンドリングが障害を隠す

1
def fetch_resources(self):
2
try:
3
return self.client.get_resources()
4
except Exception:
5
return [] # ← 空リストを返して「リソースなし」として扱う

認証エラーがExceptionでキャッチされ、空リストが返されます。呼び出し元は「リソースがない」と判断して正常終了します。

なぜ気づけなかったか

検出手段結果
ユニットテストPASS(テスト時はAPI_KEYがモックされている)
CI/CDPASS(GitHub Actionsにも環境変数が設定されている)
launchctl listジョブは表示される(実行自体はされている)
ログファイルエラーなし(Exceptionがキャッチされている)
Slack通知なし(エラー時のみ通知する設計)

全ての検出手段をすり抜けました。「正常終了しているが正しく動いていない」状態です。

修正

1
# 修正1: load_dotenvを明示的に呼ぶ
2
from dotenv import load_dotenv
3
load_dotenv(Path(os.getenv("PROJECT_ROOT", ".")) / ".env")
4
5
# 修正2: クライアント初期化時にNoneチェック
6
api_key = os.getenv("API_KEY")
7
if 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 の不一致 ⚠️
2
launchd から起動されるスクリプトはシェルプロファイルを読まない。
3
os.getenv() で取得する環境変数は plist の EnvironmentVariables か、
4
スクリプト内の load_dotenv() でしか設定されない。

事例2:plist 30ファイル破損

何が起きたか

Pre環境のセットアップ時に、30個のplistファイルを自動生成するスクリプトをClaude Codeで作成しました。しかし生成されたファイルはplist形式(XML)ではなくJSON形式でした。

1
# AIが書いたコード
2
import json
3
4
def 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ファイルが壊れていても、キャッシュが残っている限りジョブは動き続けます。

Terminal window
1
$ launchctl list | grep myproject
2
# → 30ジョブ全て表示される(キャッシュから実行中)
3
4
$ plutil -lint ~/Library/LaunchAgents/jp.myproject.pre.*.plist
5
# → 30ファイル全て Invalid property list

launchctl listで「動いている」と確認しても、plistファイル自体は壊れています。macOSを再起動するとキャッシュがクリアされ、全30ジョブが消失するリスクがありました。

修正

1
# 正しいplist生成
2
import plistlib
3
4
def 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 subprocess
10
result = subprocess.run(
11
["plutil", "-lint", output_path],
12
capture_output=True, text=True
13
)
14
if result.returncode != 0:
15
raise ValueError(f"Invalid plist: {result.stderr}")

なぜAIがjson.dump()を使ったか

AIにとって「設定ファイルを生成する」タスクでは、JSONが最も一般的な出力形式です。plistというmacOS固有のフォーマットの知識は、明示的に指示しないと適用されません。

CLAUDE.mdに以下を追加しました。

1
#### plist 生成時の形式ルール ⚠️
2
plist ファイルの生成は plistlib.dump() のみ使用すること。
3
json.dump() や手動文字列生成は禁止。
4
5
> 教訓(2026-03-24 本番30ファイル破損事故): launchdはキャッシュから
6
> ジョブを実行するため、壊れたplistでも launchctl list にジョブが
7
> 表示され問題が発覚しなかった。

サイレント障害のパターン

2つの事例から、AI生成コードにおけるサイレント障害の共通パターンを整理しました。

パターン1:寛容すぎるコンストラクタ

1
# 危険:NoneやInvalid値でも初期化成功
2
client = APIClient(api_key=None) # エラーなし
3
4
# 安全:初期化時点で検証
5
client = APIClient(api_key=api_key) # api_key is NoneならValueError

AIは「柔軟な設計」を好む傾向があり、Noneを許容するコンストラクタを書きがちです。

パターン2:広すぎるExceptionキャッチ

1
# 危険:全てのエラーを飲み込む
2
try:
3
result = api_call()
4
except Exception:
5
result = default_value # 認証エラーもネットワークエラーも同じ扱い
6
7
# 安全:想定するエラーのみキャッチ
8
try:
9
result = api_call()
10
except ConnectionError:
11
result = default_value # ネットワークエラーのみリトライ
12
# AuthenticationError は呼び出し元に伝播させる

パターン3:実行環境の差異を考慮しない

1
# ターミナルでは動くがlaunchdでは動かない
2
api_key = os.getenv("API_KEY") # ターミナル: 値あり, launchd: None

AIは「ターミナルから実行する」前提でコードを書きます。macOS launchd、Docker、systemd、cronなどの実行環境固有の制約は、CLAUDE.mdで明示する必要があります。


対策:検出の仕組み

1. 「正常終了」を疑うヘルスチェック

Terminal window
1
# ログ鮮度チェック:最終更新が閾値を超えていないか
2
check_log_freshness "/tmp/resource_monitor/stdout.log" 3 # 3時間以内
3
4
# 出力量チェック:空ファイルや極端に少ない出力を検出
5
file_size=$(wc -c < "/tmp/resource_monitor/stdout.log")
6
if [ "$file_size" -lt 100 ]; then
7
echo "⚠️ 出力量が異常に少ない"
8
fi

2. plistの定期構文検証

Terminal window
1
# 全plistの構文を検証(毎朝自動実行)
2
for plist in ~/Library/LaunchAgents/jp.myproject.*.plist; do
3
if ! plutil -lint "$plist" > /dev/null 2>&1; then
4
notify_slack "plist破損検出: $(basename $plist)"
5
fi
6
done

3. 「動いていること」の証拠を要求する

「エラーがない」ではなく「期待する出力がある」ことを検証します。

1
# 悪い検証:エラーがないことの確認
2
assert not errors
3
4
# 良い検証:期待する結果があることの確認
5
assert len(monitored_resources) > 0, "リソース監視結果が空"
6
assert last_heartbeat > threshold, "ハートビートが古い"

学んだこと

1. エラーが出ない障害が最も怖い

16日間気づけなかったのは、エラーが出なかったからです。「エラーがない = 正常」という前提は危険です。

2. AIはlaunchd/cron/Docker固有の制約を知らない

AIは「環境変数は設定されている」「ファイルシステムはアクセス可能」「ネットワークは接続されている」という暗黙の前提でコードを書きます。実行環境の制約はCLAUDE.mdで明示的に伝える必要があります。

3. launchctl listを信用しない

ジョブが「登録されている」ことと「正しく動いている」ことは別です。plistが壊れていてもキャッシュで動きます。ログが空でもジョブは正常終了します。「動いていること」を証明する仕組みが必要です。


まとめ

サイレント障害対策で重要なのは以下の3点です。

  1. 「エラーがない≠正常」: ログ鮮度・出力量・ハートビートで「動いていること」を積極的に証明します
  2. AI生成コードの環境依存バグ: 寛容すぎるコンストラクタ、広すぎるException、実行環境の差異——3つのパターンをCLAUDE.mdで予防します
  3. launchdキャッシュの罠: plistが壊れていてもジョブは動き続けます。plutil -lintによる定期検証が必須です

サイレント障害は「見えない」のが本質です。見えない障害を見えるようにする仕組みこそが、安定運用の鍵です。

Article title:サイレント障害との戦い:16日間気づけなかった機能無効化とplist30ファイル破損の教訓
Article author:45395
Release time:2026-04-03

記事へのご質問・ご感想をお聞かせください

フィードバックを送る