Метрики с Prometheus

Метрики с Prometheus

Метрики - это числовые временные ряды о состоянии системы: запросы, задержки, очереди, использование памяти. В отличие от логов, они компактны, агрегируются дёшево и идеальны для алертинга.

Pull vs push

Prometheus использует pull-модель: его сервер периодически опрашивает endpoint /metrics приложения. Альтернатива - push (StatsD, Graphite), где приложение само шлёт метрики. У pull-модели плюс: Prometheus сам видит, что таргет упал (scrape failure → метрика up=0).

Типы метрик

Counter, gauge, histogram, summary - четыре типа метрик Prometheus с примерами графиков

Counter - только растёт

import "github.com/prometheus/client_golang/prometheus"

var httpRequestsTotal = prometheus.NewCounterVec(
    prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total HTTP requests",
    },
    []string{"method", "path", "status"},
)

httpRequestsTotal.WithLabelValues("GET", "/users", "200").Inc()
<?php
declare(strict_types=1);

use Prometheus\CollectorRegistry;
use Prometheus\Storage\Redis;

$registry = new CollectorRegistry(new Redis(['host' => 'redis', 'port' => 6379]));

$counter = $registry->getOrRegisterCounter(
    namespace: 'myapp',
    name: 'http_requests_total',
    help: 'Total HTTP requests',
    labels: ['method', 'path', 'status'],
);

$counter->inc(['GET', '/users', '200']);

В PHP индустриальный клиент - promphp/prometheus_client_php. У него четыре storage-адаптера: APCu (один FPM-инстанс), Redis (кластер FPM-воркеров), InMemory (для CLI/тестов).

В отличие от Go, где Counter живёт в памяти процесса, в PHP-FPM каждый запрос - новый процесс. Поэтому нужен внешний storage (Redis/APCu), чтобы разные воркеры писали в один временной ряд.

Gauge - растёт и падает

var activeConns = prometheus.NewGauge(
    prometheus.GaugeOpts{
        Name: "active_connections",
        Help: "Active connections",
    },
)

activeConns.Inc()
activeConns.Dec()
activeConns.Set(42) // абсолютное значение
<?php
declare(strict_types=1);

$gauge = $registry->getOrRegisterGauge(
    namespace: 'myapp',
    name: 'active_connections',
    help: 'Active connections',
);

$gauge->inc();
$gauge->dec();
$gauge->set(42);

Подходит для текущих значений: число подключений, температура, размер очереди, использование памяти. Можно идти вверх и вниз.

Histogram - распределение

var requestDuration = prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
    },
    []string{"method", "path"},
)

requestDuration.WithLabelValues("GET", "/users").Observe(0.042)
<?php
declare(strict_types=1);

$histogram = $registry->getOrRegisterHistogram(
    namespace: 'myapp',
    name: 'http_request_duration_seconds',
    help: 'HTTP request duration',
    labels: ['method', 'path'],
    buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
);

$histogram->observe(0.042, ['GET', '/users']);

Histogram записывает наблюдение в bucket: «сколько запросов было быстрее N секунд». Из buckets вычисляется p50, p95, p99 через histogram_quantile. Главное - правильно подобрать buckets под ожидаемые значения; default-ы Prometheus не всегда подходят.

Summary - квантили на клиенте

Похож на histogram, но квантили считает сам клиент. Минус: нельзя агрегировать по multiple instances. В большинстве случаев предпочитайте histogram.

Naming conventions

Хорошие имена сэкономят часы в будущем:

  • <namespace>_<subsystem>_<name>_<unit> - например, myapp_http_request_duration_seconds
  • Базовые единицы: seconds, bytes, total (для counters)
  • Counter всегда заканчивается на _total
  • Не используй _count или _sum - это автоматические суффиксы histogram
prometheus.NewCounter(prometheus.CounterOpts{
    Namespace: "myapp",
    Subsystem: "auth",
    Name:      "login_failures_total",
})
// → myapp_auth_login_failures_total
<?php
declare(strict_types=1);

$registry->getOrRegisterCounter(
    namespace: 'myapp_auth',
    name: 'login_failures_total',
    help: 'Failed login attempts',
);
// → myapp_auth_login_failures_total

В promphp/prometheus_client_php нет отдельного поля subsystem - его кладут в namespace через подчёркивание.

/metrics endpoint

import "github.com/prometheus/client_golang/prometheus/promhttp"

http.Handle("/metrics", promhttp.Handler())
<?php
declare(strict_types=1);

use Prometheus\RenderTextFormat;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class MetricsController
{
    public function __construct(private readonly \Prometheus\CollectorRegistry $registry) {}

    #[Route('/metrics', name: 'app_metrics', methods: ['GET'])]
    public function __invoke(): Response
    {
        $renderer = new RenderTextFormat();
        return new Response(
            $renderer->render($this->registry->getMetricFamilySamples()),
            Response::HTTP_OK,
            ['Content-Type' => RenderTextFormat::MIME_TYPE],
        );
    }
}

В PHP endpoint /metrics - обычный Symfony Route, который рендерит registry через RenderTextFormat. Промышленный вариант - бандл artprima/prometheus-metrics-bundle (автоматически собирает метрики Symfony Kernel + предоставляет route).

Prometheus client_golang автоматически экспортирует Go-runtime метрики: go_goroutines, go_memstats_heap_alloc_bytes, process_cpu_seconds_total. PHP-аналога нет - runtime однопоточный per-request. Для FPM можно собирать метрики самого FPM (status page) через отдельный exporter hipages/php-fpm_exporter.

Docker Compose

services:
  prometheus:
    image: prom/prometheus:latest
    ports: ["9090:9090"]
    volumes:
 - ./prometheus.yml:/etc/prometheus/prometheus.yml
  grafana:
    image: grafana/grafana:latest
    ports: ["3000:3000"]
    environment:
 - GF_SECURITY_ADMIN_PASSWORD=admin
# prometheus.yml
global:
  scrape_interval: 15s
scrape_configs:
 - job_name: 'myapp'
    static_configs:
 - targets: ['app:8080']

PromQL: основы

# RPS за 5 минут
rate(http_requests_total[5m])

# Процент ошибок
sum(rate(http_requests_total{status=~"5.."}[5m]))
  / sum(rate(http_requests_total[5m]))

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

# Топ-5 endpoint-ов по нагрузке
topk(5, sum by (path) (rate(http_requests_total[5m])))

rate для Counter, irate для гранулярных всплесков, increase для абсолютного прироста, delta для Gauge.

RED и USE - что измерять

RED для сервисов и USE для ресурсов: какие 4 сигнала покрывают 90 процентов инцидентов

RED для сервисов:

  • Rate - запросов/сек
  • Errors - процент ошибок
  • Duration - латентность p50/p95/p99

USE для ресурсов:

  • Utilization - насколько загружено (%)
  • Saturation - есть ли очередь (load average, queue length)
  • Errors - счётчик ошибок ресурса (диск bad sectors)

Для большинства бэкендов RED + Go-runtime метрики покрывают 90% инцидентов.

Cardinality - главная ловушка

Каждая комбинация labels = отдельный временной ряд. path с user_id даёт миллионы рядов и убивает Prometheus.

// ПЛОХО: path с ID пользователя
httpRequestsTotal.WithLabelValues("GET", "/users/12345", "200").Inc()

// ХОРОШО: path-шаблон
httpRequestsTotal.WithLabelValues("GET", "/users/:id", "200").Inc()
<?php
declare(strict_types=1);

// ПЛОХО: path с ID
$counter->inc(['GET', '/users/12345', '200']);

// ХОРОШО: имя роута из Symfony Router
$routeName = $request->attributes->get('_route') ?? 'unknown';
$counter->inc([$request->getMethod(), $routeName, (string) $response->getStatusCode()]);

В Symfony имя роута ($request->attributes->get('_route')) - идеальный label с ограниченной cardinality. RouterListener гарантирует, что туда не попадут динамические сегменты.

Правило: labels должны иметь ограниченный набор значений (методы, статусы, имена endpoint-ов). Никогда не клади туда user_id, request_id, query parameters.

Exemplars: связь метрики и trace

Histogram говорит «p99 latency 1.2s» - но какой именно запрос был медленным? Exemplar - это указатель из метрики на конкретный trace. На графике в Grafana вы видите всплеск p99, кликаете на точку - открывается trace в Jaeger.

import "github.com/prometheus/client_golang/prometheus"

requestDuration := prometheus.NewHistogramVec(
    prometheus.HistogramOpts{
        Name:    "http_request_duration_seconds",
        Buckets: []float64{0.1, 0.25, 0.5, 1, 2.5, 5},
    },
    []string{"method", "path"},
)

// Observe с exemplar - trace_id попадёт в TSDB вместе с наблюдением
observer := requestDuration.WithLabelValues("GET", "/users")
if e, ok := observer.(prometheus.ExemplarObserver); ok {
    spanCtx := trace.SpanFromContext(ctx).SpanContext()
    e.ObserveWithExemplar(elapsed.Seconds(), prometheus.Labels{
        "trace_id": spanCtx.TraceID().String(),
    })
} else {
    observer.Observe(elapsed.Seconds())
}
<?php
declare(strict_types=1);

use OpenTelemetry\API\Metrics\Meter;
use OpenTelemetry\API\Trace\Span;

final readonly class HttpMetrics
{
    public function __construct(private \OpenTelemetry\API\Metrics\HistogramInterface $duration) {}

    public function record(string $method, string $route, float $seconds): void
    {
        // OTel SDK сам прикрепит trace_id текущего span к замеру
        $this->duration->record($seconds, [
            'http.method' => $method,
            'http.route' => $route,
        ]);
    }
}

В promphp/prometheus_client_php нативной поддержки exemplars пока нет. Обходное решение - собирать пары (latency, trace_id) в отдельный буфер и отдавать их в OpenMetrics-формате через кастомный renderer. На практике в PHP exemplars чаще включают на стороне OpenTelemetry Collector: собирай метрики через OTLP exporter из SDK PHP, и Collector сам прокинет exemplars в Prometheus remote_write.

Чтобы exemplars сохранились, Prometheus должен запускаться с --enable-feature=exemplar-storage, а scrape - поддерживать формат OpenMetrics (Accept: application/openmetrics-text). В Grafana включается опция «show exemplars» на панели - точки появляются прямо на графике.

Recording rules: предвычисленные агрегаты

Дорогие запросы (sum-by-label с histogram_quantile по миллионам рядов) на каждом обновлении дашборда тормозят Prometheus и Grafana. Recording rule вычисляет результат заранее и записывает в новую метрику - дашборды и алерты используют её.

# prometheus-rules.yml
groups:
 - name: api-aggregates
    interval: 30s
    rules:
 - record: job:http_requests:rate5m
        expr: sum by (job, status) (rate(http_requests_total[5m]))

 - record: job:http_request_duration_seconds:p95
        expr: |
          histogram_quantile(0.95,
            sum by (job, le) (rate(http_request_duration_seconds_bucket[5m]))
          )

 - record: job:error_rate:ratio5m
        expr: |
          sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
          /
          sum by (job) (rate(http_requests_total[5m]))

Соглашение по именованию: level:metric:operation - job:http_requests:rate5m означает «агрегат по job, исходник http_requests, операция rate за 5 минут». В Grafana запрос становится тривиальным: job:error_rate:ratio5m{job="api"} - никаких 50-строчных PromQL.

Выбор bucket-границ под SLO

Histogram квантили вычисляются интерполяцией внутри bucket. Если SLO «p99 < 200ms», но buckets 0.5, 1, 2.5 - все наблюдения попадут в первый bucket, и histogram_quantile(0.99, ...) вернёт что-то близкое к 0 или к 0.5 в зависимости от интерполяции. Бесполезно.

Правильные buckets группируются вокруг SLO-порога:

// SLO: p99 < 200ms
Buckets: []float64{0.025, 0.05, 0.1, 0.15, 0.2, 0.3, 0.5, 1, 2.5}
//                                          ^^^ порог SLO

// SLO для batch-задач: 30 секунд
Buckets: prometheus.ExponentialBuckets(0.5, 2, 8) // 0.5, 1, 2, 4, 8, 16, 32, 64
<?php
declare(strict_types=1);

// SLO: p99 < 200ms - бакеты группируются вокруг порога
$slo200msBuckets = [0.025, 0.05, 0.1, 0.15, 0.2, 0.3, 0.5, 1, 2.5];

// Экспоненциальные бакеты для batch-задач (start=0.5s, factor=2, count=8)
$batchBuckets = array_map(
    static fn (int $i): float => 0.5 * (2 ** $i),
    range(0, 7),
); // [0.5, 1, 2, 4, 8, 16, 32, 64]

$registry->getOrRegisterHistogram(
    namespace: 'myapp',
    name: 'http_request_duration_seconds',
    help: 'HTTP request duration',
    labels: ['method', 'route'],
    buckets: $slo200msBuckets,
);

prometheus.DefBuckets (0.005-10s) подходит «для всего и ни для чего». Меняйте под свой профиль латентности. После изменения buckets старые ряды перестают быть совместимыми - Prometheus сохраняет историю по старым границам, новые данные пишутся по новым. Это редкий, но осознанный решение.

Federation и remote_write: долгоживущее хранение

Prometheus по умолчанию хранит ~15 дней локально. Для длительных трендов и multi-cluster нужно одно из:

# Federation: master опрашивает другие Prometheus
scrape_configs:
 - job_name: federate-prod
    honor_labels: true
    metrics_path: /federate
    params:
      'match[]':
 - '{__name__=~"job:.+"}' # только recording rules
    static_configs:
 - targets: ['prom-eu:9090', 'prom-us:9090']
# remote_write: отправка в долговременное хранилище
remote_write:
 - url: https://mimir.example.com/api/v1/push
    queue_config:
      max_samples_per_send: 10000
      capacity: 100000

Federation подходит для агрегации precomputed-метрик из shard-ов. Не пересылайте через federation сырые high-cardinality ряды - это убьёт master. remote_write шлёт данные в Thanos, Cortex, Mimir, Grafana Cloud - там хранение год+, dedup между HA-парами Prometheus, downsample для старых данных.

Scrape failures как алерт первой важности

Если Prometheus не смог опросить таргет, метрики приложения за этот интервал отсутствуют. Без сигнала об этом вы будете думать, что «всё хорошо», глядя на пустой график.

- alert: TargetDown
  expr: up{job=~"api|worker"} == 0
  for: 2m
  labels:
    severity: critical
  annotations:
    summary: "Target {{ $labels.instance }} of job {{ $labels.job }} is down"

- alert: ScrapeSlow
  expr: scrape_duration_seconds > 10
  for: 5m
  labels:
    severity: warning

up - синтетическая метрика, которую Prometheus сам пишет на каждый scrape: 1 = успешно, 0 = таргет недоступен. scrape_duration_seconds показывает, сколько занял scrape - если приближается к scrape_interval, скоро начнут пропускаться scrape-ы. Эти две метрики - первое, на что настраивают алерты.

Мини-практика

Добавь Prometheus метрики к HTTP-серверу: counter requests, histogram durations с buckets 0.01-10 секунд, gauge активных соединений. Запусти Prometheus + Grafana через Docker Compose, построй дашборд с RPS, error rate и p95 latency.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.