作ったのに、何も検知しなかった
Apacheログを追記分だけ集計する仕組みを作り、 氷山ビューで 403 と 404 が見えるようになりました。 次に欲しくなるのは自動検知です。毎回ダッシュボードを開かなくても、変化があったら分かるようにしたい。
統計の定番はzスコアです。平均からの隔たりを標準偏差で割る。実装は10行程度で済みました。
そして画面を開いたら、すべての項目が「変化なし」でした。 その裏で、403は倍増し、5xxは完全に消えていたにもかかわらず。
何を作ったか
考え方は単純です。ベースライン(それ以前)を「いつもの状態」とみなし、直近と比べる。 新しいコレクションは要りません。すでに持っている時間帯バケットの集計を、期間で分けるだけです。
const mean = a => a.reduce((x,y)=>x+y,0)/a.length; const sd = a => { const m=mean(a); return Math.sqrt(a.reduce((x,y)=>x+(y-m)**2,0)/a.length); }; const z = (mean(recent) - mean(base)) / sd(base); const verdict = z >= 2.5 ? '増加' : z <= -2.5 ? '減少' : '変化なし';
直近6時間を、その前のベースライン168時間(7日)と比較する設定にしました。
2倍も、消滅も、「変化なし」
実際に出た数字です。
| 指標 | 毎時の推移 | 倍率 | z値 | 判定 |
|---|---|---|---|---|
| 403 遮断 | 81.29 → 160.83 | 1.98倍 | 0.65 | 変化なし |
| 404 探索 | 106.52 → 191.83 | 1.80倍 | 0.31 | 変化なし |
| 5xx エラー | 24.43 → 0 | 完全消滅 | -0.15 | 変化なし |
403は倍近く増えている。5xxは完全にゼロになっている。 それでも判定は全部「変化なし」でした。
σが、大きすぎた
ダッシュボードが表示していたのは z の値だけでした。そこで、 式を σ について解いて逆算してみます。
| 指標 | ベース平均 | 逆算した σ | σ ÷ 平均 |
|---|---|---|---|
| 403 遮断 | 81.29 | 約 122 | 1.5倍 |
| 404 探索 | 106.52 | 約 275 | 2.6倍 |
| 5xx エラー | 24.43 | 約 163 | 6.7倍 |
σが平均の6.7倍になる、ということ
正規分布を想定するなら、σ が平均の数倍というのは異常です。 平均24なのにσが163では、「±2.5σ」の幅は −383 から +432。 件数がマイナスになる範囲まで「正常」に含めていることになります。 この幅の中では、何が起きても検知されません。
犯人は、たった一つのスパイク
ベースラインの168時間には、rewrite の不具合で 1時間に約1,968件 の 5xx が出た山が含まれていました。 平均は24でも、この1点があるだけでσは跳ね上がります。
日周期も、σに乗る
もうひとつ見落としていました。アクセスには日周期があります。 深夜はスキャナが多く、昼は人間のアクセスが増える。時間帯によって水準がまるで違います。
168時間ぶんをひとつの母集団にすると、この昼夜差もばらつきとしてσに含まれます。 本来「時間帯が違うだけ」の変動が、「いつもの揺れ」として計上されてしまう。
結果として、σ は二重に膨らんでいました。スパイクによる膨張と、日周期による膨張です。
中央値+MADでも、足りなかった
外れ値に強い統計といえば、中央値と MAD(中央絶対偏差)です。定石どおり替えてみました。
結果は、こうでした。
| 再現データでの検証 | 平均+σ | 中央値+MAD | 正解 |
|---|---|---|---|
| 5xx が完全消滅 | z=-0.29 変化なし | z=-0.67 変化なし | 減少 |
| 403 が昼に2倍 | z=2.21 変化なし | z=1.94 変化なし | 増加 |
どちらも検知できません。 σ の膨張は緩和されましたが、 日周期のばらつきは残ったままです。 そして根本的に、時間ごとの生カウントに正規分布を当てはめるという構造自体が合っていませんでした。
百分位に変え、時刻帯を揃える
分布の形を、仮定しない
発想を変えました。平均も σ も使いません。 「直近の値は、ベースライン分布のどの位置にあるか」だけを見ます。
百分位なら、裾が重かろうが歪んでいようが関係ありません。 上位95%以上なら急増、下位5%以下なら急減。順位の話なので、分布の形に依存しない。
同じ時刻どうしで比べる
日周期への対策はこうです。直近の各時間を、ベースラインの「同じ時刻」の値とだけ比較する。 深夜3時の値は、過去7日間の深夜3時とだけ比べる。
// ベースラインを hour-of-day で束ねる const byHod = {}; base.forEach(r => { (byHod[r.hod] = byHod[r.hod] || []).push(r.v); }); // 直近の各点を、同じ時刻の母集団の中で順位づけする recent.forEach(r => { const pool = byHod[r.hod] || []; let below=0, eq=0; pool.forEach(x => { if(x < r.v) below++; else if(x === r.v) eq++; }); pcts.push((below + eq/2) / pool.length * 100); });
これで「深夜だから多い」を誤検知しなくなります。 検証では、深夜の通常値が31%(変化なし)と正しく判定されました。 補正が無ければ、全時間帯の中では高い位置に出ていたはずの値です。
百分位でも、捉えられないもの
これで解決かと思いましたが、まだ穴がありました。5xx の完全消滅が検知できません。
理由は単純です。ベースライン168時間のうち、修正後の期間は既にゼロが並んでいます。 全体の4割がゼロなら、新たにゼロになっても下位21%にしかならない。 5%の閾値には届きません。
倍率で補う
順位で捉えられないなら、比で捉えます。 ベースラインの中央値に対して、直近が何倍か。
| 判定 | 条件 |
|---|---|
| 急増 | 百分位 ≧ 95% / または 倍率 ≧ 3 かつ件数が下限以上 |
| 増加 | 百分位 ≧ 85% / または 倍率 ≧ 2 |
| 急減 | 百分位 ≦ 5% / または 倍率 ≦ 0.2 |
| 減少 | 百分位 ≦ 15% / または 倍率 ≦ 0.5 |
さらに下限(5件)を設けました。1件が2件になっただけで「2倍!」と騒がないためです。 小さな数の揺れは、比で見ると必ず大きく出ます。
3つの方式を、同じデータに当てる
7つのシナリオを用意し、3方式で判定させます。 正解と一致したセルが緑、外したセルが赤です。
A と B が外すのは、同じ2つです。5xx の完全消滅と、403 の2倍増—— まさに実運用で起きていた変化そのもの。 「変化なし」のケースは正しく判定するので、一見まともに動いているように見えます。 実際には、本当の変化だけを見逃していました。
手法の選択も、実装のうち
今回のコードにバグはありませんでした。zスコアの計算式は正しく、平均もσも正確です。 それでも何も検知しませんでした。 間違っていたのは実装ではなく、分布の形を確かめずに手法を選んだことです。
そして怖いのは、この失敗が静かなことでした。 例外も出ず、エラーログにも残らない。画面には「変化なし」と表示され続けます。 7つのシナリオを用意して初めて、欠陥が見えました。
この記事のまとめ
- ログの計数は下限ゼロ・上は青天井で裾が重い。zスコアの前提が成り立たない。
- スパイク1つでσが膨張する。逆算するとσは平均の1.5〜6.7倍だった。
- 日周期もσに乗る。昼夜差が「いつもの揺れ」として計上される。
- 中央値+MADに替えても足りない。比較の枠組み自体が合っていなかった。
- 百分位なら分布の形を仮定しない。同じ時刻どうしで比べて日周期を打ち消す。
- 百分位で捉えられない変化(既にゼロが多い場合)は倍率で補う。小さな数には下限を設ける。
- 検証は「動くか」ではなく「正しく判定するか」で。ケースを並べて初めて欠陥が見える。