Наблюдаемость и дежурства: метрики, логи, трейсы, алерты, 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.
traceparent: 00-4bf9...-a1b2-01 O->>O: SELECT ... FROM inventory (span 45 мс) O->>P: POST /charge
traceparent: 00-4bf9...-c3d4-01 P-->>O: 200 OK (span 2100 мс) 🔴 O->>Q: produce order.created
traceparent в заголовке сообщения O-->>G: 201 Created G-->>B: 201 Created (всего 2350 мс) Q->>W: consume Note over W: продолжает ТОТ ЖЕ trace
через link, а не parent W->>W: отправка письма (span 300 мс)
Два места, где контекст рвётся чаще всего, оба видны на диаграмме. Первое — асинхронные границы: очереди, 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
Общая архитектура сбора выглядит так:
трейсы, метрики, логи"] PROM["/metrics endpoint"] STDOUT["stdout: JSON-логи"] end subgraph node["Уровень ноды (DaemonSet)"] AGENT["OTel Collector agent
+ Promtail/Alloy"] end subgraph gw["Кластерный шлюз (Deployment)"] GWC["OTel Collector gateway
tail sampling, PII, батчи"] end subgraph store["Хранилища"] MIMIR[("Prometheus/Mimir
метрики")] LOKI[("Loki
логи")] TEMPO[("Tempo
трейсы")] end SDK -->|OTLP gRPC| AGENT STDOUT -->|чтение файлов| AGENT PROM -->|scrape| MIMIR AGENT -->|OTLP, sticky по trace_id| GWC GWC --> MIMIR GWC --> LOKI GWC --> TEMPO MIMIR --> GRAF["Grafana
единая точка входа"] LOKI --> GRAF TEMPO --> GRAF MIMIR --> AM["Alertmanager"] AM --> PD["PagerDuty / Opsgenie"]
Почему два уровня коллекторов? Агент на ноде забирает телеметрию по 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: это задача на рабочий день, а не повод будить человека.
Алерты: правила, за которые платят сном
Формулируется одним предложением, которое стоит повесить на стену: алерт должен будить человека только тогда, когда есть проблема у пользователя И человек может что-то сделать прямо сейчас. Всё остальное — тикет, дашборд или удаление.
Отсюда прикладные следствия:
- Алертим на симптомах, а не на причинах. «CPU 90 %» — не проблема, если латентность в норме (может, это эффективная утилизация). «p99 checkout > 2 с» — проблема всегда. Алертов на причины должно быть мало и только там, где причина неизбежно ведёт к отказу с задержкой: диск заполнится через 4 часа, срок сертификата истекает через 7 дней, репликация отстала на час.
- У каждого алерта — runbook. Не «ссылка на вики», а конкретный документ: как подтвердить, что проблема реальна; три первые команды диагностики; известные способы смягчения; кого эскалировать. Алерт без runbook — это перекладывание своей работы на того, кто дежурит ночью.
- Алерт без действия удаляется. Раз в квартал берите список алертов за период, считайте, по скольким что-то реально сделали, и безжалостно чистите. Норма — менее 2 будящих страниц за смену; выше — начинается усталость от алертов, и настоящий инцидент утонет в шуме.
- Группировка и подавление обязательны. Отказ ноды не должен рождать 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’и, удалять шумные алерты. Это не «свободная неделя», это работа по снижению будущей боли. Если ждать от дежурного ещё и спринтовых задач, он не сделает ни того, ни другого.
или жалоба пользователя Обнаружен --> Принят: дежурный нажал ack
(цель: < 5 мин) Обнаружен --> Эскалация: нет ack 15 минут Эскалация --> Принят: ответил вторичный Принят --> Диагностика: открыт канал инцидента,
назначен Incident Commander Диагностика --> Смягчение: гипотеза есть Диагностика --> Диагностика: гипотеза не подтвердилась Диагностика --> Расширение: нужны люди
из других команд Расширение --> Смягчение Смягчение --> Проверка: откат / фича-флаг /
переключение трафика Проверка --> Диагностика: SLI не восстановился Проверка --> Восстановлен: SLI в норме 15 минут Восстановлен --> Постмортем: severity 1-2 —
обязателен Восстановлен --> Норма: severity 3 —
тикет на исправление Постмортем --> Норма: action items
в бэклоге с владельцами note right of Смягчение Сначала СМЯГЧИТЬ, потом разбираться. Откат до понимания причины — норма, а не поражение. end note
Отдельно про роль Incident Commander: при инциденте выше severity 3 назначается человек, который не чинит руками, а координирует — ведёт таймлайн, распределяет задачи, общается с бизнесом. Без этой роли пять инженеров параллельно перезапускают один и тот же под, а поддержка не знает, что говорить клиентам.
Первые пять минут: дерево решений
с другой стороны?
внешний чек, жалобы"} B -->|Нет| C["Алерт врёт.
Silence на 1 ч +
ТИКЕТ на исправление правила"] B -->|Да| D["ack + открыть канал
#inc-YYYYMMDD-имя"] D --> E{"Был деплой или
изменение конфига
за последний час?"} E -->|Да| F["ОТКАТ или выключение
фича-флага. Немедленно."] E -->|Нет| G{"Затронут один сервис
или всё сразу?"} G -->|Всё| H["Смотреть общую инфраструктуру:
сеть, DNS, БД, сертификаты,
облачный статус-пейдж"] G -->|Один| I["RED сервиса → USE его ресурсов →
трейсы медленных запросов"] F --> J{"SLI восстановился?"} H --> J I --> J J -->|Да| K["Зафиксировать таймлайн,
назначить постмортем"] J -->|Нет| L["Эскалировать: вторичный,
владелец сервиса, вендор"] L --> I style F fill:#e76f51,color:#fff style C fill:#e9a23b,color:#3a3f4b
Два узла здесь принципиальны. «Был ли деплой» — потому что подавляющее большинство инцидентов вызвано изменением; корреляция графика 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 как слой инструментации делает такой гибрид обратимым: смена бэкенда — правка конфига коллектора, а не передеплой сорока сервисов.
Три рычага снижения счёта, дающие наибольший эффект:
- Семплирование и дроп на коллекторе. 90 % трейсов успешных запросов и debug-логи не нужны — режьте до отправки, а не после.
- Ретеншен по уровням. Метрики: 15 с — 14 дней, 5 мин — 90 дней, 1 ч — 2 года (в Prometheus это делает Thanos/Mimir downsampling). Логи: 7 дней в горячем индексе, 90 дней в S3 без индексации, дальше Glacier.
- Дроп неиспользуемых метрик. В типичной установке 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 делает выбор обратимым.
Источники
- Betsy Beyer et al. Site Reliability Engineering — sre.google/sre-book/, главы про мониторинг распределённых систем и дежурства.
- The Site Reliability Workbook — sre.google/workbook/, глава Alerting on SLOs: каноническая таблица окон и burn rate.
- Alex Hidalgo. Implementing Service Level Objectives — O’Reilly, 2020. Самая практичная книга про SLO.
- Charity Majors, Liz Fong-Jones, George Miranda. Observability Engineering — O’Reilly, 2022. О высокой кардинальности и unknown unknowns.
- Brendan Gregg. The USE Method — методика анализа ресурсов.
- Tom Wilkie. The RED Method — метрики сервисов.
- Prometheus Documentation — разделы Instrumenting, Recording Rules, Alerting Rules; Best Practices по именованию метрик.
- OpenTelemetry Documentation — SDK, Collector, семантические соглашения.
- W3C Trace Context — стандарт заголовка
traceparent. - Alertmanager Configuration — маршрутизация, группировка, inhibit rules.
- Google. Postmortem Culture: Learning from Failure — принципы безвинного разбора и пример документа.
- PagerDuty Incident Response — открытая документация про роли, эскалации и здоровье ротаций.
- John Allspaw. Blameless PostMortems and a Just Culture — текст, с которого началась практика.
- Grafana Loki и Tempo — документация по хранилищам логов и трейсов.
Что дальше
Мы научились видеть, что происходит в системе, и реагировать так, чтобы люди не выгорали. Осталась последняя большая тема трека — та, где отказ обходится дороже любого сгоревшего бюджета ошибок: скомпрометированный секрет, подменённая зависимость или образ, собранный не из того исходника.
Следующая статья — про защиту самого конвейера доставки: Безопасность конвейера: секреты, supply chain, SAST/DAST, подпись образов.