「また同じ理由で鳴ってる」——半年前にある一時点のスナップショットを「基準値」として固定し、以降ずっとそれと比較し続ける異常検知を組んだことはないだろうか。運用初期はうまく回る。ところが対象システムの構造がどんどん変わっていく環境だと、数ヶ月後には「基準値との差分アラート」がほぼ毎回鳴るようになる(律儀に鳴るだけ鳴って、原因は毎回同じ)。しかもその差分の大半は、実際の劣化ではなく「基準値を取った時点の姿」と「今の姿」が構造的に別物になっただけ、というケースが積み重なっていく。
これは私たちが社内で運用している、自動開発パイプラインの品質を継続監視する診断ツールで実際に踏んだ罠だ。パイプライン改修のたびに、ある特定コミット前後で取得した固定baselineとの比較が引っかかる。原因を1件ずつ追うと、その多くは「本当の回帰」ではなく「baseline取得時から改修が進みすぎて、比較対象がもう別物」というノイズだった(犯人はいつも「基準値の方が年を取っていた」。正直、しばらくは検出ロジックのバグを疑って時間を溶かした)。この記事では、固定baseline比較がなぜ時間とともに陳腐化するのか、そしてrolling window比較でそれをどう解消したかを、実際の閾値・数式・ガード条件付きで紹介する。
この記事で学べること
- 固定baseline比較が「一度きりの検証」には向くが「継続監視」には向かない理由
- 直近ウィンドウどうしを比較するrolling window比較の設計(
[now-2N, now-N)と[now-N, now)) - ratio計算に add-one 平滑化を使う理由(0除算とノイズ alertを同時に防ぐ)
- サンプル数が少ないときに判定を出さない insufficient data ガードの作り方
- 固定baseline比較を「撤去」せず両モードを併存させた設計判断の理由
前提条件
- 何らかの継続的な指標(エラー率、失敗件数、レイテンシなど)を時系列で記録している
- 比較対象のシステムが高頻度で構造変更される(=基準値が長期間有効という前提が成立しにくい)
- 判定結果をCIやアラートの発火条件として使いたい(false positiveのコストが高い)
固定baseline比較の限界
最初に作った比較の仕組みはシンプルだった。ある時点(大きめの構造移行の前後)でスナップショットを1つ取り、それを「基準値」としてファイルに固定する。以降の比較は、常にこの固定ファイルと現在の値を比べる。
# 固定baseline比較(最初の実装)
baseline-snapshot.sh --window 30d --out baseline.json
# ... 数ヶ月運用 ...
compare-baseline.sh --baseline baseline.json --current <(baseline-snapshot.sh --window 30d)
この仕組みは、狙った目的(「ある特定の改修が回帰を起こしていないか」の単発検証)には正しく機能した。だが継続監視のトリガーとして使い始めると、雲行きが変わる。対象システムの構造改修が続くほど、比較先の「基準値」そのものが古くなっていく。基準値を取った時点の構造と、今の構造が違いすぎて、差分の大半が「本当の劣化」ではなく「基準値が古いことによるノイズ」になってしまう。
これは検出ロジックの精度の問題ではない。比較対象の選び方の問題だ。固定baselineは「常時参照し続ける基準」として設計されたものではなく、特定の変更前後を比較する一度きりのhistorical snapshotだった。それを継続監視の基準に流用したのが、そもそもの無理筋だった。
rolling window比較の設計
解決策は、比較対象を固定ファイルではなく「直近の履歴から動的に生成した2つのウィンドウ」に変えることだった。
compare-baseline.sh --rolling --window 7d
内部の動きはこうだ。
- previous window:
[now-2N, now-N) - recent window:
[now-N, now)
--window 7d を指定すると、直近7日間(recent)と、その前の7日間(previous)を比較する。実装は baseline-snapshot.sh --window <N>d --until <iso> を2回呼び出すだけで済む。recentは --until <now>、previousは --until <now-N> を渡し、--until が半開区間 [until-N, until) としてウィンドウを切る仕組みを利用している(ちなみに、新しい計算ロジックは1行も書いていない。既存の--until引数を2回呼ぶだけだ)。
固定baselineと違って、この2つのウィンドウは常に「今」を基準にスライドする。structural改修が入っても、previousとrecentは同じ改修後の構造で取得される。基準値が古くなるという問題がそもそも発生しない。
判定までの流れを図にするとこうなる。
サンプル数のガードがratio計算より先に効くのがポイントだ。以下、この図の各分岐を順に見ていく。
ratio計算とadd-one平滑化
比較対象は2つの数値指標(エラー件数と、特定パターンに一致した件数)に絞った。判定は単純な比率だ。
ratio = recent / max(previous, 1)
わざわざ max(previous, 1) を挟んでいるのには理由がある。式そのものは1行で済むほど単純なのに、素直に recent / previous と書くと2つの問題が起きる。
- previousが0件のとき、0除算になる
- previousが0件からrecentが1件に増えただけで、ratioが無限大(もしくは特別扱いの分岐)になり、たった1件の増加が最大重度のアラートとして扱われてしまう(1件のノイズに緊急招集をかけるようなものだ)
分母に1を足す(正確には「1未満なら1に下駄を履かせる」add-one平滑化)ことで、この両方を同時に防げる。0件→1件のような小さな変化は ratio = 1/1 = 1.0 に収まり、閾値超えの判定にならない。逆に previousが10件でrecentが20件に増えたようなケースは ratio = 20/10 = 2.0 としてきちんと拾える。
判定閾値は ratio_threshold(既定 1.5)だ。ratioがこれを超えたら severity: "critical" のfindingとして扱い、exit codeを1にする。閾値は設定ファイルの rolling.ratio_threshold でオーバーライドできるようにしてあるので、指標の性質やチームのアラート疲れ耐性に応じて調整できる。
insufficient dataガード
rolling比較を組んで最初にぶつかったのは、閾値判定そのものではなく「サンプルが少なすぎるときにどう振る舞うか」だった。previousもrecentも件数が少ないウィンドウ(例: 1件→2件)だと、ratioは 2.0 になり閾値 1.5 を超えてしまう。だが1件から2件への変化を「回帰」と呼ぶのは、統計的にはほぼ意味がない。
これに対処するため、判定の前段にガードを1つ挟んだ。
previous.total_entries < min_entries_per_window
OR recent.total_entries < min_entries_per_window
→ insufficient_data: true, exit 0(advisory)
min_entries_per_window の既定値は 5。previousかrecentどちらかのウィンドウが5件未満なら、回帰findingを一切出さずに insufficient_data: true を立ててexit 0で終える。「判定しない」ことを明示的な状態として返す設計にしたわけです。サイレントにfindingを0件にするのとは違い、呼び出し側は「回帰なし」と「判定不能」を区別できる。
このガードがないと、小さいNのノイズで頻繁にcriticalが飛び、結局アラートを無視する運用になっていく(毎回鳴る警報は、そのうち誰も見なくなる)。閾値だけ厳しくしても解決しない問題で、サンプルサイズそのものを判定条件に含める必要があった。
両モードの併存という設計判断
ここまで読むと「固定baseline比較はもう不要では」と思うかもしれない。実際には撤去せず、両モードを併存させた。
固定baseline比較(--baseline/--current)が向いているのは、「ある特定の改修(大きめの移行やリファクタ)の前後で、意図通りに劣化していないか」を単発で確認する場面だ。この用途では「今と半年前を比べたい」わけではなく「この1つの変更の前後だけを正確に比べたい」のが目的なので、固定スナップショットの方が素直で分かりやすい。
一方rolling window比較が向いているのは、「継続的に監視し続けて、傾向の変化を検出したい」場面だ。目的が違う2つの比較様式を、無理に1つに統合しなかった。
| 固定baseline比較 | rolling window比較 | |
|---|---|---|
| 比較対象 | 固定した1点のスナップショット | [now-2N, now-N) と [now-N, now) の2ウィンドウ |
| 向いている場面 | 特定の改修前後を単発で検証したいとき | 継続的に監視し、傾向の変化を検出したいとき |
| 構造改修への耐性 | 改修が進むほど基準が古くなり陳腐化する | 常に「今」基準でスライドするため陳腐化しない |
| 出力への影響 | 既存ロジックは無変更(modeフィールドを追加しただけ) | windows.previous / windows.recent / insufficient_data / metrics[].ratio を追加 |
出力スキーマはadditiveな拡張にとどめ、既存の固定モードの利用側に破壊的変更が及ばないようにした。これも「片方を消して置き換える」のではなく「別ツールとして併存させる」ことを優先した結果だ。
動作確認
閾値とガードを並べただけでは、結局ちゃんと機能するのか実感が湧かない。理屈は筋が通っていそうに見えるが、実際どうなの? 具体的な数値で追ってみる。
ケース1: 本当の回帰
previous windowのエラー件数が10件、recent windowが25件だったとする。
ratio = 25 / max(10, 1) = 2.5
2.5 > 1.5 → severity: "critical" → exit 1
閾値の1.5を大きく超えているので、criticalとして検出される。実際のテストでは、意図的に3倍程度悪化させたfixtureを用意し、exit 1になることを確認している。
ケース2: ノイズレベルの変化
previousが0件、recentが1件だったとする。
ratio = 1 / max(0, 1) = 1 / 1 = 1.0
1.0 <= 1.5 → 正常
add-one平滑化がなければ0除算エラーか「無限大」扱いになっていたケースだが、平滑化により1.0という妥当な値に収まり、アラートは出ない。
ケース3: サンプル不足
previousが3件、recentが4件だったとする。
ratio = 4 / max(3, 1) ≈ 1.33(閾値未満だが、そもそも判定しない)
min_entries_per_window(5) 未満 → insufficient_data: true, exit 0
ratioを計算する前に、件数ガードで止まる。仮にratioが閾値を超えていたとしても、この件数では判定を出さない。
注意点・Tips
- 既存の固定モードのロジックは変更しない: rolling比較を追加するときに固定モードの判定を触ると、既存の運用に予期しない影響が出る。出力に
modeフィールドを追加するだけの、非破壊的な拡張にとどめるのが安全。 --rollingと--baseline/--currentは同時指定不可にする: 「今と半年前」と「直近同士」を混ぜて指定できると、意味不明な比較になる。矛盾した指定はexit codeを分けて(例: exit 2)明示的にエラーとして返すべき。- ウィンドウ判定はイベントのタイムスタンプで行う: ファイルのmtimeなど環境依存の値でウィンドウを切ると、OSやファイルシステムによって挙動が変わる。記録データ自身が持つタイムスタンプフィールドを正とするほうが安定する。
- 閾値と最小サンプル数は両方設定可能にする:
ratio_thresholdだけを厳しくしても、少サンプルのノイズ問題は解決しない。閾値とサンプルサイズガードは別軸のパラメータとして両方チューニング可能にしておく。
こうした「固定値の比較対象がいつの間にか古くなる」問題は、モニタリング全般でよく起きる。ログの異常検知、パフォーマンスの劣化検知、コスト増の検知など、対象システムが変化し続ける限り同じ罠が待っている。特にAIエージェントを使った自動開発パイプラインのように改修サイクルが速い環境では、この陳腐化がより早く顕在化しやすい(詳しくはAI開発支援も参照)。
まとめ
固定baseline比較は「特定の変更前後の一度きりの検証」には向くが、「継続的な監視」の基準として使うと、対象システムの構造改修が進むほど比較対象そのものが古くなり、実際の劣化とは無関係なノイズを積み上げてしまう。この陳腐化を解消するには、比較対象を固定ファイルではなく直近の履歴から動的に生成する2つのウィンドウ([now-2N, now-N) と [now-N, now))に変え、ratio = recent / max(previous, 1) というadd-one平滑化付きの比率で判定するのが有効だ。加えて、サンプル数が少ないときは判定そのものを出さないガード(min_entries_per_window 既定5、閾値 ratio_threshold 既定1.5)を用意しておくことで、小さいNのノイズによるアラート疲れも防げる。固定モードを撤去せず両モードを併存させたのは、「一度きりの検証」と「継続監視」という目的の違うニーズを、無理に1つの仕組みに押し込まなかったからだ。



