CI/CD, инфраструктура и облака Наблюдаемость и дежурства: метрики, логи, трейсы, алерты, SLO и постмортемы
0%

Наблюдаемость и дежурства: метрики, логи, трейсы, алерты, SLO и постмортемы

Наблюдаемость и дежурства: метрики, логи, трейсы, алерты, SLO и постмортемы

Всё, что мы строили в предыдущих статьях трека, имеет одно неприятное свойство: оно работает до тех пор, пока не перестаёт. Пайплайн из https://courses.digitable.life/post/devops/01-ci-fundamentals/ собрал артефакт, стратегия из https://courses.digitable.life/post/devops/04-cd-and-release-strategies/ выкатила его канареечно, Kubernetes из https://courses.digitable.life/post/devops/06-kubernetes/ расселил поды по нодам. В 3:47 ночи телефон звонит. Дальше начинается дисциплина, о которой эта статья.

Здесь два тесно связанных сюжета. Первый — техника: какие данные система о себе излучает, как их собрать, сколько это стоит и как по ним найти причину. Второй — социотехника: кто просыпается, при каком условии, что он делает в первые пять минут и что происходит с организацией после того, как всё починили. Второй сюжет важнее. Идеальный Grafana-дашборд в компании, где алерты игнорируют, потому что их четыреста в день, стоит ровно ноль.

Мониторинг против наблюдаемости: чем отличается вопрос

Различие затаскали до бессмысленности, но оно настоящее, и его проще всего сформулировать через множество вопросов, на которые система умеет отвечать.

Мониторинг отвечает на заранее заданные вопросы. Вы решили, что важна доля 5xx, повесили график и порог. Система знает, что 5xx выросли. Это работает, пока список отказов конечен и известен: диск заполнился, процесс упал, очередь выросла. Классический мониторинг — это про known unknowns: вы знаете, что диск может заполниться, не знаете когда.

Наблюдаемость (термин пришёл из теории управления, у Калмана: система наблюдаема, если по её выходам можно восстановить внутреннее состояние) отвечает на вопросы, которые вы не задавали заранее. «Почему у пользователей из Казахстана, вошедших через OAuth, на тарифе Pro, после релиза 4.19, checkout занимает 8 секунд, а у остальных 200 мс?» Такой вопрос невозможно предусмотреть — комбинаций слишком много. Unknown unknowns.

Практическое следствие ровно одно, и оно про кардинальность и связность данных. Чтобы отвечать на непредусмотренные вопросы, нужны сигналы с высокой кардинальностью (user_id, build_id, region, plan) и возможность переходить между сигналами: с графика на логи, с лога — на трейс, с трейса — на профиль CPU. Система, где логи, метрики и трейсы живут в трёх несвязанных интерфейсах без общего trace_id, не наблюдаема, сколько бы вы за неё ни платили.

Обратная сторона: высокая кардинальность стоит денег, и здесь лежит главная инженерная ошибка отрасли.

Сигналы: что это физически и сколько стоит

Каноническая тройка — метрики, логи, трейсы — на практике дополняется профилями (continuous profiling) и событиями (деплои, миграции, feature-флаги). Разница между ними не философская, а структурная: как данные агрегируются при записи.

Метрики Логи Трейсы Профили
Что это числовой ряд, агрегированный при записи дискретное событие с полями дерево спанов одного запроса стек-семплы по CPU/памяти
Кардинальность низкая (жёсткое ограничение) высокая высокая средняя
Стоимость на запрос ~0 (амортизируется) O(объёма) O(объёма) × sampling ~1 % CPU
Отвечает на вопрос «плохо ли сейчас» «что именно произошло» «где именно время/ошибка» «почему CPU/память»
Задержка до графика 15–60 с 5–30 с 10–60 с минуты
Годится для алертов да редко (дорого и шумно) нет нет
Типичный объём 100 МБ/день на 10к серий 10–500 ГБ/день 5–50 ГБ/день 1–5 ГБ/день

Ключевая строка — «годится для алертов». Метрики агрегируются на стороне приложения, поэтому запрос rate(http_requests_total{code=~"5.."}[5m]) стоит одинаково при 100 и при 100 000 RPS. Алерт на логах («если строк с ERROR больше 100») требует просканировать весь поток — это стоит пропорционально трафику и ломается ровно в тот момент, когда трафик аномален, то есть во время инцидента. Алертим на метриках, расследуем логами и трейсами — это не стилистика, это про то, что не развалится под нагрузкой.

Кардинальность: главный источник аварий в самом мониторинге

Prometheus и все TSDB на его модели хранят один временной ряд на каждую уникальную комбинацию имени метрики и меток. Ряд — это индекс, чанки в памяти, запись в WAL. Стоимость — примерно 3–8 КБ RAM на активную серию плюс ~1,3–2 байта на сэмпл в сжатом виде на диске.

Взрыв кардинальности: метки перемножаются в число временных рядов

Арифметика простая и беспощадная: число серий равно произведению мощностей меток. Добавление метки user_id не увеличивает нагрузку «немного» — оно умножает её на число пользователей. Классический сценарий смерти: инженер добавляет pod в метку прикладной метрики, деплой перекатывает 60 подов в день, ретеншен 15 дней — и число серий растёт линейно во времени, пока Prometheus не съест всю память и не откажется стартовать (загрузка WAL после OOM сама требует памяти, получается петля).

Защита ставится заранее, на трёх уровнях:

# prometheus.yml — жёсткие лимиты, чтобы один плохой сервис не унёс весь мониторинг
global:
  scrape_interval: 30s          # 15 с только там, где реально нужно
  scrape_timeout: 10s
  external_labels:
    cluster: prod-eu
    region: eu-central-1

scrape_configs:
  - job_name: kubernetes-pods
    kubernetes_sd_configs: [{ role: pod }]
    # лимиты на цель: цель просто помечается как failed, а не убивает Prometheus
    sample_limit: 5000          # больше 5000 серий с одного пода — цель отбрасывается
    label_limit: 30
    label_value_length_limit: 200
    relabel_configs:
      # скрейпим только явно размеченные поды
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
        action: keep
        regex: "true"
      - source_labels: [__meta_kubernetes_pod_label_app]
        target_label: app
    metric_relabel_configs:
      # выбрасываем заведомо взрывоопасные метки уже после скрейпа
      - regex: "(user_id|session_id|request_id|email|order_id)"
        action: labeldrop
      # и целые метрики, которые никто не смотрит, но которые стоят памяти
      - source_labels: [__name__]
        regex: "go_gc_duration_seconds_.*|apiserver_request_duration_seconds_bucket"
        action: drop

Ежедневная гигиена — знать своих топ-нарушителей. Prometheus отдаёт это одним запросом:

# 10 метрик с наибольшим числом серий — первое, что смотрят при росте памяти
$ curl -s 'http://prometheus:9090/api/v1/query' \
    --data-urlencode 'query=topk(10, count by (__name__)({__name__=~".+"}))' \
  | jq -r '.data.result[] | "\(.value[1])\t\(.metric.__name__)"' | sort -rn

412330  apiserver_request_duration_seconds_bucket
188104  http_request_duration_seconds_bucket
 96550  container_memory_working_set_bytes
 41200  kafka_consumergroup_lag
 18904  http_requests_total

Первая строка — типичная. Гистограмма API-сервера Kubernetes с десятком меток и 12 бакетами легко даёт полмиллиона серий и часто составляет больше половины всей нагрузки на TSDB, при этом никем не используется. Дропайте её metric_relabel_configs, если не занимаетесь тюнингом control plane.

Метрики: что мерить и как не соврать себе

Два взаимодополняющих набора, оба стоит знать наизусть.

RED (для сервисов, взгляд пользователя): Rate — запросов в секунду, Errors — доля неуспешных, Duration — распределение времени ответа. Придуман Томом Уилки, отлично ложится на HTTP/gRPC/очереди.

USE (для ресурсов, взгляд системы, Брендан Грегг): Utilization — доля времени занятости, Saturation — длина очереди к ресурсу, Errors — счётчик ошибок. Для CPU, диска, сети, пула соединений.

RED говорит, плохо ли пользователю. USE говорит, что именно упёрлось. Алерты — почти всегда на RED (симптом), диагностика — по USE (причина).

Инструментация на Go с prometheus/client_golang — обратите внимание на бакеты и на то, чего в метках нет:

package metrics

import (
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"
)

var (
	// Счётчик: только монотонный рост. Метки — низкой кардинальности.
	// route — это ШАБЛОН пути (/users/:id), а не сырой URL: иначе кардинальность
	// станет равна числу разных id, то есть бесконечной.
	RequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
		Name: "http_requests_total",
		Help: "Всего HTTP-запросов, обработанных сервисом.",
	}, []string{"method", "route", "code"})

	// Гистограмма: бакеты выбираются под ваш SLO, а не по умолчанию.
	// Дефолтные бакеты client_golang (.005 ... 10) почти всегда неудачны:
	// половина попадает в +Inf, и p99 считается по воздуху.
	// Здесь SLO = 300 мс, поэтому вокруг него сетка плотнее.
	RequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
		Name:    "http_request_duration_seconds",
		Help:    "Время обработки HTTP-запроса.",
		Buckets: []float64{0.01, 0.025, 0.05, 0.1, 0.2, 0.3, 0.5, 1, 2, 5},
	}, []string{"method", "route"})

	// Saturation из USE: сколько запросов сейчас в обработке.
	InFlight = promauto.NewGauge(prometheus.GaugeOpts{
		Name: "http_requests_in_flight",
		Help: "Число запросов в обработке прямо сейчас.",
	})
)

Гистограммы, квантили и главная ложь мониторинга. histogram_quantile() считает квантиль по бакетам линейной интерполяцией внутри бакета — это оценка, а не истина. Если 40 % запросов упало в бакет [1, 2], «p99 = 1,84 с» означает лишь «где-то между 1 и 2». Отсюда правило: бакеты подбираются под SLO-порог, чтобы граница бакета совпадала с порогом. Тогда доля «быстрых» запросов считается точно, без интерполяции.

Вторая ложь тяжелее: квантили нельзя усреднять. avg(p99_by_pod) — бессмысленное число. Единственный корректный способ — агрегировать бакеты, а потом брать квантиль:

# ПРАВИЛЬНО: сначала суммируем бакеты по подам, потом считаем квантиль
histogram_quantile(0.99,
  sum by (le, route) (rate(http_request_duration_seconds_bucket[5m]))
)

# НЕПРАВИЛЬНО (встречается в каждом втором дашборде):
avg by (route) (histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])))

Дорогие агрегации выносятся в recording rules — это разница между дашбордом, который открывается за 300 мс, и дашбордом, который таймаутит в момент инцидента:

# rules/recording.yaml
groups:
  - name: sli-recording
    interval: 30s
    rules:
      # доля успешных запросов за 5 минут — базовый SLI, используется алертами ниже
      - record: job:sli_availability:ratio_rate5m
        expr: |
          sum by (job) (rate(http_requests_total{code!~"5.."}[5m]))
            /
          sum by (job) (rate(http_requests_total[5m]))

      # доля запросов быстрее 300 мс — SLI латентности через бакет, без интерполяции
      - record: job:sli_latency:ratio_rate5m
        expr: |
          sum by (job) (rate(http_request_duration_seconds_bucket{le="0.3"}[5m]))
            /
          sum by (job) (rate(http_request_duration_seconds_count[5m]))

Логи: структура, семплирование и цена хранения

Единственное правило, которое даёт 80 % пользы: логи — это структурированные события, а не текст для человека. Строка 2026-03-14 03:47:12 ERROR failed to charge user 8812: timeout требует regexp для любого анализа. JSON-объект с полями user_id, err, duration_ms, trace_id фильтруется индексом и джойнится с трейсом.

# structlog — стандарт де-факто для структурного логирования в Python
import structlog, logging, sys

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,   # подтягивает trace_id из контекста
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso", utc=True),
        structlog.processors.StackInfoRenderer(),
        structlog.processors.format_exc_info,
        structlog.processors.JSONRenderer(),        # в проде — JSON, локально — ConsoleRenderer
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
    logger_factory=structlog.PrintLoggerFactory(sys.stdout),
)

log = structlog.get_logger()

def charge(user_id: str, amount_cents: int, trace_id: str) -> None:
    # Привязываем контекст один раз — дальше он попадёт в каждую запись,
    # включая логи из вложенных вызовов.
    logger = log.bind(user_id=user_id, trace_id=trace_id, amount_cents=amount_cents)
    logger.info("charge.started")
    try:
        gateway.charge(user_id, amount_cents, timeout=3.0)
    except TimeoutError:
        # Никаких f-строк: поля отдельно, сообщение — стабильный идентификатор события.
        logger.error("charge.gateway_timeout", gateway="stripe", retryable=True)
        raise
    logger.info("charge.succeeded")

Обратите внимание на "charge.gateway_timeout" как стабильный ключ события. По нему можно построить метрику и алерт; по строке «failed to charge user 8812: timeout» — нельзя, она уникальна для каждого пользователя.

Семплирование логов. При 10 000 RPS и 1 КБ на запись access-логи дают 864 ГБ в сутки. Хранить это 30 дней в проиндексированном виде — десятки тысяч долларов в год. Рабочий компромисс:

  • все ошибки и предупреждения — без семплирования;
  • успешные запросы — 1 из 100, но обязательно все запросы медленнее SLO-порога;
  • «хвостовое» семплирование по trace_id: если запрос попал в выборку трассировки, его логи тоже сохраняются целиком — иначе трейс есть, а логов к нему нет;
  • дебаг-уровень включается динамически по флагу на 15 минут, а не живёт постоянно.

Сравнение стоимости хранения логов. Порядок величин на 100 ГБ логов в сутки, 14 дней хранения (цены меняются, важны соотношения):

Решение Стоимость/мес Порог входа Эксплуатационная нагрузка Когда брать
Loki (self-hosted, S3) 150–400 $ (S3 + 3 ВМ) средний средняя: шардирование, компактор есть Kubernetes и SRE-ресурс
Elasticsearch/OpenSearch (self-hosted) 1500–3000 $ (нужен RAM под индексы) высокий высокая: шарды, ILM, ребаланс нужен полнотекстовый поиск и аналитика
ClickHouse (self-hosted) 300–700 $ высокий средняя, но требует экспертизы очень большой объём, нужны агрегации
Grafana Cloud Logs ~0,5 $/ГБ ≈ 1500 $ низкий почти нулевая до ~50 ГБ/день, ценят время инженеров
Datadog Logs ingest ~0,10 $/ГБ + индексация ~1,7 $ за млн событий ≈ 5000–15 000 $ низкий нулевая деньги есть, инженеров нет
CloudWatch Logs ingest 0,50 $/ГБ ≈ 1500 $ + запросы нулевой на AWS нулевая вы уже в AWS и объём небольшой

Главная ловушка облачных APM — тарификация по хостам плюс кастомные метрики. Datadog Pro стоит порядка 15–23 $ за хост в месяц, но включает лишь ~100–200 кастомных метрик на хост; сверх этого — около 0,05 $ за метрику в месяц. Одна гистограмма с 12 бакетами и 3 метками на 30 хостов легко превращается в тысячи «кастомных метрик». Счета, выросшие в 5 раз после «мы просто добавили метрику», — обыденность. Если идёте в SaaS, включайте лимиты и алерт на сам счёт (см. https://courses.digitable.life/post/devops/15-cloud-cost-and-tradeoffs/).

Трейсы и OpenTelemetry: как связать всё в одну картину

Трейс — это дерево спанов: каждый спан имеет trace_id, span_id, parent_span_id, имя, время начала и конца и атрибуты. Ценность даёт не сам трейс, а распространение контекста между процессами — по стандарту W3C Trace Context, через заголовок traceparent.

Два места, где контекст рвётся чаще всего, оба видны на диаграмме. Первое — асинхронные границы: очереди, cron, батчи. Если не положить traceparent в заголовки сообщения Kafka/RabbitMQ, трейс обрывается на продюсере и половина системы становится невидимой. Второе — сторонние прокси и балансировщики, которые вырезают неизвестные заголовки. Проверяется тривиально: curl -H 'traceparent: 00-...' через весь путь и tcpdump/логи на выходе.

Head vs tail sampling — решение, которое определяет и стоимость, и полезность:

Head sampling Tail sampling
Где решается в SDK, в начале трейса в коллекторе, после сборки всего трейса
Ресурсы минимальные буфер всех спанов в памяти (десятки ГБ)
Что теряется редкие ошибки: 1 % выборка ловит 1 % проблем ничего важного
Стоимость хранения линейно по проценту 100 % ошибок + ~1 % успехов
Сложность нулевая нужен stateful-коллектор, sticky-балансировка по trace_id

Практическое правило: начинайте с head sampling 100 % при малом трафике (до ~200 RPS — это дёшево), переходите на tail sampling, когда объём становится заметным. Никогда не оставляйте head sampling 1 % на проде без исключения для ошибок — это выбрасывает ровно те трейсы, ради которых всё затевалось.

OpenTelemetry Collector: единая точка сбора

Collector — самый недооценённый компонент стека. Он снимает с приложения знание о бэкенде, даёт возможность сменить вендора без передеплоя всего парка и служит местом, где чистятся PII и режется кардинальность.

# otel-collector-config.yaml — рабочая конфигурация для прод-кластера
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }

processors:
  # ОБЯЗАТЕЛЬНО первым: без лимита памяти коллектор убивает ноду при всплеске
  memory_limiter:
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 20

  # tail sampling: сохраняем 100% ошибок и медленных, 2% остального
  tail_sampling:
    decision_wait: 10s          # ждём завершения трейса; больше — точнее, но дороже по памяти
    num_traces: 100000
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow
        type: latency
        latency: { threshold_ms: 500 }
      - name: baseline
        type: probabilistic
        probabilistic: { sampling_percentage: 2 }

  # вычищаем персональные данные до отправки наружу
  attributes:
    actions:
      - key: user.email
        action: delete
      - key: http.request.header.authorization
        action: delete
      - key: db.statement
        action: hash          # оставляем форму запроса, убираем значения

  # генерируем RED-метрики прямо из спанов — не нужна отдельная инструментация
  spanmetrics:
    histogram:
      explicit:
        buckets: [10ms, 50ms, 100ms, 300ms, 1s, 5s]
    dimensions:
      - name: http.route
      - name: http.status_code

  batch:
    timeout: 5s
    send_batch_size: 8192

exporters:
  otlphttp/tempo:
    endpoint: http://tempo-distributor:4318
  prometheusremotewrite:
    endpoint: http://mimir:9009/api/v1/push
    resource_to_telemetry_conversion: { enabled: false }  # иначе взрыв кардинальности
  loki:
    endpoint: http://loki:3100/loki/api/v1/push

service:
  extensions: []
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, attributes, tail_sampling, batch]
      exporters: [otlphttp/tempo]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheusremotewrite]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, attributes, batch]
      exporters: [loki]
  telemetry:
    metrics:
      level: detailed         # коллектор тоже надо мониторить: otelcol_processor_dropped_spans

Общая архитектура сбора выглядит так:

Почему два уровня коллекторов? Агент на ноде забирает телеметрию по localhost (быстро, без сетевых сбоев) и добавляет метаданные ноды. Шлюз централизует то, что требует полной картины: tail sampling должен видеть все спаны одного трейса, поэтому от агента к шлюзу нужна балансировка по trace_id, а не round-robin — иначе спаны одного трейса разъедутся по разным репликам и решение о семплировании станет случайным.

SLO: как превратить «работает» в число

Наблюдаемость без SLO даёт бесконечный поток графиков, по которым непонятно, надо ли что-то делать. SLO переводит надёжность в валюту, которой можно торговать с продуктом.

Три термина, которые часто путают:

  • SLI (indicator) — измеримая величина: доля запросов с кодом не 5xx; доля запросов быстрее 300 мс; доля успешно доставленных сообщений. Всегда «хорошие события / все события».
  • SLO (objective) — целевое значение SLI на окне: «99,9 % за 30 скользящих дней».
  • SLA (agreement) — контракт с деньгами за нарушение. SLO всегда строже SLA, обычно с запасом в порядок.

Бюджет ошибок = 1 − SLO. Это разрешённое количество отказа, и это самая полезная идея во всей SRE-практике: пока бюджет есть, команда катит фичи быстро; бюджет исчерпан — релизы замораживаются до восстановления. Спор «надёжность против скорости» превращается из политического в арифметический.

SLO за 30 дней Бюджет недоступности Что это означает на практике
99 % 7 ч 18 мин одна плохая ночь в месяц — это норма
99,5 % 3 ч 39 мин реалистично для внутренних сервисов
99,9 % 43 мин 12 с требует HA, отработанных откатов, дежурства
99,95 % 21 мин 36 с нужны multi-AZ и автоматический failover
99,99 % 4 мин 19 с человек не успевает среагировать: только автоматика
99,999 % 26 с практически недостижимо со сторонними зависимостями

Важное отрезвление: ваш SLO не может быть выше произведения SLA зависимостей. Если вы стоите на RDS Multi-AZ (99,95 %), ALB (99,99 %) и внешнем платёжном шлюзе (99,9 %), потолок доступности — около 99,84 %, и обещать 99,99 % — самообман. Считайте цепочку прежде, чем печатать цифру на лендинге.

Алерты по скорости сжигания бюджета

Классический алерт «доля 5xx > 1 % пять минут» плох сразу с двух сторон: он срабатывает на безобидные всплески и молчит при медленной деградации в 0,9 %, которая за неделю съест весь бюджет. Решение из SRE Workbook — multi-window, multi-burn-rate.

Burn rate — во сколько раз быстрее нормы тратится бюджет. Единица означает «ровно на 30 дней». Значение 14,4 означает «весь месячный бюджет за 2 дня» (или, что то же самое, 2 % бюджета за час).

Скорость сжигания бюджета ошибок и окна алертов

Два окна нужны по разным причинам: длинное окно даёт значимость (не реагируем на одну неудачную секунду), короткое обеспечивает быстрое затухание — когда проблема кончилась, алерт гаснет через минуты, а не через час.

# rules/slo-alerts.yaml — канонический набор для SLO 99.9%
groups:
  - name: slo-availability
    rules:
      # Записываем error ratio на нескольких окнах: алерты должны быть дешёвыми
      - record: job:slo_errors:ratio_rate5m
        expr: |
          sum by (job) (rate(http_requests_total{code=~"5.."}[5m]))
            / sum by (job) (rate(http_requests_total[5m]))
      - record: job:slo_errors:ratio_rate1h
        expr: |
          sum by (job) (rate(http_requests_total{code=~"5.."}[1h]))
            / sum by (job) (rate(http_requests_total[1h]))
      - record: job:slo_errors:ratio_rate30m
        expr: |
          sum by (job) (rate(http_requests_total{code=~"5.."}[30m]))
            / sum by (job) (rate(http_requests_total[30m]))
      - record: job:slo_errors:ratio_rate6h
        expr: |
          sum by (job) (rate(http_requests_total{code=~"5.."}[6h]))
            / sum by (job) (rate(http_requests_total[6h]))

      # 14.4 × 0.001 = 0.0144 — 2 % бюджета за час. Будим человека.
      - alert: SLOErrorBudgetBurnFast
        expr: |
          job:slo_errors:ratio_rate1h  > (14.4 * 0.001)
            and
          job:slo_errors:ratio_rate5m  > (14.4 * 0.001)
        for: 2m
        labels:
          severity: page
          slo: availability
        annotations:
          summary: "{{ $labels.job }}: бюджет ошибок сгорает в 14× быстрее нормы"
          description: "При текущей скорости весь месячный бюджет закончится за ~2 дня."
          runbook_url: "https://runbooks.internal/slo-burn-availability"
          dashboard_url: "https://grafana.internal/d/slo/{{ $labels.job }}"

      # 6 × 0.001 = 0.006 — 5 % бюджета за 6 часов. Тоже page, но медленнее.
      - alert: SLOErrorBudgetBurnSlow
        expr: |
          job:slo_errors:ratio_rate6h  > (6 * 0.001)
            and
          job:slo_errors:ratio_rate30m > (6 * 0.001)
        for: 15m
        labels:
          severity: page
          slo: availability
        annotations:
          summary: "{{ $labels.job }}: устойчивая деградация, бюджет за ~5 дней"
          runbook_url: "https://runbooks.internal/slo-burn-availability"

Для трети SLO-порогов (burn rate 1 на окнах 3 д / 6 ч) severity должен быть ticket, а не page: это задача на рабочий день, а не повод будить человека.

Алерты: правила, за которые платят сном

Формулируется одним предложением, которое стоит повесить на стену: алерт должен будить человека только тогда, когда есть проблема у пользователя И человек может что-то сделать прямо сейчас. Всё остальное — тикет, дашборд или удаление.

Отсюда прикладные следствия:

  1. Алертим на симптомах, а не на причинах. «CPU 90 %» — не проблема, если латентность в норме (может, это эффективная утилизация). «p99 checkout > 2 с» — проблема всегда. Алертов на причины должно быть мало и только там, где причина неизбежно ведёт к отказу с задержкой: диск заполнится через 4 часа, срок сертификата истекает через 7 дней, репликация отстала на час.
  2. У каждого алерта — runbook. Не «ссылка на вики», а конкретный документ: как подтвердить, что проблема реальна; три первые команды диагностики; известные способы смягчения; кого эскалировать. Алерт без runbook — это перекладывание своей работы на того, кто дежурит ночью.
  3. Алерт без действия удаляется. Раз в квартал берите список алертов за период, считайте, по скольким что-то реально сделали, и безжалостно чистите. Норма — менее 2 будящих страниц за смену; выше — начинается усталость от алертов, и настоящий инцидент утонет в шуме.
  4. Группировка и подавление обязательны. Отказ ноды не должен рождать 40 страниц.
# alertmanager.yml — маршрутизация, группировка и подавление
route:
  receiver: default-slack
  group_by: [alertname, cluster, service]   # НЕ по instance: иначе 40 страниц с одной ноды
  group_wait: 30s          # копим первые алерты группы, чтобы отправить одним сообщением
  group_interval: 5m       # как часто досылать новые алерты уже открытой группы
  repeat_interval: 4h      # напоминание о неразрешённом
  routes:
    - matchers: [ 'severity="page"' ]
      receiver: pagerduty
      group_wait: 10s
      continue: true                       # продублировать в Slack для контекста команды
    - matchers: [ 'severity="page"' ]
      receiver: incident-slack
    - matchers: [ 'severity="ticket"' ]
      receiver: jira
      group_interval: 1h
    - matchers: [ 'env="staging"' ]        # стейджинг никогда не будит людей
      receiver: dev-slack

inhibit_rules:
  # Если весь кластер недоступен, не сыпать алертами про отдельные сервисы
  - source_matchers: [ 'alertname="ClusterDown"' ]
    target_matchers: [ 'severity=~"page|ticket"' ]
    equal: [cluster]
  # Критический алерт подавляет предупреждение о том же самом
  - source_matchers: [ 'severity="page"' ]
    target_matchers: [ 'severity="warning"' ]
    equal: [alertname, service]

receivers:
  - name: pagerduty
    pagerduty_configs:
      - routing_key_file: /etc/alertmanager/secrets/pd_key
        description: '{{ .CommonAnnotations.summary }}'
        details:
          runbook: '{{ .CommonAnnotations.runbook_url }}'
          dashboard: '{{ .CommonAnnotations.dashboard_url }}'
  - name: incident-slack
    slack_configs:
      - api_url_file: /etc/alertmanager/secrets/slack_url
        channel: '#incidents'
        title: '{{ .Status | toUpper }}: {{ .CommonLabels.alertname }}'
        text: >-
          {{ .CommonAnnotations.description }}
          <{{ .CommonAnnotations.runbook_url }}|Runbook> ·
          <{{ .CommonAnnotations.dashboard_url }}|Дашборд>

Проверять маршрутизацию до инцидента, а не во время:

# куда уйдёт алерт с такими метками — сухой прогон без отправки
$ amtool config routes test --config.file=alertmanager.yml \
    severity=page service=checkout env=prod
pagerduty
incident-slack

# заглушить известную проблему на время работ, обязательно с автором и причиной
$ amtool silence add alertname=HighLatency service=checkout \
    --duration=2h --author="ivan@corp" --comment="плановая миграция БД, тикет OPS-4412"

Мониторинг самого мониторинга. Prometheus, который упал, не пришлёт алерт о том, что он упал. Минимум: правило Watchdog, срабатывающее всегда, и внешняя система (dead man’s switch — например, healthchecks.io или второй Prometheus в другом регионе), которая шлёт страницу, если сигнал перестал приходить.

Дежурства: как построить ротацию, которая не выжигает людей

Техническая часть закончилась, начинается организационная — и здесь ломается большинство внедрений.

Базовая конструкция. Ротация недельная (сменять чаще — не успеваешь набрать контекст; реже — выгораешь). Минимум 6 человек в ротации: при пяти дежурство выпадает раз в пять недель, что уже ощутимо, при четырёх — люди начинают увольняться. Обязателен вторичный дежурный (эскалация через 10–15 минут без ответа) и явное правило: если основной не ответил, автоматически звонит вторичному, а не «кто-нибудь заметит в чате».

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

Handoff — обязательная церемония. Пятнадцать минут в конце смены: что горело, что заглушено (и когда истекают silence), что осталось незакрытым, какие изменения в проде на следующей неделе. Без handoff новый дежурный узнаёт о заглушенном алерте в момент, когда silence истекает в субботу ночью.

Правило «дежурный не пишет фичи». У дежурного на неделю есть задача: разгребать поток, чинить хрупкое, улучшать runbook’и, удалять шумные алерты. Это не «свободная неделя», это работа по снижению будущей боли. Если ждать от дежурного ещё и спринтовых задач, он не сделает ни того, ни другого.

Отдельно про роль Incident Commander: при инциденте выше severity 3 назначается человек, который не чинит руками, а координирует — ведёт таймлайн, распределяет задачи, общается с бизнесом. Без этой роли пять инженеров параллельно перезапускают один и тот же под, а поддержка не знает, что говорить клиентам.

Первые пять минут: дерево решений

Два узла здесь принципиальны. «Был ли деплой» — потому что подавляющее большинство инцидентов вызвано изменением; корреляция графика SLI с маркерами деплоя экономит часы. Практический совет: аннотируйте деплои прямо в Grafana (через /api/annotations из пайплайна) — это самый дешёвый способ сократить время диагностики. «Алерт врёт» — потому что ложное срабатывание обязано порождать тикет; иначе через полгода все ваши алерты — ложные.

Постмортемы: как организация учится

Постмортем — это не отчёт для начальства, а механизм превращения дорогого опыта в изменения в системе. Условие работоспособности одно и оно жёсткое: безвинность (blameless). Если формулировка звучит как «Иван выкатил плохой код», следующий Иван скроет детали, и вы потеряете данные о реальной причине. Правильная формулировка: «система позволила выкатить непроверенную миграцию в прод в пятницу вечером без ревью — вот три места, где не было защиты».

Триггеры обязательного постмортема фиксируются заранее: любой инцидент severity 1–2; любое сгорание более 20 % месячного бюджета за раз; любая потеря данных; любой инцидент, повторившийся дважды.

# Постмортем: деградация checkout, 2026-03-14

**Статус:** финальный · **Severity:** 2 · **Автор:** @ivan · **Ревью:** @maria, @oleg
**Длительность:** 03:47–05:12 UTC (85 минут) · **Бюджет:** сожжено 61 % месячного

## Влияние
Около 12 400 пользователей получили 5xx или таймаут на /checkout.
Оценка недополученной выручки — 38 000 $. Данные не потеряны, повторные
списания не зафиксированы (проверено по идемпотентным ключам в payments).

## Что произошло (кратко)
Миграция добавила индекс на таблицу orders (240 млн строк) без CONCURRENTLY.
Postgres взял ACCESS EXCLUSIVE-блокировку на 9 минут; пул соединений
checkout-svc исчерпался за 40 секунд; отсутствие таймаута на запрос
превратило локальную проблему БД в полный отказ сервиса.

## Таймлайн (UTC)
| Время | Событие | Источник |
|---|---|---|
| 03:38 | Начат деплой release-4.19.2 с миграцией | GitHub Actions run #8841 |
| 03:41 | Миграция взяла блокировку на orders | pg_locks, лог RDS |
| 03:47 | SLOErrorBudgetBurnFast → PagerDuty | Alertmanager |
| 03:52 | Ack дежурным (5 мин — в пределах цели) | PagerDuty |
| 03:58 | Гипотеза «проблема с сетью» отвергнута | трейсы Tempo |
| 04:11 | Найдена блокировка через pg_stat_activity | ручной запрос |
| 04:19 | Миграция прервана, блокировка снята | psql |
| 04:26 | Пул соединений всё ещё исчерпан, рестарт подов | kubectl |
| 05:12 | SLI в норме 15 минут, инцидент закрыт | Grafana |

## Что сработало
- Многооконный алерт поймал деградацию за 6 минут — раньше жалоб пользователей.
- Трейсы позволили за 6 минут отвергнуть гипотезу о сети.
- Идемпотентность платежей предотвратила двойные списания.

## Что не сработало
- Миграция прошла ревью, но никто не проверял её на копии прод-объёма
  (на стейджинге в orders 12 000 строк — блокировка занимала 40 мс).
- У запросов к БД не было statement_timeout: сервис ждал вечно.
- Runbook «checkout деградирует» не содержал шага «проверить pg_locks».
- Дежурный не знал, что деплой идёт: аннотации деплоев не попадали в Grafana.

## Причины (пять «почему», без имён)
Отказ → пул исчерпан → запросы висят без таймаута → блокировка таблицы →
миграция без CONCURRENTLY → **в CI нет автоматической проверки миграций
на опасные паттерны, а стейджинг не отражает объём данных прода**.

## Action items
| # | Действие | Владелец | Срок | Тикет |
|---|---|---|---|---|
| 1 | Линтер миграций в CI (squawk): блокировать ALTER без CONCURRENTLY | @ivan | 21.03 | OPS-4471 |
| 2 | statement_timeout=5s и pool_timeout=2s во всех сервисах | @oleg | 28.03 | OPS-4472 |
| 3 | Аннотации деплоев в Grafana из пайплайна | @maria | 21.03 | OPS-4473 |
| 4 | Шаг «pg_locks» в runbook checkout | @ivan | 18.03 | OPS-4474 |
| 5 | Стейджинг с анонимизированной копией прод-объёма | @maria | 30.04 | OPS-4480 |

Три критерия качественного постмортема, по которым его стоит ревьюить: (1) в разделе причин нет имён людей, только свойства системы; (2) каждый action item имеет владельца, срок и тикет — иначе он не существует; (3) есть раздел «что сработало» — иначе документ читается как самобичевание, и их перестают писать. Ключевая метрика зрелости — не число постмортемов, а доля action items, закрытых в срок. Если она ниже 50 %, вы пишете художественную литературу.

Сколько стоит наблюдаемость: честный расчёт

Ориентир, о котором молчат вендоры: расходы на телеметрию не должны превышать 10–20 % инфраструктурного бюджета. Реальность бывает жёстче: истории, где счёт за Datadog обгонял счёт за AWS, — не городская легенда, а регулярно повторяющийся сюжет.

Сценарий для расчёта: 40 сервисов, 120 подов, 3000 RPS, 80 ГБ логов в сутки, 30 дней хранения метрик, 14 дней логов.

Вариант Инфраструктура Лицензии/SaaS Время инженеров Итого/мес Порог входа
Self-hosted LGTM (Prometheus + Loki + Tempo + Grafana на своих нодах, S3) ~600 $ (3 ВМ + 2 ТБ S3) 0 ~0,3 FTE ≈ 2500 $ ~3100 $ высокий
VictoriaMetrics + Loki (меньше ресурсов на те же метрики) ~350 $ 0 ~0,25 FTE ≈ 2000 $ ~2350 $ высокий
Grafana Cloud 0 ~1800–2600 $ ~0,05 FTE ≈ 400 $ ~2200–3000 $ низкий
Datadog (infra + APM + logs) 0 ~6000–12 000 $ ~0,05 FTE ~6400–12 400 $ очень низкий
CloudWatch + X-Ray (только AWS) 0 ~2500–4000 $ ~0,1 FTE ≈ 800 $ ~3300–4800 $ нулевой

Выводы, которые из этой таблицы честно следуют:

  • Self-hosted дешевле по счёту, но не по деньгам — пока у вас нет ~0,25 FTE свободного SRE. Инженер, который каждую неделю чинит Loki-компактор, стоит дороже, чем разница в тарифах. При 5 сервисах self-hosted почти всегда проигрывает; при 300 — почти всегда выигрывает.
  • Точка перелома обычно проходит около 50–100 хостов или 100 ГБ логов в сутки. До неё берите SaaS и занимайтесь продуктом; после — считайте всерьёз.
  • Datadog покупают не за технологию, а за время до первого дашборда: интеграции из коробки, ноль эксплуатации, отличный UX. Это законный выбор для команды из десяти человек без SRE. Но заложите в бюджет рост в 2–3 раза за год и включите лимиты на кастомные метрики и индексацию логов с первого дня.
  • Гибрид работает лучше всего: метрики и алерты — self-hosted (дёшево, полный контроль, нет vendor lock-in на самом критичном), логи и трейсы — SaaS с агрессивным семплированием (дорогая часть, где эксплуатация болезненнее всего). OpenTelemetry как слой инструментации делает такой гибрид обратимым: смена бэкенда — правка конфига коллектора, а не передеплой сорока сервисов.

Три рычага снижения счёта, дающие наибольший эффект:

  1. Семплирование и дроп на коллекторе. 90 % трейсов успешных запросов и debug-логи не нужны — режьте до отправки, а не после.
  2. Ретеншен по уровням. Метрики: 15 с — 14 дней, 5 мин — 90 дней, 1 ч — 2 года (в Prometheus это делает Thanos/Mimir downsampling). Логи: 7 дней в горячем индексе, 90 дней в S3 без индексации, дальше Glacier.
  3. Дроп неиспользуемых метрик. В типичной установке 20–40 % серий не участвуют ни в одном дашборде и ни в одном алерте. Найти их можно, разобрав правила и дашборды и сравнив с count by (__name__); в Grafana Cloud есть Adaptive Metrics, в self-hosted — скрипт на полчаса и metric_relabel_configs.

Типичные ошибки

  • Дашборд-первый подход. Сначала строят сто графиков, потом удивляются, что на инциденте некогда их смотреть. Порядок обратный: SLO → алерты по бюджету → runbook → и только затем дашборд с 6–8 панелями (RED сервиса, USE его зависимостей, деплои).
  • Алерты на причины вместо симптомов. Пятьдесят правил на «CPU > 80 %», и ни одного на «пользователь не может оплатить».
  • Метки с идентификаторами. user_id в Prometheus-метке убивает TSDB. Идентификаторы — в логи и спаны.
  • Дефолтные бакеты гистограмм. Половина запросов в +Inf, p99 — фантазия. Бакеты подбирают под SLO.
  • Усреднение перцентилей. avg(p99) бессмысленен математически; агрегируйте бакеты.
  • Нет корреляции сигналов. Три интерфейса, ни одного общего trace_id — расследование превращается в перебор вкладок. Минимум: trace_id в каждой строке лога и exemplars в гистограммах Prometheus.
  • Тестирование алертов в проде. Правила Prometheus покрываются юнит-тестами через promtool test rules — это дешевле, чем узнать в 4 утра, что выражение всегда возвращает пустой вектор.
  • Silence без срока и без комментария. Вечная заглушка — это удалённый алерт, только незаметно.
  • Постмортемы без action items или с action items без владельцев. Ритуал вместо обучения.
  • Дежурство без компенсации и без права на сон. Технически идеальная система с выгоревшей командой чинится дольше, чем кривая с бодрой.

Проверять правила надо тестами, как обычный код:

# tests/slo_rules_test.yaml — запускается в CI: promtool test rules tests/slo_rules_test.yaml
rule_files:
  - ../rules/slo-alerts.yaml

evaluation_interval: 1m

tests:
  - interval: 1m
    input_series:
      - series: 'http_requests_total{job="checkout", code="200"}'
        values: '0+950x120'          # 950 успешных запросов в минуту
      - series: 'http_requests_total{job="checkout", code="500"}'
        values: '0+50x120'           # 50 ошибок в минуту = 5 % — burn rate 50
    alert_rule_test:
      - eval_time: 65m
        alertname: SLOErrorBudgetBurnFast
        exp_alerts:
          - exp_labels:
              severity: page
              slo: availability
              job: checkout
            exp_annotations:
              summary: "checkout: бюджет ошибок сгорает в 14× быстрее нормы"
              description: "При текущей скорости весь месячный бюджет закончится за ~2 дня."
              runbook_url: "https://runbooks.internal/slo-burn-availability"
              dashboard_url: "https://grafana.internal/d/slo/checkout"

Мини-итог

  • Наблюдаемость — это способность отвечать на незаданные заранее вопросы; для этого нужны сигналы высокой кардинальности и связь между ними через trace_id.
  • Три сигнала разделены по стоимости и роли: алертим на метриках (агрегация при записи — стоимость не зависит от трафика), расследуем логами и трейсами.
  • Кардинальность метрик — произведение мощностей меток. Идентификаторы в метках убивают TSDB; их место — в логах и спанах. Лимиты (sample_limit, labeldrop) ставятся заранее.
  • SLI/SLO/бюджет ошибок превращают спор о надёжности в арифметику. Алерты — многооконные по burn rate (14,4 / 6 / 1), а не по фиксированному порогу.
  • Хороший алерт: симптом + действие + runbook. Менее двух будящих страниц за смену — иначе усталость от алертов сводит всё к нулю.
  • Дежурство — оплачиваемая работа: ротация от шести человек, вторичный дежурный, handoff, «дежурный не пишет фичи».
  • Постмортем безвинный, с владельцами и сроками у каждого action item; метрика зрелости — доля закрытых в срок, а не число написанных документов.
  • Бюджет на телеметрию — 10–20 % от инфраструктурного. Точка перелома self-hosted/SaaS — около 50–100 хостов; OpenTelemetry делает выбор обратимым.

Источники

Что дальше

Мы научились видеть, что происходит в системе, и реагировать так, чтобы люди не выгорали. Осталась последняя большая тема трека — та, где отказ обходится дороже любого сгоревшего бюджета ошибок: скомпрометированный секрет, подменённая зависимость или образ, собранный не из того исходника.

Следующая статья — про защиту самого конвейера доставки: Безопасность конвейера: секреты, supply chain, SAST/DAST, подпись образов.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов