時刻を値として扱う — timestamp()、導関数、計装 API、スパン
一言でいうと
timestamp()はサンプルの時刻を、time()は評価時刻を値として取り出すので、「いつから」を引き算で計算できます。deriv()とpredict_linear()はゲージの傾きでトレンドを読みます。クライアントライブラリはCounter・Gauge・Histogramをレジストリに登録して/metricsで公開します。トレースはtrace_idを共有し、parent_idでつながったスパンのツリーです。
なぜ必要なのか
「このプロセスが最後に再起動したのはいつか」「デプロイしてからどれくらい経つか」「ディスクはいつ埋まるか」は、どれも時刻を計算しないと答えが出ません。Prometheusの計装指針は、ここで1つの原則を立てています。経過時間ではなく、イベントが起きたUnix時刻を出力するということです。「最後の成功からN秒」をアプリケーションが自分で更新すると、更新ロジックが止まったときに値も止まります。時刻を出力すれば、time() - my_timestamp_metricでいつでも正しい経過時間が求まり、更新ロジックが止まる問題から抜け出せます。
PCAのPromQLドメイン(28%)には「timestamp metrics」の項目が、Instrumentationドメイン(16%)には「client libraries」が、Observability Concepts(18%)には「tracing and spans」があります。3つとも前のモジュールでは簡単に触れただけなので、ここでまとめて整理します。
どう動くのか
time()、timestamp()、そして開始時刻のメトリクス
time()は1970-01-01 UTCからの秒数を返しますが、ドキュメントはこれが現在時刻ではなく、式が評価される時刻だと強調しています。過去の区間をグラフに描くときは、各点にその点の時刻が入るという意味です。timestamp(v)はインスタントベクトルの各サンプルが記録された時刻を秒で返し、floatサンプルとヒストグラムサンプルを同じ方法で扱います。
クライアントライブラリが標準で出力するprocess_start_time_secondsは、「プロセスが開始したUnix時刻(秒)」です。このメトリクス1つで再起動を検出できます。
# 지금 기준으로 프로세스가 동작한 시간(초)
time() - process_start_time_seconds
# 최근 1시간 안에 시작 시각이 바뀐(=재시작한) 인스턴스
changes(process_start_time_seconds[1h]) > 0
# 스크레이프가 얼마나 오래됐나 — 샘플 시각과 평가 시각의 차이
time() - timestamp(up)
changes()は範囲内で値が変わった回数を数えるので、開始時刻が変わると再起動として読めます。カウンターであれば、resets()が減少した回数を数えて、同じ目的に使えます。最後の式は、スタレネス(staleness)を理解していないと読めません。クエリ時刻は実際のサンプルとは無関係に決まり、Prometheusは各時系列についてlookback期間(デフォルト5分、--query.lookback-deltaで調整)内で最も新しいサンプルを、その時刻の値として使います。ターゲットが消えると、時系列はすぐにstaleとして記録され、結果から外れます。そのため、timestamp()で見たサンプル時刻が評価時刻より数分遅れていれば、スクレイプが遅延しているというサインです。
deriv()とpredict_linear() — ゲージ専用の導関数
rate()がカウンターの毎秒の増加率だとすれば、deriv(v range-vector)は単純線形回帰で求めた毎秒の導関数で、predict_linear(v, t)は同じ回帰でt秒後の値を予測します。どちらの関数も、ドキュメントに「ゲージにのみ使用すること、floatサンプルでのみ動作する」と書かれており、範囲内にfloatサンプルが2つ以上ないと計算されません。+Infや-Infが混ざると、結果はNaNです。ゲージの差が必要なときはdelta()を使いますが、これは最初の値と最後の値の差を範囲全体に外挿するので、整数のサンプルでも小数が出ることがあります。計装指針は、ゲージにrate()を使わないよう明記しています。
# 4시간 추세로 볼 때 6시간 뒤 남은 디스크가 0 이하가 되는가
predict_linear(node_filesystem_avail_bytes{mountpoint="/data"}[4h], 6 * 3600) < 0
クライアントライブラリ — 登録し、公開し、スクレイプのときに読まれる
公式ライブラリはGo、Java/Scala、Node.js、Python、Ruby、Rustで、ライブラリはスクレイプ時点で追跡中のすべてのメトリクスの現在の状態を出力します。値を押し出して送るのではなく、読み取られていく構造です。4つの主要なタイプのうち、Counterは増えるだけで再起動すると0に戻り(減ることのある値には使わないでください)、Gaugeは増減し、Histogramは観測値をバケットに数えて、_bucket{le}、_sum、_countの3つの時系列(クラシックヒストグラムの場合)として公開します。
from prometheus_client import Counter, Histogram, start_http_server
REQS = Counter("requests_total", "Total requests",
labelnames=["method"], namespace="myapp")
LAT = Histogram("request_duration_seconds", "HTTP request latency",
labelnames=["method", "endpoint"], namespace="myapp",
buckets=[.01, .05, .1, .25, .5, 1, 2.5, 5])
def handle(method, endpoint):
REQS.labels(method=method).inc()
with LAT.labels(method=method, endpoint=endpoint).time():
...
start_http_server(8000) # /metrics 노출
Pythonでは、Counterは名前の末尾の_totalをいったん取り除き、公開するときにもう一度付けます(OpenMetricsが_totalを要求するためです)。namespace・subsystem・nameはアンダースコアでつながって完全な名前になり、registryはデフォルトでREGISTRYで、Noneを渡すと登録しません(テストコード用)。Histogramのleは予約されたラベルなのでラベル名には使えず、bucketsは昇順でなければならず、+Infは常に自動的に付きます。デフォルトのバケットは.005 .01 .025 .05 .075 .1 .25 .5 .75 1 2.5 5 7.5 10です。
Goでは、prometheus.NewRegistry()でレジストリを作り、reg.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(...))でランタイム・プロセスのコレクターを登録します。そのあと、promauto.With(reg).NewCounter(prometheus.CounterOpts{Name: ..., Help: ...})でメトリクスを作り、promhttp.HandlerFor(reg, ...)を/metricsに設定します。HistogramOptsのBucketsを空にするとDefBuckets(.005 .01 .025 .05 .1 .25 .5 1 2.5 5 10)が使われますが、ドキュメントは、これがネットワークサービスの応答時間を広く測るように合わせた値なので、ほとんどの場合は自分の用途に合ったバケットを定義する必要があると述べています。
バケットの設計
クラシックヒストグラムでは、バケットは計装の時点で固定され、バケットごとに時系列が1つずつできます(空であっても)。あとから変えると、異なるレイアウト同士で集計できなくなり、大きな混乱が生じます。指針は、想定する値の範囲と実行したいクエリに合わせてバケットを選ぶよう勧めています。たとえば「リクエストの95%を300ms以内に」というSLOがあるなら、0.3に境界を置いてこそ、_bucket{le="0.3"}で正確な割合が求まります。分位数はhistogram_quantile(0.95, sum by (le) (rate(x_bucket[5m])))でサーバー側で計算し、推定誤差は分位数が属するバケットの幅の範囲内に収まります。ネイティブヒストグラム(Go・Javaが対応)は、バケットを選ばずに解像度だけを決めるので、ドキュメントは可能ならそちらを勧めています。もう1つの指針は、存在しないメトリクスを作らないことです。イベントが起きるまで時系列がないとクエリが難しくなるため、0を先に出力しておきます。ラベルのないメトリクスは、ほとんどのライブラリが自動的に0を出力します。
トレースとスパン
トレースはリクエストがアプリケーションを通過した経路で、スパンはその中の作業単位です。スパンには、名前、親スパンID(ルートは空)、開始・終了時刻、スパンコンテキスト(trace ID、span ID、trace flags、trace state)、属性(attributes)、イベント、リンク、ステータスが入ります。同じトレースのスパンは同じtrace_idを共有し、子のparent_idは親のspan_idと同じです。この2つのフィールドだけでツリーが作られます。OpenTelemetryのドキュメントは、スパンを「文脈・相関・階層を持つ構造化ログ」にたとえています。
サービスの境界を越えるときは、コンテキスト伝播(context propagation)が必要です。デフォルトの伝播方式はW3C TraceContextで、traceparentヘッダーに<version>-<trace-id>-<parent-id>-<trace-flags>の形式(例: 00-a0892f3577b34da6a3ce929d0e0e4736-f03067aa0ba902b7-01)で入れて送り、受け取った側がこれを取り出して新しいスパンの親にします。伝播は通常、計装ライブラリが自動で行います。メトリクスとトレースをつなぐ結び目がexemplarです。Pythonのobserve(0.43, exemplar={"trace_id": "..."})のように、観測値にtrace_idを付けられ、OpenMetrics形式でのみ公開されます。
現場での姿
「メモリリークのアラートが毎朝の未明に届くのに、朝には正常になっている」ということがあります。predict_linearを30分の範囲で設定していると、夜間バッチが動く30分間の傾きだけで数時間後を外挿してしまいます。範囲をバッチの周期より長く取るか、forの継続時間を設けるのが、指針に沿った対応です。
再起動の検出では、changes(process_start_time_seconds[1h])の代わりにup == 0を使って、見逃すことがあります。再起動がスクレイプ間隔の中で終わると、upは一度も0になりません。開始時刻のメトリクスはプロセスが自分で出力する値なので、短い再起動でも値が変わって残ります。
次のクイズで確認すること
time()が返す時刻の正確な意味、timestamp()と開始時刻のメトリクスで作る式、lookbackのデフォルト値、deriv/predict_linearが要求する条件、Python・Goの計装APIの登録方法とデフォルトのバケット、バケットの設計指針、スパンの必須要素とtraceparentヘッダーの形式を問います。参考: PromQL Functions, Querying basics — Staleness, Instrumentation practices, Histograms and summaries, client_python Histogram, Instrumenting a Go application, OpenTelemetry Traces, Context propagation。