Prometheus の p99 はどこから出てくるのか? 生の数字は増え続けるカウンタだけなのに
アプリの /metrics を curl で覗くと、http_requests_total 183042 のような、増え続けるだけの数字が並んでいる。ところが Grafana には「秒間 12.4 リクエスト」「p99 = 0.43 秒」がきれいに描かれる。生データには秒間の値もパーセンタイルも書かれていないのに、この 2 つの数字はどこで計算されているのだろうか。
- アプリが公開するのは「起動からの累計」であるカウンタで、秒間の値は Prometheus 側の
rate()が時間窓の両端の差から計算する。累計なので取りこぼしやプロセス再起動があっても計算が壊れにくい - レイテンシは histogram として「閾値
le以下だった件数」をバケットごとのカウンタで持ち、histogram_quantile()がバケット内を一様分布と仮定した線形補間で p99 を推定する。だから値の精度はバケットの幅で決まる - 集計は「
rate()が先、sumが後」。summary のパーセンタイルは複数インスタンスで平均できないが、histogram のバケットは足し合わせてから分位数を出せる
アプリは「累計」しか教えない
Prometheus はプル型の監視システムで、設定した間隔(scrape_interval)ごとにアプリの /metrics を HTTP で取りに行き(スクレイプ)、その時点の値を時刻と一緒に保存する。保存された「時刻と値の列」を時系列と呼ぶ。
リクエスト数のような「起きた回数」はカウンタとして公開する。カウンタとは、増えることしかなく、プロセスの再起動時にだけ 0 に戻る累計値のことだ。現在のメモリ使用量のように上下する値はゲージと呼び、カウンタとは区別する。
直感的には「直近 1 秒のリクエスト数」をアプリが直接出す方が素直に思える。しかしそうすると、スクレイプが 1 回失敗しただけでその区間のリクエストは誰にも数えられずに消える。累計なら、次にスクレイプが成功したときの値に失敗区間の分も含まれているので、2 点の差を取れば何件起きたかは失われない。アプリ側は整数を 1 足すだけで済み、集計のコストを Prometheus 側に寄せられる点も累計方式の利点である。
rate() は「差 ÷ 時間」に 2 つの補正を足したもの
rate(http_requests_total[5m]) は、直近 5 分の窓に入ったサンプルから 1 秒あたりの平均増加量を返す。基本は「最後の値 − 最初の値」を経過秒数で割る計算だが、2 つの補正が入る。
1 つ目はカウンタリセットの補正だ。公式ドキュメントは、ターゲットの再起動などによる単調性の崩れを自動で補正すると明記している。値が前より小さくなったら「0 に戻ってからまた数え始めた」とみなし、戻る前の値を足し込む。たとえば 1000 → 1030 → 5 → 35 と並んだら、増加量は 30 + 5 + 30 = 65 と数える。
値
1030 ┤ ●
1000 ┤ ● │ 再起動で 0 に戻る
│ │
35 ┤ │ ●
5 ┤ └─●
└─┬──┬──┬───┬──▶ 時刻
増加: +30 +5 +30 = 65
2 つ目は外挿である。窓の端ちょうどにサンプルがあるとは限らないので、rate() と increase() は窓の両端まで傾きを延ばして推定する。このため increase(...[5m]) は「5 分間に何件増えたか」を返すのに、結果が 29.97 のような小数になることがある。壊れているのではなく、推定値として設計どおりの挙動だ。
窓の幅には目安がある。窓に 2 点以上のサンプルが入らないと差が取れず、結果が空になる。Grafana は窓をスクレイプ間隔の 4 倍以上にすることを勧めており、そのための変数 $__rate_interval を用意している。窓を広げるほど線はなめらかになるが、短いスパイクはならされて見えなくなる。直近 2 点だけで計算する irate() は逆に鋭く反応するが揺れが大きく、アラート条件には向かない。
車の走行距離計(オドメーター)に近い。時速は記録していなくても、2 回の記録時刻の距離計の差を時間で割れば平均速度が出る。途中で記録を 1 回取り損ねても、次の記録の距離計にはその区間の走行分が入っているので距離は失われない。メーターを交換して 0 に戻ったら、交換前の数字を足してつなげばよい。rate() がやっているのはこの計算である。
histogram は「何秒以下が何件」の累積カウンタ
レイテンシの p99 を出すには、1 件ずつの所要時間を全部保存すれば確実だ。しかしリクエストのたびに値を保存すると、データ量はトラフィックに比例して増えていく。そこで histogram は、あらかじめ決めた境界値ごとに「この秒数以下だった件数」をカウンタで数える。境界値ごとの数え箱をバケットと呼び、ラベル le(less than or equal、以下)で境界を表す。
0 0.1 0.25 0.5 1 (秒) ├──────┼───────┼────────┼──────┤ │ 50件 │ 40件 │ 8件 │ 2件 │ └──────┴───────┴────────┴──────┘ le="0.1" = 50 le="0.25" = 90 (50+40) le="0.5" = 98 (90+8) le="1" = 100 le="+Inf" = 100 = _count
各バケットは自分の境界以下の全件を数える累積値なので、le="+Inf" は全件数と一致し、これが _count になる。合計時間の _sum も一緒に公開されるので、rate(_sum[5m]) / rate(_count[5m]) で平均レイテンシも出せる。どれもただのカウンタなので、前節の rate() がそのまま使える。
境界はアプリ側で決める。Go のクライアントライブラリの既定値 DefBuckets は .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10(秒)で、ドキュメント自身が「たいていは用途に合わせて定義し直す必要がある」と書いている。境界は時系列の数そのものなので、増やせば精度は上がるが、ラベルの組み合わせ × バケット数だけ保存量が増える。
p99 は「バケットの中を均等とみなした」推定値
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) は、上の図から p95 を次のように求める。100 件中 95 番目の観測は、90 件目まで(0.25 秒以下)と 98 件目まで(0.5 秒以下)の間、つまり 0.25〜0.5 秒のバケットにある。このバケットの 8 件の中では 5 番目なので、公式ドキュメントのとおりバケット内の観測が一様に分布していると仮定し、0.25 + 0.25 × 5/8 ≒ 0.406 秒と答える。
ここに落とし穴がある。実際の 8 件が全部 0.26 秒付近に固まっていても、計算結果は 0.406 秒のままだ。誤差の大きさはその分位数が入ったバケットの幅で決まる。公式の解説には、本当の p95 が約 220ms なのに 200〜300ms のバケットに入ったせいで 295ms と出て、300ms の SLO をほぼ破っているように見えた例が載っている。また分位数が最上位の +Inf バケットに入ると、2 番目に高いバケットの上限がそのまま返る。上の例で境界の最大が 1 秒なら、本当は 8 秒かかっていても p99 は 1 秒と表示される。
だからバケットの境界は SLO の閾値に合わせて置く。「300ms 以内に 95%」を見たいなら le="0.3" を作れば、rate(..._bucket{le="0.3"}[5m]) / rate(..._count[5m]) で「300ms 以内に返せた割合」を補間なしで正確に計算できる。分位数を推定するより、閾値以下の割合を見る方が histogram の得意な使い方だ。
// Go: SLO 300ms の前後を細かく区切る
prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Buckets: []float64{.05, .1, .2, .3, .45, .6, 1, 2.5},
})
集計は rate() が先、sum が後
Pod が 10 個あれば、バケットの時系列も 10 組ある。全体の p99 は、まずインスタンスごとに rate() を取り、le ラベルを残したまま足し合わせてから histogram_quantile() に渡す。
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
順番を逆にして先に sum すると壊れる。1 つの Pod が再起動してそのカウンタが 0 に戻ると、合計値ががくんと下がる。rate() はこれを合計全体のリセットと誤認するため、存在しない急増がグラフに現れる。公式ドキュメントが「必ず先に rate()、それから集計」と書いているのはこのためだ。by (le) を付け忘れて le を消してしまうと、どのバケットの値か分からなくなり分位数は計算できない。
似た型に summary があり、こちらはアプリ側で p99 などの分位数そのものを計算して公開する。精度は高いが、分位数は平均できない。Pod A の p99 が 0.1 秒、Pod B が 0.9 秒でも、全体の p99 は 0.5 秒とは限らず、リクエスト数の偏り次第で大きく変わる。公式の解説も summary の分位数の avg() を「統計的に無意味」として誤りの例に挙げている。複数インスタンスで動く Web アプリでは、足し算で合算できる histogram を選ぶのが基本になる。なお最近の Prometheus には、境界を事前に決めなくてよく誤差も小さい native histogram があり、公式はクライアントが対応していればこちらを推奨している。
よくあるのは 3 つの場面だ。1 つ目は「p99 がいつ見てもぴったり 0.5 や 1 に張り付いている」で、ほとんどの場合、分位数が最上位バケットに入っており境界の上限が返っているだけである。境界を足すか、le="+Inf" と 1 つ下のバケットの件数差を見れば、範囲外の遅いリクエストが何件あるか分かる。2 つ目はデプロイのたびにリクエスト数のグラフにスパイクが立つケースで、rate(sum(...)) のように集計の順番が逆になっていないかを疑う。3 つ目は短い窓([1m] など)のグラフが途切れ途切れになるケースで、窓にサンプルが 2 点入っていない。Grafana では窓を $__rate_interval にしておくのが無難だ。
Prometheus は自分自身のメトリクスをスクレイプする設定で起動するので、それを使って生のカウンタとバケット、rate() と histogram_quantile() の結果を見比べる。
docker run -d --name prom -p 9090:9090 prom/prometheus
# (1) 生データ: 累計カウンタと累積バケット
curl -s localhost:9090/metrics | grep '^prometheus_http_request_duration_seconds_bucket{handler="/metrics"'
# 1〜2 分待って何度か curl し、リクエストを発生させてから:
# (2) 秒間リクエスト数(Prometheus 側で計算)
curl -s localhost:9090/api/v1/query \
--data-urlencode 'query=sum by (handler) (rate(prometheus_http_requests_total[2m]))'
# (3) p99 の推定値
curl -s localhost:9090/api/v1/query \
--data-urlencode 'query=histogram_quantile(0.99, sum by (le) (rate(prometheus_http_request_duration_seconds_bucket[2m])))'
docker rm -f prom
(1) では le が大きくなるほど値が増えていき、le="+Inf" が最大になる累積の形が見える。(2) の value は小数の秒間値で、生データには無かった数字だ。(3) を何度か叩くと、p99 が境界値の間を補間した半端な値、あるいはどこかのバケットの境界値ちょうどになることがある。後者は分位数が最上位付近のバケットに落ちたときの挙動である。ブラウザで http://localhost:9090 を開いて同じクエリを Graph タブで描いてもよい。
increase(x[5m])は 5 分間の正確な件数を返す — 窓の両端まで外挿した推定値なので小数になり、実際の件数と少しずれる。課金のような厳密な件数が要る用途には使わない。- histogram の p99 は実測値である — バケット内を一様分布とみなした補間による推定値で、誤差はバケットの幅で決まる。最上位バケットに入れば境界の上限しか返らない。
- 各 Pod の p99 を平均すれば全体の p99 になる — 分位数は平均できない。histogram のバケットを
sum by (le)で足してからhistogram_quantile()に渡す。
- カウンタ
- 増えることしかなく、再起動時にだけ 0 に戻る累計値のメトリクス。
- rate()
- 時間窓内のカウンタの増加を、リセット補正と外挿を加えて 1 秒あたりの値にする PromQL 関数。
- histogram
- 境界
le以下の観測件数をバケットごとの累積カウンタで持ち、_sumと_countも公開するメトリクス型。 - バケット
- histogram の境界値ごとの数え箱。境界の置き方が分位数の精度を決める。
- histogram_quantile()
- バケット内を一様分布と仮定した線形補間で、histogram から分位数を推定する PromQL 関数。
- summary
- アプリ側で分位数を計算して公開するメトリクス型。精度は高いがインスタンス間で集計できない。
- Histograms and summaries | Prometheus — histogram と summary の比較表と、バケット幅のせいで p95 が大きくずれる具体例
- Query functions | Prometheus —
rate()のリセット補正・外挿とhistogram_quantile()の補間規則の一次情報 - Metric types | Prometheus — counter / gauge / histogram / summary の定義と native histogram の位置づけ
- New in Grafana 7.2: $__rate_interval | Grafana Labs — rate の窓が短すぎると何が起きるか、スクレイプ間隔 4 倍の目安の理由