副題:正しく “計る・可視化する・アラートする” の設計と実装


TL;DR

  • Prometheusは、メトリクスを集め(Scrape)、PromQLで質問できるようにする基盤。アプリは /metrics を公開し、PrometheusがPullする。
  • メトリクスは Counter(カウンタ)/ Gauge(ゲージ)/ Histogram(ヒストグラム)/ Summary(サマリー) を正しく使い分ける。p95/p99レイテンシは Histogram(ヒストグラム)+****histogram_quantile が王道。
  • ラベル設計は低カーディナリティuser_id 等はNG)。命名は単位つき_seconds, _bytes)。
  • アラートはSLOに紐づける(例:5xx>1% を10分継続、p95>300ms を10分継続)。

この記事で得られること

  • Prometheusの役割とデータフローが分かる
  • メトリクスの設計原則(何を・どう測るか)が分かる
  • 主要言語(PHP/Laravel, Python/FastAPI, Node/Express)の最小実装が手元で動かせる
  • PromQLとAlertの最低限のひな形が手に入る

想定読者:Web/API開発者、SRE/インフラ担当、PoCから本番運用へ移行中のチーム


全体像(Metricsの立ち位置)

  • 観測性の3本柱:Metrics / Logs / Traces
  • 本記事の範囲:Metrics(数値の時系列)
  • 典型フロー:アプリ(計測)→ /metrics → Prometheus(Scrape/TSDB)→ Grafana(可視化/Alert)
  • Traces(Jaeger) とも相互補完:レイテンシが悪化→トレースでボトルネック特定

図の指示:アプリ → /metrics → Prometheus → Grafana の矢印。別枝で OTel Collector を挟む代替ルートも薄く描く。


Prometheusの役割(ざっくり)

  • Pull型でエンドポイント(/metrics)を定期スクレイプ
  • PromQLによる集計・関数(rate, increase, histogram_quantile 等)
  • Alerting(Alertmanager連携)。ルールはYAMLで管理

メトリクス設計の基本

1) まずSLO/SLIから逆算

  • 例(API):
    • 可用性:エラー率 < 1%(1日移動)
    • レイテンシ:p95 < 300ms(1時間移動)
    • トラフィック:RPS・帯域
    • 飽和度:CPU/メモリ/コネクション使用率

2) メトリクスタイプの使い分け

  • Counter(単調増加)…HTTPリクエスト件数、エラー件数
  • Gauge(上下する値)…同時実行数、キュー深さ、メモリ
  • Histogram(分布)…レイテンシ、ペイロードサイズ
  • Summary(分位点をローカル計算)…集約が難しいため分散環境では基本非推奨Histogram推奨

3) 命名規約と単位

  • snake_case単位サフィックス必須:_seconds, _bytes, _ratio, _total
  • ネームスペース例:app_http_requests_total / app_http_request_duration_seconds

4) ラベル設計(超重要)

  • 低カーディナリティに限定:method, route, status など有限集合
  • NGuser_id, request_id, timestamp, フリーテキスト
  • ルートはパスの実値でなくパターン/users/:id)を使う

5) バケット設計(Histogram)

  • Web APIの初期バケット例(秒):
    • [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5]
  • I/O重めAPIなら上限を広げる(10, 20)等。実測分布を見て再調整

実装:最小のインスツルメント

いずれも /metrics を公開し、PrometheusがScrapeします。

PHP(Laravelミドルウェア例:promphp/prometheus_client_php

実運用ではAPCu/Redisストレージによる永続化を推奨。InMemoryの場合、プロセスが再起動するとメモリ上に蓄積されていたメトリクスは消え、再起動前にPrometheusがPullしていなかった分のデータは失われる。以下はInMemoryの簡易例。

<?php
// app/Http/Middleware/MetricsMiddleware.php
use Closure;
use Prometheus\CollectorRegistry;
use Prometheus\RenderTextFormat;
use Prometheus\Storage\InMemory; // APCu/Redisに置換推奨

class MetricsMiddleware
{
    private CollectorRegistry $registry;
    public function __construct()
    {
        $this->registry = new CollectorRegistry(new InMemory());
    }
    public function handle($request, Closure $next)
    {
        $method = $request->getMethod();
        $route  = optional($request->route())->uri() ?? $request->path();
        $hist = $this->registry->getOrRegisterHistogram('app', 'http_request_duration_seconds', 'Request duration', ['method','route','status'], [0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2,5]);
        $counter = $this->registry->getOrRegisterCounter('app', 'http_requests_total', 'HTTP Requests', ['method','route','status']);

        $start = microtime(true);
        $response = $next($request);
        $status = (string) $response->getStatusCode();
        $duration = microtime(true) - $start;
        $hist->observe($duration, [$method, $route, $status]);
        $counter->inc([$method, $route, $status]);
        return $response;
    }
}
// routes/web.php
use Prometheus\RenderTextFormat;
Route::get('/metrics', function () {
    $registry = app(\Prometheus\CollectorRegistry::class);
    $renderer = new RenderTextFormat();
    return response($renderer->render($registry->getMetricFamilySamples()))
        ->header('Content-Type', RenderTextFormat::MIME_TYPE);
});

既存のLaravel用Exporterパッケージを使うのも可。ラベルに生の**path****を使わず、ルート名/パターンで束ねる**のがコツ。

Python(FastAPI)

# main.py
from fastapi import FastAPI, Request, Response
import time
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST

app = FastAPI()
REQS = Counter('http_requests_total', 'HTTP Requests', ['method','route','status'])
LAT  = Histogram('http_request_duration_seconds', 'Request duration', ['method','route','status'],
                 buckets=[0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2,5])

@app.middleware('http')
async def metrics_mw(request: Request, call_next):
    start = time.perf_counter()
    response = await call_next(request)
    dur = time.perf_counter() - start
    route = request.url.path  # 実運用は名前付きルートに寄せる
    REQS.labels(request.method, route, response.status_code).inc()
    LAT.labels(request.method, route, response.status_code).observe(dur)
    return response

@app.get('/metrics')
def metrics():
    return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)

Uvicornのマルチプロセス時はprometheus_multiproc_dir運用が必要。

Node.js(Express)

// app.js
const express = require('express');
const client = require('prom-client');
const app = express();
const register = new client.Registry();
client.collectDefaultMetrics({ register });

const reqCounter = new client.Counter({
  name: 'http_requests_total', help: 'HTTP Requests', labelNames: ['method','route','status']
});
const reqDuration = new client.Histogram({
  name: 'http_request_duration_seconds', help: 'Request duration',
  labelNames: ['method','route','status'],
  buckets: [0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2,5]
});
register.registerMetric(reqCounter);
register.registerMetric(reqDuration);

app.use((req, res, next) => {
  const end = reqDuration.startTimer({ method: req.method, route: req.path });
  res.on('finish', () => {
    reqCounter.inc({ method: req.method, route: req.path, status: res.statusCode });
    end({ status: res.statusCode });
  });
  next();
});

app.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});

app.listen(8080);

Prometheus設定(最小)

prometheus.yml

global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'api'
    static_configs:
      - targets: ['api:8080']  # Docker Composeのサービス名 or host:port

Scrapeできないとき:FW/ネットワーク、/metrics のContent-Type、Dockerの名前解決(host.docker.internal など)を確認。


PromQLとは(超要約)

  • PromQL = Prometheus Query Language。Prometheusに保存された時系列データ(メトリクス)を選ぶ・絞る・変換する・集計するための言語。
  • 基本の流れは ①メトリクス名を決める → ②ラベルで絞る → ③時間範囲を取る([5m] など)→ ④関数で変換(rate/increase)→ ⑤集約(sum by (...)
  • 主な構成要素
    • ラベル選択http_requests_total{method="GET", status=~"5.."}
    • レンジ選択[1m], [5m](この形はrange vector
    • 変換関数rate(), increase(), avg_over_time() など
    • 集約sum by (route)(...), max by (instance)(...)
    • 分位点histogram_quantile(0.95, ...)(Histogram用)

例:RPS sum by (method, route) (rate(http_requests_total[1m]))

例:5xxエラー率 sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))


PromQL チートシート(最低限)

RPS(1分レート)

sum by (method, route) (rate(http_requests_total[1m]))

5xxエラー率(5分)

sum(rate(http_requests_total{status=~"5.."}[5m]))
  /
sum(rate(http_requests_total[5m]))

p95レイテンシ(全体)

histogram_quantile(0.95,
  sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
)

ルート別を見るなら by (le, route) で集計し、必要ならrouteでフィルタ。

同時実行数(Gauge例)

sum(http_inprogress_requests)

アラート例(Alertmanager連携)

groups:
- name: api.alerts
  rules:
  - alert: HighErrorRate
    expr: |
      sum(rate(http_requests_total{status=~"5.."}[5m]))
      /
      sum(rate(http_requests_total[5m])) > 0.01
    for: 10m
    labels:
      severity: page
    annotations:
      summary: "5xx error rate > 1% (10m)"

  - alert: HighLatencyP95
    expr: |
      histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) > 0.3
    for: 10m
    labels:
      severity: ticket
    annotations:
      summary: "p95 latency > 300ms (10m)"

よくあるハマり

  • ラベル爆発user_id 等でカード数が増えるとメモリ圧迫&遅い
  • Histogramを増やし過ぎ:バケット×ラベルの組み合わせが掛け算で膨らむ
  • ルートの正規化不足:実パス(/users/123)で分断
  • Scrape失敗:CORSや認証を挟まない(Promは内部ネットワークから直接アクセス)
  • Pythonマルチプロセスprometheus_multiproc_dir 未設定

付録:最小Docker Compose(任意)

version: "3.9"
services:
  api:
    build: ./api
    ports: ["8080:8080"]
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
    ports: ["9090:9090"]
    depends_on: [api]

次のステップ

  • Jaeger編(Traces):遅いリクエストのボトルネックをSpanで可視化
  • Grafana編(可視化/Alert):ダッシュボード作法とSLO運用

執筆メモ(後で削除してOK)

可能なら Recording Rulesエラーバジェット の節を追記

図を1枚:データフロー(アプリ→Prometheus→Grafana/Jaeger)

実測に応じてHistogramバケットを調整した事例(Before/After)を入れる

投稿者 kojiro777

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です