300本のPrometheusアラートをLLMで棚卸し、ノイズ候補を自動検出した
300本のアラートルールをLLMに食わせてノイズを炙り出した話
ある時期、自分が管理しているPrometheusのアラートルールが気づけば300本近くになっていた。サービスが増えるたびに追加して、減らすタイミングは誰も決めない。典型的な中小企業あるあるだ。オンコール担当が「またこのアラート鳴った」と言いながらSnoozeするルーティンが常態化していたら、それはもうアラートシステムが機能していない。
問題は分かっている。でも300本を人手でレビューする時間は誰にもない。そこでLLMを使ったルールの棚卸しを試みた。
なぜLLMレビューが刺さるのか
アラートルールの棚卸しが面倒な理由は、判断の粒度が均一ではないからだ。「このルールは閾値が厳しすぎる」「このルールはすでに別のルールと重複している」「このルールが対象にしているメトリクスはもう誰も見ていない」といった問題は、YAMLをgrepしても出てこない。文脈の読み取りが必要で、それが人間の作業コストを押し上げる。
LLMはその文脈読み取りを得意とする。完璧ではないが、「とりあえず怪しい候補を出す」という用途なら十分に機能する。全部を自動で判断させるのではなく、人間のレビューを最小化するための絞り込みとして使うのがポイントだ。
データの準備:ルール定義 + 発火頻度を組み合わせる
LLMにルール定義だけを渡しても「閾値が適切かどうか」は判断しにくい。発火実績データと組み合わせると判断精度が上がる。
まずPrometheusのQuery APIで過去30日分の発火頻度を取得する。
import requests
import json
from datetime import datetime, timedelta
PROMETHEUS_URL = "http://localhost:9090"
def get_alert_fire_count(alert_name: str, days: int = 30) -> int:
end = datetime.utcnow()
start = end - timedelta(days=days)
query = f'count_over_time(ALERTS{{alertname="{alert_name}",alertstate="firing"}}[{days}d])'
resp = requests.get(f"{PROMETHEUS_URL}/api/v1/query", params={
"query": query,
"time": end.timestamp()
})
result = resp.json().get("data", {}).get("result", [])
if not result:
return 0
return int(float(result[0]["value"][1]))
次にアラートルールのYAMLを読み込んで、各ルールに発火回数を付与したJSONを作る。
import yaml
def load_rules_with_stats(rule_file: str) -> list[dict]:
with open(rule_file) as f:
data = yaml.safe_load(f)
enriched = []
for group in data.get("groups", []):
for rule in group.get("rules", []):
if "alert" not in rule:
continue
name = rule["alert"]
fire_count = get_alert_fire_count(name)
enriched.append({
"alert": name,
"expr": rule.get("expr", ""),
"for": rule.get("for", ""),
"labels": rule.get("labels", {}),
"annotations": rule.get("annotations", {}),
"fire_count_30d": fire_count
})
return enriched
300本あると全部を1回のLLMコールに詰め込むのはトークン的に厳しい。自分は30〜50本ずつバッチに分けて処理した。
プロンプト設計:「怪しさの理由」を書かせる
出力フォーマットをJSONで固定するのが重要だ。自由記述で返させると後処理が辛くなる。
SYSTEM_PROMPT = """
あなたはPrometheusアラートルールの品質レビューを行うSREです。
与えられたアラートルールのリストを分析し、以下の観点で問題を持つルールを特定してください。
判定基準:
- noise_candidate: 発火頻度が異常に高い(過去30日で100回超)、または閾値が明らかに緩すぎる
- dead_candidate: 発火頻度がゼロで、かつルール定義から見て発火する状況が想定しにくい
- duplicate_candidate: 他のルールと表現・意図が重複している
- unclear: アノテーションが空またはアラート名から意図が読み取れない
出力はJSON配列で返してください。問題がないルールは含めないこと。
[
{
"alert": "アラート名",
"issue_type": "noise_candidate|dead_candidate|duplicate_candidate|unclear",
"reason": "判断した理由を1〜2文で",
"confidence": "high|medium|low"
}
]
"""
def review_batch(rules: list[dict], client) -> list[dict]:
user_content = json.dumps(rules, ensure_ascii=False, indent=2)
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_content}
],
temperature=0.2
)
raw = response.choices[0].message.content
# JSONブロックだけ抽出する処理を入れる
return json.loads(raw)
temperature=0.2に下げているのは、創造性より一貫性を優先するため。ルールレビューは「同じインプットに同じ判断を返してほしい」場面なので低くする。
実際に出てきた候補の傾向
300本を処理した結果、LLMが候補として上げたのは約80本。大まかな内訳はこうだった。
- noise_candidate: 約35本。CPUやメモリの閾値が80%で設定されているが、常時85〜90%で動いているサービスがあり、実質的に毎時間鳴っていたケース。
- dead_candidate: 約25本。廃止されたサービスのメトリクスに対するルール、または
forが30mに設定されていて現実的にその状態が30分続くことはないルール。 - duplicate_candidate: 約10本。
DiskUsageHighとDiskSpaceLowが別グループで定義されていてほぼ同義だったケース。 - unclear: 約10本。
alert: CustomAlert001みたいな名前でannotationsが空のやつ。誰が何のために作ったか不明。
人間が最終判断したのはここからで、confidence: highに絞るとさらに半分程度に落とせた。結果的に40本強を削除または修正し、オンコール件数は翌月比で約4割減った。
非エンジニアの意思決定者に向けて一言添えると、夜間や休日に「また誤報かよ」と叩き起こされるコストは馬鹿にならない。アラートの品質を上げることは、オンコール担当のモチベーション維持と直結する経営課題でもある。
運用に組み込む際の注意点
この仕組みを定期実行(月次など)にする場合、LLMの判断を直接削除処理に繋げるのは危険だ。必ずPull RequestやSpreadsheet出力など「人間が確認する工程」を挟む。LLMはconfidence: highでもたまに的外れな判断をする。特にビジネスロジックと密結合したルールは、コードを読まないと意図が分からないので誤判定が増える傾向があった。
あと、発火頻度がゼロだからといって即座に「不要」と断言できないケースもある。年に一度しか発生しない障害に備えたルールは意図的にゼロであるべきだ。その区別をLLMにさせるのは難しく、ここは人間が判断するしかない。ルール定義にコメントで意図を書いておく運用を徹底すると、LLMの判断精度も上がるし、そもそも人間のレビューも楽になる。