← назад к разделу

Когда сервис работает под нагрузкой, вопросы возникают конкретные: сколько запросов в секунду? сколько из них падает? сколько памяти занимает процесс? Логи отвечают на «что случилось», а метрики — на «как сейчас». Без метрик на эти вопросы приходится угадывать.

В Python экосистеме для этого используют prometheus-client и Prometheus — систему сбора и хранения временных рядов. FastAPI подключается одной строкой через prometheus-fastapi-instrumentator.

Что такое Prometheus и как он работает

Prometheus не ждёт, пока ему пришлют данные — он сам приходит и забирает. Каждые 15 секунд scraper делает GET /metrics к вашему сервису и получает текстовый список текущих значений. Это называется pull-модель.

Ваш сервис хранит метрики в памяти и отдаёт их по запросу. Библиотека prometheus-client берёт на себя и хранение, и форматирование ответа.

Подключение

Установите зависимости:

prometheus-client>=0.20
prometheus-fastapi-instrumentator>=7.0

Инициализация в main.py:

from fastapi import FastAPI
from prometheus_fastapi_instrumentator import Instrumentator

app = FastAPI()

Instrumentator(
    should_group_status_codes=True,
    should_ignore_untemplated=True,
    excluded_handlers=["/health/live", "/health/ready", "/metrics"],
).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)

После старта GET /metrics отдаёт текстовый список метрик. Prometheus scraper забирает его каждые 15 секунд.

should_group_status_codes=True группирует 200/201/204 в класс 2xx — это снижает количество уникальных комбинаций меток и уменьшает нагрузку на Prometheus.

Стандартные метки сервиса

Prometheus по-умолчанию знает только адрес, с которого взял данные. Чтобы различать сервисы, среды (prod/staging) и версии, добавляют identity-метки через Info:

import os
from prometheus_client import Info

SERVICE_INFO = Info("service", "Service identity labels")
SERVICE_INFO.info({
    "service": os.getenv("SERVICE_NAME", "order-service"),
    "env":     os.getenv("APP_ENV", "dev"),
    "version": os.getenv("BUILD_VERSION", "unknown"),
})

Это экспортирует одну метрику service_info{service="order-service",env="prod",version="1.4.2"} 1. Grafana подтягивает эти значения через group_left и добавляет к любому графику. Так не нужно дублировать эти метки на каждом счётчике.

RED для HTTP — автоматически

RED — три вопроса о состоянии HTTP-сервиса:

  • Rate — сколько запросов в секунду
  • Errors — какая доля заканчивается ошибкой
  • Duration — как долго обрабатывается запрос

Instrumentator собирает всё это без дополнительного кода и экспортирует:

http_requests_total{handler, method, status_code}
http_request_duration_seconds_bucket{handler, method, le}
http_request_size_bytes_bucket{handler, method, le}

Запросы в Prometheus для дашборда:

# Rate — запросы в секунду по эндпоинту
sum(rate(http_requests_total[5m])) by (handler, method)

# Errors — доля 5xx
sum(rate(http_requests_total{status_code="5xx"}[5m])) by (handler)
  /
sum(rate(http_requests_total[5m])) by (handler)

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

Если нужны конкретные границы для SLO (например, 100ms/500ms/1s):

from prometheus_fastapi_instrumentator import Instrumentator, metrics

Instrumentator().instrument(app).add(
    metrics.latency(buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0])
)

USE для ресурсов — тоже автоматически

USE — три вопроса о ресурсах:

  • Utilization — насколько загружен ресурс
  • Saturation — есть ли очередь
  • Errors — есть ли ошибки на уровне ресурса

prometheus-client экспортирует метрики процесса и GC без настройки:

МетрикаЧто показывает
process_resident_memory_bytesСколько памяти занимает процесс (RSS)
process_virtual_memory_bytesВиртуальная память
process_cpu_seconds_totalПотраченное процессорное время
python_gc_objects_collected_total{generation}Сборка мусора по поколениям
python_gc_collections_total{generation}Количество GC-циклов

Пример алертов на насыщение ресурсов:

groups:
  - name: order-service
    rules:
      - alert: MemoryHigh
        expr: >
          process_resident_memory_bytes{service="order-service"}
          > 400 * 1024 * 1024
        for: 10m
        labels:
          severity: warning

      - alert: CPUHigh
        expr: >
          rate(process_cpu_seconds_total{service="order-service"}[5m]) > 0.8
        for: 5m
        labels:
          severity: warning

Пулы соединений (asyncpg, Redis) метрики не экспортируют сами — их нужно инструментировать вручную через Gauge (об этом ниже).

Бизнес-метрики

HTTP и ресурсы — это инфраструктура. Бизнес-метрики отвечают на другой вопрос: что происходит в домене? Сколько заказов создано? Какое распределение сумм? Сколько корзин активно прямо сейчас?

Для этого в prometheus-client есть три типа объектов:

  • Counter — только растёт (количество событий, ошибок)
  • Histogram — распределение значений (суммы, длительности)
  • Gauge — текущее значение, может расти и падать (активные корзины, размер пула)

Выносите метрики в отдельный модуль:

# app/metrics/order_metrics.py
from prometheus_client import Counter, Histogram, Gauge

order_created_total = Counter(
    "order_created_total",
    "Total orders created",
    ["channel"],
)

order_amount_rubles = Histogram(
    "order_amount_rubles",
    "Order amount distribution",
    ["channel"],
    buckets=[100, 500, 1000, 5000, 10000, 50000],
)

payment_processing_seconds = Histogram(
    "payment_processing_seconds",
    "Payment processing duration",
    ["payment_method", "status_class"],
    buckets=[0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
)

cart_active_total = Gauge(
    "cart_active_total",
    "Currently active shopping carts",
)

Применение в обработчике:

from app.metrics.order_metrics import order_created_total, order_amount_rubles

class CreateOrderHandler:
    def __init__(self, order_repo: OrderRepository) -> None:
        self._order_repo = order_repo

    async def handle(self, command: CreateOrderCommand) -> Order:
        order = await self._order_repo.save(Order.create(command))

        order_created_total.labels(channel=command.channel).inc()
        order_amount_rubles.labels(channel=command.channel).observe(
            float(order.amount)
        )
        return order

Histogram.observe() автоматически раскладывает значение по bucket-ам и накапливает _sum/_count — из них Prometheus считает квантили без дополнительного кода.

Для замера времени удобен context manager Histogram.time() — или perf_counter вручную, если нужна метка по результату:

import time
from app.metrics.order_metrics import payment_processing_seconds

class ProcessPaymentHandler:
    async def handle(self, command: ProcessPaymentCommand) -> PaymentResult:
        start = time.perf_counter()
        try:
            result = await self._payment_client.charge(command)
            status = "success"
            return result
        except PaymentClientError:
            status = "client_error"
            raise
        except Exception:
            status = "server_error"
            raise
        finally:
            duration = time.perf_counter() - start
            payment_processing_seconds.labels(
                payment_method=command.payment_method,
                status_class=status,
            ).observe(duration)

Пример Gauge для пула asyncpg:

from prometheus_client import Gauge

db_pool_size = Gauge("db_pool_size", "asyncpg pool total connections")
db_pool_active = Gauge("db_pool_active", "asyncpg pool acquired connections")

async def update_pool_metrics(pool: asyncpg.Pool) -> None:
    db_pool_size.set(pool.get_size())
    db_pool_active.set(pool.get_size() - pool.get_idle_size())

Вызывать из фоновой задачи каждые N секунд — не из обработчика, иначе метрика обновляется только при трафике.

Как правильно называть метрики

Prometheus-конвенция: snake_case, с единицей измерения в имени.

order_created_total          # Counter — суффикс _total
payment_processing_seconds   # Histogram длительности — _seconds
order_amount_rubles          # Histogram суммы — единица в имени
cart_active_total            # Gauge — текущее количество

Частые ошибки:

orderCreatedCount            # camelCase — не принято
paymentTime                  # без единицы — непонятно, секунды? миллисекунды?
order_amount                 # нет единицы

Counter всегда заканчивается на _total. Histogram несёт единицу (_seconds, _bytes, _rubles) — Prometheus автоматически добавит _bucket, _sum, _count с правильными суффиксами.

Низкая cardinality меток — важно

Метки в Prometheus — это не просто текст. Каждая уникальная комбинация меток создаёт отдельный временной ряд в базе данных. Миллион уникальных значений = миллион рядов = Prometheus падает из-за нехватки памяти.

Правило: метка — это категория с небольшим числом значений, а не уникальный идентификатор.

# Хорошо — 3-10 значений на метку
order_created_total.labels(
    channel="web",           # web / mobile / api
).inc()

payment_processing_seconds.labels(
    payment_method="SBP",    # CARD / SBP / CRYPTO
    status_class="success",  # success / client_error / server_error
).observe(duration)

# Плохо — миллион временных рядов, OOM Prometheus
order_created_total.labels(
    user_id=str(command.user_id),    # миллионы значений
    order_id=str(order.id),          # миллионы значений
).inc()

Если нужно наблюдать за конкретными сущностями — это задача для трассировки, не для метрик. Метрики дают агрегированную картину, трейсы — детали конкретного запроса.

/metrics на отдельном порту

Эндпоинт /metrics не должен быть виден извне. В FastAPI его изолируют через отдельное ASGI-приложение на другом порту:

import uvicorn
import os
from prometheus_client import make_asgi_app

metrics_app = make_asgi_app()

async def run_metrics_server() -> None:
    config = uvicorn.Config(
        metrics_app,
        host="0.0.0.0",
        port=int(os.getenv("METRICS_PORT", "9090")),
    )
    server = uvicorn.Server(config)
    await server.serve()

Запуск параллельно с основным приложением:

import asyncio

async def main() -> None:
    await asyncio.gather(
        run_app(),
        run_metrics_server(),
    )

Порт 9090 закрывают на уровне Ingress-правил и открывают только для namespace мониторинга через NetworkPolicy.

Коротко

  • prometheus-client хранит метрики в памяти, Prometheus забирает их по pull-модели каждые 15 секунд.
  • prometheus-fastapi-instrumentator автоматически собирает RED-метрики (rate/errors/duration) по HTTP-эндпоинтам.
  • USE-метрики процесса (память, CPU, GC) экспортируются без настройки — process_* и python_gc_*.
  • Бизнес-события — Counter (растёт), Histogram (распределение), Gauge (текущее значение).
  • Identity-метки (service, env, version) выставляются один раз через Info, не дублируются на каждой метрике.
  • Имена метрик: snake_case + единица измерения (_seconds, _bytes, _total).
  • Метки — категории с малым числом значений. user_id как метка = взрыв временных рядов и падение Prometheus.
  • /metrics — на отдельном порту через make_asgi_app(), закрыт от внешнего трафика.

Что почитать дальше

  • Tracing в Python — OTel, ручные span'ы, per-entity наблюдаемость без взрыва cardinality.
  • Logging в Python — structlog, JSON в проде, контекстные поля.
  • SLO и алерты в Python — error budget, multi-window burn rate, runbook.
  • Health checks в Python — liveness vs readiness, кастомные проверки.