副題:正しく “計る・可視化する・アラートする” の設計と実装
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
など有限集合 - NG:
user_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)を入れる