メトリクス・ログ・トレースは別々の問いに答える
一言でいうと
メトリクスはいつ、どれくらいに答え、ログはなぜに、トレースはどこに答えます。この3つは、互いの上位互換ではなく、答えられる質問の形が違う道具です。この境界を無視して、メトリクスにリクエストIDを付けた瞬間、それはもうメトリクスではなく、圧縮率の悪いイベントストアになります。
なぜ必要なのか
障害対応の最中に、ダッシュボードを開きます。パネルが40個あります。CPU、メモリ、スレッド数、GCの回数、コネクションプールのサイズ、ヒープ使用量、毎秒のリクエスト数。すべてグラフが描かれています。ところが、今知りたいことは1つです。「ユーザーが失敗を経験しているのか、経験しているなら何%なのか」。その答えは、40個のパネルのどこにもありません。
メトリクス設計の問題は、データが足りないことではなく、収集したデータで答えられる質問と、答えるべき質問がずれていることです。そのため、出発点はメトリクスの一覧ではなく、質問の一覧です。オンコールが午前3時に投げる質問を5つ先に書き出し、その5つに答えるメトリクスだけを作ります。
どう動くのか
| 質問 | メトリクス | ログ | トレース |
|---|---|---|---|
| いつから悪くなったのか | 答えられます | 難しいです | できません |
| 何%のリクエストが影響を受けたか | 答えられます | 高コストです | できません |
| このリクエストはなぜ失敗したのか | できません | 答えられます | できません |
| 遅いリクエストがどのサービスで時間を使ったか | できません | できません | 答えられます |
メトリクスにできないマスが重要です。そのマスをメトリクスで埋めようとしてラベルを増やした瞬間、コストが爆発します。メトリクスの限界は、カーディナリティで決まります。
そして、試験で最もよく出る概念がパーセンタイルです。10,000件のうち9,900件が50ms、100件が3,000msかかったとすると、平均は79.5msです。実際に79.5msで応答を受け取ったリクエストは、1件もありません。さらに悪いのは感度です。遅い100件が3,000msから6,000msへと2倍に悪化しても、平均は79.5から109.5に動くだけで、30msの上昇は、どのアラートのしきい値も超えません。
ここから2つのことが導かれます。
1つ目に、p99は「ユーザーの1%」ではありません。リクエスト100個のうち1個という意味なので、1つの画面が20個のAPIを呼び出すと、その画面がp99に当たる確率は18.2%で、1日に200回リクエストするユーザーは、86.6%の確率で1日に一度は最悪の区間を経験します。
2つ目に、パーセンタイルは合算できません。サーバーAが9,900件すべて50ms、サーバーBが100件すべて3,000msなら、p99の平均は1,525ms、リクエスト数で加重した平均は79.5ms、2台のサーバーを1列に並べた本当のp99は3,000msです。3つの数字はすべて違い、前の2つはまったく意味がありません。カウンターは足し合わせられますが、パーセンタイルは足し合わせられません。ヒストグラムが存在する理由が、まさにこれです。
SLIは測定指標、SLOはその目標値、SLAは法的な契約です。30日で99.9%が目標なら、エラーバジェットは0.1%、43,200分の0.1%である43.2分です。バーンレートはそのバジェットを使い切る速度の倍数なので、14.4で消費し続けると、50時間で30日分のバジェットがなくなります。
現場での姿
筆者の7ノードのホームラボ(コントロールプレーン3台とGPUワーカー4台)にはkube-prometheus-stackが入っていて、GrafanaがMetalLBのプール10.0.0.200–215の中の10.0.0.203を使っています。ここで、ヒストグラム1つがどれほど高コストかを計算してみると、実感がわきます。
http_request_duration_seconds_bucket
route 120 × method 5 × le 11 × pod 40 = 264,000 시계열
여기에 _sum 과 _count: 120 × 5 × 40 × 2 = 48,000
------------------------------------------------
합계 약 312,000 시계열 — 메트릭 하나에서
メトリクス1つで30万の時系列を使います。時系列1つあたり約8KBとすると、1,000万の時系列は80GBです。「とりあえず全部入れて、あとで減らそう」がなぜ危険なのかは、この掛け算1つで説明がつきます。
同じクラスターで学んだ教訓をもう1つ付け加えます。KubeVirtを入れたとき、コンポーネントの状態はすべてAllComponentsReadyだったのに、VMは起動しませんでした。「状態がReady」と「実際に動作する」は別の命題です。オブザーバビリティは、Readyラベルではなく、ユーザーが経験する症状を材料にしなければなりません。
Prometheusの世界の見方
Prometheusを使っていると、「なぜこう動くのか」と思うことがいくつかありますが、そのほとんどはプル(pull)モデルと時系列の保存構造から来ています。その2つを知れば、残りは説明がつきます。
ターゲットが消えると、メトリクスも消えます。Podが死ぬと、その時系列はもう入ってきません。そのため、up == 0では消えたターゲットを捉えられません。up自体がないからです。消えることを捉えるには、absent()か、サービスディスカバリの期待される個数との比較を使います。
値はスクレイプ時点のスナップショットです。15秒ごとにスクレイプするなら、その間の瞬間的な急増は見えません。カウンターは累積なので見逃しませんが、ゲージはスクレイプした瞬間の値だけが残ります。瞬間の最大値を知る必要があるなら、アプリケーションが最大値をカウンターやヒストグラムとして直接残す必要があります。
rate()はカウンターにだけ使います。ゲージに使っても意味がなく、逆にカウンターをそのままグラフにすると、上がり続ける線しか見えません。カウンターが再起動で0になるのは、rateが自動で補正します。
範囲ベクトルのウィンドウは、スクレイプ間隔の4倍以上にします。rate(x[1m])なのに間隔が30秒だと、点が2つしかなく値が不安定になり、1つでも見逃すと結果が空になってしまいます。[5m]程度が安全な既定値です。
ラベルが1つでも違えば、別の時系列です。そのため、ラベルを変えるデプロイは、グラフに途切れを作ります。古い時系列はその時点で終わり、新しい時系列が始まります。ダッシュボードで合計を見るときにsum by ()でまとめておくと、この途切れが目立たなくなります。
保存はローカルで、長く置くには別の仕組みが必要です。Prometheus自体は、長期保管と高可用性を目標にしていません。その役割は、リモートライトと別の長期ストレージが担います。
次の確認で見ること
このモジュールは概念モジュールなので、ラボはありません。クイズで3つのシグナルの境界とパーセンタイルの性質を確認したあと、次のモジュールでprometheus.ymlを自分で書きながら、「どのターゲットが残り、どのメトリクスが捨てられるのか」を手で決めることになります。