Skip to content

필사 모드: 質問に答えるPrometheusメトリクス設計 — 型の選択、カーディナリティ予算、rateと分位数の罠

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — パネルは40個あるのに答えがないダッシュボード

障害対応中にダッシュボードを開きます。パネルが40個あります。CPU、メモリ、スレッド数、GC回数、コネクションプールサイズ、ヒープ使用量、秒間リクエスト数。すべてグラフが描かれています。ところが今知りたいことはひとつだけです。「ユーザーが失敗を経験しているか、しているなら何パーセントか」。

その答えはあの40個のパネルのどこにもありません。

メトリクス設計の問題はデータが足りないことではなく、収集したデータで答えられる質問と答えるべき質問がずれていることです。この記事はそのずれを減らす方法を扱います。Prometheus 3.13.0 LTS基準で確認し、ネイティブヒストグラムは3.8.0で安定化しましたが、スクレイプは依然として明示的なオプションで有効にする必要があります。

メトリクスが答えられる質問の形

メトリクスは時間とともに変わる数値の集計です。この定義から、答えられる質問の形が決まります。

質問メトリクスは答えるか理由
いつから悪化したか答える時間軸が連続的で過去が保存される
何パーセントのリクエストが影響を受けるか答える集計値がそのまま比率になる
昨日の同じ時間帯と比べてどうか答える保存コストが低く長期保持ができる
このユーザーのリクエストがなぜ失敗したか答えない個々のイベントが集計されて消えている
遅いリクエストがどのサービスで時間を使ったか答えないサービスごとの統計が同じリクエストのものである保証がない
どの入力値がこの分岐を引いたか答えない値がラベルになければ復元できない

最後の三行が重要です。その質問に答えようとラベルを増やした瞬間、メトリクスはログやトレースの劣った代替品になります。メトリクスの限界はカーディナリティが決めます。 リクエストごとに値が変わるものをラベルに入れ始めると、それはすでにメトリクスではなく圧縮率の悪いイベントストアです。

出発点は質問のリストです。オンコールが深夜3時に投げる質問五つを先に書き出し、その五つに答えるメトリクスだけを作ります。

  1. ユーザーが失敗を経験しているか、何パーセントか
  2. 遅くなったか、どのルートが遅くなったか
  3. いつからか、デプロイの時刻と重なっているか
  4. キャパシティの限界に達したか (キュー長、コネクションプール、ディスク)
  5. 依存している外部サービスのうち問題があるところはあるか

カウンタ、ゲージ、ヒストグラム — 選び方を間違えると計算が不可能になる

型の選択は好みの問題ではありません。間違えると、後でやりたい計算が原理的に不可能になります。

カウンタは単調増加しかしません。プロセスが再起動すると0に戻ります。値そのものには意味がなく、rateで秒あたりの変化率を見てはじめて意味が生まれます。リクエスト数、エラー数、処理したバイト数、リトライ回数がここに該当します。

ゲージは上下します。現時点の状態を表します。キュー長、アクティブなコネクション数、メモリ使用量、温度がここに該当します。

ヒストグラムは観測値の分布をバケットカウンタの集合として記録します。レイテンシ、レスポンスサイズのように「分位数を知りたい値」に使います。

もっともよくある間違いは、レイテンシをゲージで記録することです。

# 悪い例 — 最後のリクエストのレイテンシしか残らない。分位数も平均も計算できない
from prometheus_client import Gauge

last_latency = Gauge("http_request_duration_seconds", "リクエスト処理時間")

def handle(req):
    t0 = time.monotonic()
    resp = process(req)
    last_latency.set(time.monotonic() - t0)   # それまでの値はすべて消える
    return resp

スクレイプ間隔が15秒で秒間リクエストが500件なら、7500件のうち1件の値しか保存されません。残りは存在しなかったことになります。この時系列ではp99を求められず、後でデータを再処理しても復元されません。

# 良い例 — ヒストグラムはすべての観測をバケットに累積する
from prometheus_client import Counter, Histogram

REQUESTS = Counter(
    "http_requests_total", "総リクエスト数",
    ["method", "route", "status_class"],
)
LATENCY = Histogram(
    "http_request_duration_seconds", "リクエスト処理時間",
    ["method", "route"],
    buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
)

def handle(req):
    route = req.route_template          # "/v1/orders/:id" — 実際のIDではない
    with LATENCY.labels(req.method, route).time():
        resp = process(req)
    REQUESTS.labels(req.method, route, f"{resp.status // 100}xx").inc()
    return resp

status_classに注目します。ステータスコードをそのまま入れると値が数十種類になりますが、ほとんどの質問は2xxか5xxかだけを必要とします。詳細なコードが必要になるのは調査するときで、そのときはログを見ます。

知りたいことよくある誤答誤答の結果
秒間リクエスト数カウンタゲージで直前1秒のリクエスト数スクレイプの間のリクエストが消える
レイテンシの分位数ヒストグラムゲージで最後の値分位数の計算が不可能
レイテンシの分位数ヒストグラムアプリケーションが計算したp99ゲージインスタンス間の集計が不可能
現在のキュー長ゲージカウンタで累積エンキュー数現在の滞留が分からない
総スループットカウンタゲージで秒間スループットスクレイプ漏れ時にその区間が消失
バッチジョブの成功可否カウンタ + タイムスタンプゲージ成功時のみカウンタを増やす失敗と未実行を区別できない

最後の行はバッチジョブで繰り返される問題です。失敗したときもカウンタを上げなければ、「動いたが失敗した」と「そもそも動かなかった」を区別できません。最後の成功時刻をゲージとして残すことも一緒に行います。

JOB_RUNS = Counter("batch_job_runs_total", "バッチ実行回数", ["job", "result"])
JOB_LAST_SUCCESS = Gauge("batch_job_last_success_timestamp_seconds", "最後の成功時刻", ["job"])

def run_job(name, fn):
    try:
        fn()
        JOB_RUNS.labels(name, "success").inc()
        JOB_LAST_SUCCESS.labels(name).set(time.time())
    except Exception:
        JOB_RUNS.labels(name, "failure").inc()
        raise

ラベルのカーディナリティ予算

カーディナリティは感覚ではなく掛け算です。メトリクスひとつの時系列数はラベル値の個数の積であり、そこにインスタンス数が掛かります。

http_request_duration_seconds_bucket
  route          120個 (ルートテンプレート)
  method           5個
  le              11個 (バケット境界 + Inf)
  instance        40個 (ポッド数)
  ------------------------------------
  = 120 * 5 * 11 * 40 = 264,000 時系列

これに_sumと_countが加わる
  120 * 5 * 40 * 2 = 48,000
  ------------------------------------
合計 約312,000時系列 — メトリクスひとつから

Prometheusひとつが扱うアクティブな時系列は、おおよそ数百万規模でメモリとクエリの応答性が急激に悪化します。正確な限界はハードウェアとクエリパターンによって変わりますが、メトリクスひとつが30万時系列を使うなら、それだけで予算のかなりの部分が消えるという点が重要です。

絶対に入れてはいけないラベルは、値の集合が無限に近いものです。

  • ユーザーID、セッションID、リクエストID、トレースID
  • 正規化されていないURLパス — /v1/orders/A-99183
  • メール、電話番号、注文番号
  • タイムスタンプ、またはそれを含む文字列
  • エラーメッセージの原文 — スタックトレースの断片が混じると値が事実上無限になる
  • 自由形式のクエリ文字列

境界線上にあるものもあります。podinstanceはポッド数だけ掛かり、オートスケーリングとローリングデプロイで値が変わり続けるため、時間が経つほど積み上がります。インスタンスごとの区別が本当に必要なメトリクスにだけ残し、残りはレコーディングルールで集計してから元データを短く保持する戦略が現実的です。

現状を診断するクエリです。

# 時系列数の多いメトリクス上位10個
topk(10, count by (__name__)({__name__=~".+"}))

# 特定のメトリクスでどのラベルがカーディナリティを作っているか
count(count by (route) (http_requests_total))
count(count by (instance) (http_requests_total))
count(count by (status) (http_requests_total))

# 全体のアクティブ時系列数の推移 — 階段状に跳ねたら新しいラベルが入ったということ
prometheus_tsdb_head_series

# スクレイプ対象ごとに何個のサンプルを送っているか (上限を超えるとスクレイプが拒否される)
topk(20, scrape_samples_scraped)

prometheus_tsdb_head_seriesはアラートを掛けておく価値があります。カーディナリティ事故はたいていデプロイ直後に階段状に現れるため、デプロイと時刻を突き合わせれば原因のコミットをすぐに見つけられます。

収集時点で止める方法もあります。スクレイプ設定にサンプル数の上限を掛けておけば、暴発した対象がPrometheus全体を巻き込むのを防げます。

# prometheus.yml
scrape_configs:
  - job_name: checkout-api
    sample_limit: 20000              # この対象がこれ以上送るとスクレイプ失敗として扱う
    label_limit: 24
    label_value_length_limit: 256
    scrape_interval: 15s
    metric_relabel_configs:
      # 事故対応用 — 問題になったラベルを収集時点で消す。
      # labeldropではなくreplaceで空値を入れる理由: labeldropはラベルの
      # 「名前」だけを見てマッチするため、このjobのすべてのメトリクスから
      # user_idを消してしまう。下のように書けば、http_requests_totalだけから消せる。
      # 空値のラベルは、Prometheusのデータモデルにおいて存在しないラベルと同じ扱いになる。
      - source_labels: [__name__]
        regex: 'http_requests_total'
        target_label: user_id
        replacement: ''
        action: replace
      # まったく不要なメトリクスは捨てる
      - source_labels: [__name__]
        regex: 'go_gc_duration_seconds.*|python_gc_.*'
        action: drop

sample_limitに引っかかると、その対象のスクレイプがまるごと失敗するため、値には余裕を持たせつつ必ず設定しておきます。上限がないと、ひとつのサービスのミスが監視全体を止めてしまいます。

rateが静かに間違った答えを返す条件

rateはrange vectorの最初のサンプルと最後のサンプルから秒あたりの増加率を求め、カウンタのリセットを補正します。罠は三つあります。

罠1 — ウィンドウがスクレイプ間隔に対して狭い

rateはウィンドウ内に最低二つのサンプルが必要です。スクレイプ間隔が15秒でウィンドウが20秒だと、サンプルがひとつしか入らない瞬間が生まれ、結果が空になります。グラフには穴として、アラートでは「条件が成立しない」として現れます。

# 危険 — スクレイプ間隔が15sのとき、[20s]はサンプルが1個になる瞬間がある
rate(http_requests_total[20s])

# 安全 — スクレイプ間隔の4倍以上。アラートには[5m]以上を推奨
rate(http_requests_total[1m])
rate(http_requests_total[5m])

経験則は単純です。アラートには5分以上、ダッシュボードにはGrafanaのrate間隔変数を使います。 4倍の規則は、スクレイプが1、2回抜けても結果が生き残るようにします。

罠2 — sumの後にrateを適用する

この間違いは結果がもっともらしいので長く生き残ります。

# 間違い — カウンタを先に足すと、インスタンスの再起動(リセット)が検知されない
rate(sum(http_requests_total) by (route)[5m:])

# 正しい — rateを先に、集計はその後
sum(rate(http_requests_total[5m])) by (route)

カウンタのリセット補正は個々の時系列単位でのみ正確です。ポッドがひとつ再起動すると、その時系列は0に落ちますが、すでに合算された後だと「全体が少し減った」としか見えず、リセットとして認識されません。結果は実際より低く出るリクエスト率で、デプロイ直後のたびにトラフィックが減ったように見えます。

罠3 — カウンタをそのまま描く

# 右肩上がりの直線が出るだけ。何の情報もない
http_requests_total

# 秒あたりの増加率 — これが本来見たかった値
sum(rate(http_requests_total[5m])) by (route)

# 特定区間の総増加量 — アラートの文言に使いやすい
sum(increase(http_requests_total{status_class="5xx"}[1h])) by (route)

increaserateにウィンドウの長さを掛けたものです。したがって整数にならない値が出ます。エラーが3件発生したのにincreaseが3.4を返すのはバグではなく外挿の結果です。「正確にN件」が必要な計算には使いません。

irateは最後の二つのサンプルだけを見ます。ダッシュボードで瞬間的な反応を見るときには有用ですが、アラートには絶対に使いません。ノイズ一回で発火します。

histogram_quantileが静かに間違った答えを返す条件

分位数の計算は罠がさらに多いです。

罠1 — 集計からleを抜かす

# 間違い — leを捨てるとバケットが潰れて意味のない数字になる
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (route))

# 正しい — leは必ず残す
histogram_quantile(0.99,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route)
)

この間違いはエラーを出さず数字を返します。だからダッシュボードに何か月も残り続けます。

罠2 — バケット境界の外側で推定する

histogram_quantileはバケットの間を直線で補間します。p99が最後の有限バケットより上にあると、関数はその最後の境界値を返します。実際のp99が8秒でも最後のバケットが2.5秒なら、結果は永遠に2.5秒です。

# 診断 — Infバケットと最後の有限バケットの比率を見る
sum(rate(http_request_duration_seconds_bucket{le="+Inf"}[5m])) by (route)
-
sum(rate(http_request_duration_seconds_bucket{le="10.0"}[5m])) by (route)

この値が0より大きければ、最後のバケットを超えるリクエストがあるということで、p99が信頼できない状態にある可能性があります。

逆にバケットが粗すぎると補間誤差が大きくなります。バケットが0.1と1.0しかないのに、リクエストの大半が0.15秒に集中していると、p99の推定は0.1と1.0の間を直線で切るため実際と大きくずれます。バケットはSLOのしきい値の周辺を細かく置きます。 300msを目標にするなら、0.2、0.25、0.3、0.4、0.5がバケットになければなりません。

罠3 — 分位数を平均する

# 間違い — 分位数は算術平均が成立しない
avg(histogram_quantile(0.99, ...))

# 正しい — バケットカウンタを先に合算してから分位数を求める
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))

インスタンスごとのp99の平均は全体のp99ではありません。ヒストグラムが価値を持つ理由がまさにこれです。バケットカウンタは足し合わせることができ、足した後に分位数を求めれば全体の分布に対する推定になります。アプリケーションが自前で計算したp99ゲージを公開すると、この性質が失われます。

ネイティブヒストグラム

Prometheus 3.8.0からネイティブヒストグラムが安定機能です。バケット境界を指数スキーマで自動管理するため境界を自分で選ぶ必要がなく、ひとつの時系列で表現されるためカーディナリティが大幅に減ります。ただしスクレイプは明示的に有効化する必要があります。

# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: checkout-api
    # ネイティブヒストグラムのスクレイプは明示的なopt-in
    scrape_native_histograms: true
    static_configs:
      - targets: ['checkout-api:8000']
# 固定バケット: _bucket時系列にleラベルが付く
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route))

# ネイティブヒストグラム: leなしでメトリクス自体を渡す
histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m])) by (route))
項目固定バケットヒストグラムネイティブヒストグラム
時系列数バケット数掛けるラベルの組み合わせラベルの組み合わせごとに1個
バケット設計事前に選ぶ必要があり、変えると過去と不連続になる自動、解像度だけ指定
範囲外の値最後の境界で切られる指数スキーマで広くカバー
ツール互換性どこでも動くスクレイプのopt-inが必要、一部ツール未対応
移行難易度基準線クエリとダッシュボードの修正が必要

移行は新規メトリクスから始めるほうが安全です。既存のメトリクスを変えると、その時点で過去データとクエリが不連続になります。

レコーディングルール — いつ、どんな名前で

レコーディングルールは計算をクエリ時点から評価時点に移します。作る基準は四つです。

  1. 同じ表現式が三箇所以上で繰り返されるとき
  2. クエリの実行が2秒を超えるとき
  3. アラートルールが重い表現式を毎回の評価で回すとき
  4. 高カーディナリティな元データを集計して長期保持したいとき

名前は水準:メトリクス:演算という規約に従います。コロンはレコーディングルールにだけ使い、元のメトリクス名には絶対に使いません。この規約を守れば、名前を見ただけでどの次元が残っているか分かります。

# rules/http.yml
groups:
  - name: http_sli
    interval: 30s
    rules:
      # 階層1 — 元データから一度だけ集計する
      - record: route:http_requests:rate5m
        expr: sum(rate(http_requests_total[5m])) by (route)

      - record: route:http_requests_errors:rate5m
        expr: sum(rate(http_requests_total{status_class="5xx"}[5m])) by (route)

      # 階層2 — 階層1を参照する。元データを再スキャンしない
      - record: route:http_error_ratio:rate5m
        expr: |
          route:http_requests_errors:rate5m
            /
          route:http_requests:rate5m

      - record: route:http_request_duration_seconds:p99_rate5m
        expr: |
          histogram_quantile(0.99,
            sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route)
          )

      # サービス全体 — ルートの次元をなくした要約
      - record: service:http_error_ratio:rate5m
        expr: |
          sum(rate(http_requests_total{status_class="5xx"}[5m]))
            /
          sum(rate(http_requests_total[5m]))

階層構造が核心です。階層1を複数のルールが再利用すれば、元の時系列スキャンは一回で済みます。ただしルール同士で循環参照を作ってはいけず、promtoolはこれを検出してくれないため、レビューで確認する必要があります。

レコーディングルールが作る時系列数はデプロイ前に計算します。ルートが120個あって五つのウィンドウそれぞれにルールを作ると、600個の新しい時系列です。ルールが50個なら3万個です。

検証はCIで回します。

# 構文チェック
promtool check rules rules/http.yml

# 単体テスト — 入力時系列を与えて期待値を検証する
promtool test rules tests/http_test.yml

# 設定全体のチェック
promtool check config prometheus.yml
# tests/http_test.yml
rule_files:
  - ../rules/http.yml

evaluation_interval: 30s

tests:
  - interval: 15s
    input_series:
      - series: 'http_requests_total{route="/v1/orders", status_class="2xx"}'
        values: '0+150x40'
      - series: 'http_requests_total{route="/v1/orders", status_class="5xx"}'
        values: '0+3x40'
    promql_expr_test:
      - expr: route:http_error_ratio:rate5m
        eval_time: 8m
        exp_samples:
          - labels: 'route:http_error_ratio:rate5m{route="/v1/orders"}'
            value: 0.0196078431372549

期待値を自分で計算しておけば、後で誰かがルールを「最適化」して意味を変えてしまったときにCIが検知してくれます。

ダッシュボードに載せる前に自問すべき五つの質問

パネルをひとつ作るたびに以下を通します。通らなければそのパネルは作りません。

  1. このパネルが答える質問を一文で書けるか。 「CPU使用率」は質問ではありません。「このサービスがCPUの限界に達して遅延が起きているか」が質問です。
  2. 値が悪化したとき何をするか決まっているか。 見ることはできても何の行動も引き起こさない指標は、ダッシュボードではなく探索クエリとして残します。
  3. この値はユーザー体験とどうつながっているか。 GC回数はそれ自体では何の意味もありません。レイテンシと一緒に置かれてはじめて意味が生まれます。
  4. 正常範囲を知っているか。 正常が何か分からなければ異常も分かりません。軸の範囲としきい値の線を一緒に決めます。
  5. このクエリはカウンタをそのまま描いていたり、分位数を平均していたりしないか。 前の二節で扱った罠をもう一度確認します。

パネルの順序も質問の順序に従います。一番上はユーザー視点の指標、その下が原因の候補、一番下がインフラ資源です。上から下へ読むと「影響があるか、どこから来ているか、資源の問題か」という流れになります。

おわりに — メトリクスの価値は数ではなく質問への対応から生まれる

メトリクス300個のシステムでオンコールが答えを見つけられないのはよくあることです。逆に、よく選ばれた20個でほとんどの障害を絞り込んでいくチームもあります。違いは収集量ではなく、各メトリクスがどの質問に答えるために存在するのかが明示されているかどうかです。

今すぐできるもっとも安価な点検は二つです。第一に、時系列数上位10個のメトリクスを抜き出し、それぞれ「これで何の質問に答えるか」を書いてみます。答えが出ないものが、たいていコストの半分を占めます。第二に、ダッシュボードでhistogram_quantileが入ったクエリをすべて検索し、by (leがあるか確認します。ひとつくらいは抜けています。

さらに掘り下げるための資料です。

현재 단락 (1/229)

障害対応中にダッシュボードを開きます。パネルが40個あります。CPU、メモリ、スレッド数、GC回数、コネクションプールサイズ、ヒープ使用量、秒間リクエスト数。すべてグラフが描かれています。ところが今知...

작성 글자: 0원문 글자: 11,750작성 단락: 0/229