voidlearn
 

5.1Логи, метрики, трейсы и алерты

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

Три столпа: что каждый даёт и чего не даёт

Логи, метрики и трейсы не заменяют друг друга: они отвечают на разные вопросы и стоят принципиально по-разному. Отвечая, называй и сильную сторону, и границу применимости каждого.

МетрикиЛогиТрейсы
Отвечают начто происходит и насколько плохочто именно случилось с конкретным запросомгде в цепочке сервисов потерялось время
Природа данныхагрегаты, числовой ряд во временидискретные события с текстомдерево спанов одного запроса
Стоимостьнизкая и предсказуемая: не зависит от rpsвысокая, растёт линейно с трафикомвысокая - поэтому почти всегда сэмплируются
Кардинальностьжёстко ограничена (лейблы)любаялюбая
Чего НЕ даютне расскажут про конкретный запрос: в них нет user_id и order_id - только «0.4 % запросов упало»не дают общей картины: по логам нельзя понять, стало ли хуже, чем вчера, и они врут при сэмплированиине видят того, чего не проинструментировали, и почти всегда неполны из-за сэмплирования
Роль в инцидентеобнаружение и алертпоследняя миля: точная причиналокализация: какой сервис/запрос виноват
Формулировка, которая нравится интервьюерам

«Метрика говорит, что что-то сломалось. Трейс говорит, где. Лог говорит, что именно. Инцидент разбирается по этой лестнице сверху вниз, и хорошая наблюдаемость тем и ценна, что по ней можно спуститься без прыжков: с графика перейти к экземплярам трейсов, из трейса уйти к логам с тем же trace_id. Три системы без связей между ними дают не observability, а три отдельных сайта, между которыми дежурный копирует айдишники руками в три часа ночи.»

Иногда добавляют четвёртый и пятый столп: профили (continuous profiling: отвечает на вопрос «какая строчка кода жрёт CPU прямо сейчас в проде», про них отдельный раздел ниже) и события (деплои, миграции, изменения флагов, без которых график «стало плохо в 14:03» не с чем сопоставить).

Структурированные логи и slog

Строка log.Printf("user %s failed: %v", id, err) хороша ровно до момента, когда логов становится гигабайт в день. После этого по ним нужно искать и агрегировать, а грепать неструктурированный текст значит писать регулярки по чужому форматированию. Структурированный лог пишет событие с полями: {"level":"error","msg":"payment failed","user_id":"u-42","order_id":"o-99","err":"timeout","trace_id":"4bf9…"}. Такое можно фильтровать (user_id = "u-42"), считать («сколько таких за час»), строить по нему графики и связывать с трейсами.

С Go 1.21 это есть в стандартной библиотеке, пакет log/slog, и это правильный дефолт: единый интерфейс, за которым можно поменять реализацию (zap, zerolog) без правки вызовов.

// 1) Handler решает, как и куда писать. Формат выбирает он, а не место вызова.
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level:     slog.LevelInfo,
    AddSource: true,                       // файл:строка дорого, но на ERROR окупается
    ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
        if a.Key == "password" || a.Key == "authorization" {
            return slog.String(a.Key, "[REDACTED]")   // санитайзер на уровне хендлера
        }
        return a
    },
})
logger := slog.New(h).With(                // 2) With даёт общие поля всем записям
    slog.String("service", "billing"),
    slog.String("version", version),
)
slog.SetDefault(logger)

// 3) Типизированные атрибуты вместо форматирования: slog.String, slog.Int, slog.Duration
logger.Info("payment processed",
    slog.String("order_id", o.ID),
    slog.Int64("amount_minor", o.Amount),
    slog.Duration("took", time.Since(start)),
)

// 4) Группы вкладывают объекты в JSON
logger.Info("request done",
    slog.Group("http",
        slog.String("method", r.Method),
        slog.String("route", route),       // шаблон пути, не конкретный URL
        slog.Int("status", ww.Status()),
    ),
    slog.Group("db", slog.Int("queries", n), slog.Duration("time", dbTime)),
)

// 5) Контекстный логгер: логгер с полями запроса кладём в context
func Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        reqID := r.Header.Get("X-Request-Id")
        if reqID == "" { reqID = uuid.NewString() }
        sc := trace.SpanContextFromContext(r.Context())

        l := slog.Default().With(
            slog.String("request_id", reqID),
            slog.String("trace_id", sc.TraceID().String()),  // мост «лог -> трейс»
        )
        ctx := context.WithValue(r.Context(), loggerKey{}, l)
        w.Header().Set("X-Request-Id", reqID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// 6) LogAttrs идёт без аллокаций, для горячих мест
logger.LogAttrs(ctx, slog.LevelDebug, "cache lookup", slog.String("key", k))

With возвращает логгер с предвычисленными атрибутами: встроенные хендлеры форматируют их один раз, прямо при вызове, поэтому каждая запись от такого логгера дешёвая, и он годится именно для «контекста, общего для многих записей». Группы дают вложенность в JSON: поля одной темы лежат под общим префиксом, в Elasticsearch это поле http.method, и оно не столкнётся с одноимённым полем из другой группы. А LogAttrs обходится без промежуточных аллокаций, если логирование стоит на горячем пути.

Уровни: когда какой

УровеньСмыслПримерыВ проде
DEBUGподробности для разработчикасодержимое структуры, попадание в кэш, шаги алгоритмавыключен; включается точечно и временно
INFOзначимое событие нормальной работыстарт/остановка сервиса, версия, изменение конфигурации, бизнес-событие («заказ оплачен»)включён, но экономно
WARNаномалия, с которой справилисьретрай удался со второго раза, деградация на фолбэк, приближение к лимитувключён; никого не будим
ERRORоперация не выполнена, кто-то пострадалзапрос завершился 5xx, сообщение ушло в DLQ, не смогли записать в БДвключён; повод для алерта по частоте, а не по каждой строке
Три вечных ошибки с уровнями
  • ERROR на ожидаемое. «Пользователь не найден» при 404 идёт в INFO или вообще никуда. Если ERROR горит постоянно, дежурный перестаёт на него смотреть, и настоящая ошибка тонет.
  • Логирование ошибки на каждом уровне стека. Один сбой превращается в пять записей, и по количеству ERROR невозможно понять масштаб. Правило: ошибку либо обрабатывают, либо оборачивают и возвращают выше, а логирует её только тот, кто принял решение (обычно middleware верхнего уровня).
  • DEBUG в проде «на всякий случай». Это дорого (диск, сеть, деньги за индексацию), это тормозит горячий путь и это главный источник утечки чувствительных данных.

Что логировать в проде и чего в логах быть не должно

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

Должно быть
  • timestamp в UTC, RFC3339 с миллисекундами
  • level и msg, причём msg постоянный, переменное уходит в атрибуты (иначе не сгруппируешь)
  • service, version, env, instance/pod
  • request_id и trace_id/span_id
  • идентификаторы сущностей: order_id, user_id (внутренний!)
  • исход: status, duration_ms, route (шаблон)
  • для ошибок: тип или код ошибки, а на ERROR ещё и источник (AddSource)
  • события жизненного цикла: старт с версией, получен SIGTERM, завершение shutdown
Не должно быть никогда
  • пароли, токены, ключи API, заголовок Authorization, куки сессии
  • номера карт, CVV, полные реквизиты, за которыми стоит ещё и PCI DSS
  • персональные данные: email, телефон, адрес, ФИО, паспорт; за них спросят по GDPR и 152-ФЗ
  • тела запросов и ответов целиком, потому что туда попадает всё перечисленное выше, а объём растёт непредсказуемо
  • полные URL с query-параметрами (в них попадают токены сброса пароля и поисковые запросы)
  • дампы структур через %+v, ведь сегодня в структуре нет PII, а завтра появилось поле

Обеспечивают это так: санитайзер на уровне Handler (как ReplaceAttr выше), потому что полагаться на дисциплину каждого места вызова нельзя; типы, которые не умеют печататься (свой тип Secret string с методами String()/LogValue(), возвращающими [REDACTED]), и тогда ни логгер, ни %v значение не покажут (но структуру с таким полем JSON-хендлер отдаст в encoding/json, и без своего MarshalJSON секрет уйдёт в лог); хеширование вместо значения, если нужно только сравнивать; и ретеншн: логи с любыми персональными данными живут строго ограниченное время.

Correlation id и связь с трейсом

request_id и trace_id решают одну задачу, «собрать все записи об одном запросе», но живут на разных уровнях. Request id рождается на входе (обычно ingress или первый сервис), едет заголовком X-Request-Id и возвращается клиенту в ответе, чтобы пользователь из саппорта мог назвать его и по нему нашлось всё. Trace id задан W3C-стандартом traceparent, живёт вместе со спанами и понятен системе трейсинга.

Правильно писать оба в каждую запись лога. Тогда работают оба перехода: из трейса, где виден медленный спан, идёшь в логи по trace_id; из жалобы пользователя с request id идёшь в трейс и дальше в логи всех сервисов. Технически это одна строчка в middleware: достать trace.SpanContextFromContext(ctx) и положить оба поля в контекстный логгер. От кода требуется одно: context.Context должен передаваться везде. Как только его потеряли (например, ушли в горутину без него), связь рвётся, и запись выпадает из картины.

Prometheus: pull-модель и типы метрик

Prometheus сам ходит за метриками: раз в 15–30 секунд делает HTTP GET на /metrics каждой цели, получает текстовый снимок текущих значений и кладёт в свою TSDB. Приложение ничего никуда не отправляет, оно просто держит счётчики в памяти и умеет их отрендерить.

Pull даётЧем платим
Автоматический health-check: цель не ответила - метрика up = 0. Отдельный мониторинг «жив ли сервис» не нужен.Нужен service discovery: в динамическом кластере адреса меняются постоянно (в k8s это решено из коробки).
Контроль частоты на стороне мониторинга. Приложение не может «залить» Prometheus частыми отправками: темп сбора задаёт он сам (от взрыва числа рядов это, правда, не защищает).Короткоживущие задачи не успевают быть опрошенными - для них нужен Pushgateway (и он же - известный источник граблей).
Простота приложения: счётчики в памяти, никаких очередей, ретраев и буферов при недоступности бэкенда.Сетевая связность «мониторинг → сервис»: за NAT, за файрволом, у клиента на площадке - не работает.
Тривиальная отладка: curl localhost:8081/metrics - и видно почти то же, что увидит Prometheus (exemplars и формат OpenMetrics - только с заголовком Accept).Дискретизация: всё, что произошло между скрейпами, видно только в агрегате. Всплеск gauge, который короче интервала, не виден совсем.

Отсюда же правило про счётчики: Prometheus устойчив к перезапускам именно потому, что counter только растёт, а функция rate() умеет распознать сброс на нуль при рестарте пода. Модель на этом и держится: можно потерять пару скрейпов и не потерять данные.

Четыре типа метрик

ТипЧто этоКогда использоватьКак считать
Counterмонотонно растущее число; сбрасывается только при рестартевсё, что случилось: запросы, ошибки, отправленные байты, обработанные сообщениявсегда через rate()/increase(), никогда «как есть»
Gaugeчисло, которое может расти и падатьвсё, что есть сейчас: горутины, соединения в пуле, длина очереди, температуранапрямую, плюс avg/max/min_over_time
Histogramнабор кумулятивных счётчиков по бакетам + _sum + _countдлительности и размеры, когда нужны перцентили по всему сервисуhistogram_quantile() по rate() бакетов
Summaryперцентили, посчитанные на стороне клиента, плюс sum и countкогда нужен точный перцентиль одного инстанса и заранее неизвестны бакетычитается напрямую; агрегировать между инстансами нельзя
Histogram против summary - самый частый подвопрос

Histogram считает только «сколько наблюдений попало в бакет ≤ X». Перцентиль считают на сервере в момент запроса, а значит, его можно получить по сумме всех инстансов, по любому срезу лейблов и за любое окно времени. Платишь приблизительностью (точность ограничена шириной бакета) и ростом числа временных рядов (по одному на бакет). Summary считает перцентиль прямо в приложении по скользящему окну. Погрешность у него задаётся в настройках и обычно мала, но он намертво привязан к инстансу: p99 десяти подов нельзя ни сложить, ни усреднить, потому что перцентиль не аддитивен. Плюс summary дороже по CPU в самом приложении. Практический вывод: почти всегда нужен histogram, а если сомневаешься, бери histogram.

Лейблы и взрыв кардинальности

Каждая уникальная комбинация имени метрики и значений лейблов заводит отдельный временной ряд со своим индексом, своими чанками в памяти и на диске. Кардинальность метрики в худшем случае равна произведению мощностей всех её лейблов, и растёт она мультипликативно.

# Хорошо: 8 маршрутов × 5 методов × 6 кодов = 240 рядов
http_requests_total{route="/users/{id}", method="GET", code="200"}

# Катастрофа: у user_id миллион значений
http_requests_total{path="/users/12345", user_id="u-12345", code="200"}
#   1 000 000 путей × 1 000 000 пользователей = ряды, которых физически не может быть.
#   Prometheus начнёт съедать память гигабайтами и умрёт по OOM,
#   а с ним ляжет весь мониторинг в момент, когда он нужнее всего.
Что нельзя класть в лейблы
  • Идентификаторы: user_id, order_id, session_id, trace_id, request_id.
  • Сырой URL вместо шаблона маршрута. /users/12345 заводит новый ряд на каждого пользователя; нужен /users/{id}.
  • Текст ошибки. err.Error() притащит в лейбл IP, айдишники и таймстемпы. Нужен короткий классифицированный код: timeout, conflict, invalid_input.
  • Email, IP-адрес, User-Agent дают то же самое плюс персональные данные в мониторинге.
  • Таймстемпы и версии в значениях. При каждом деплое старые ряды умирают, новые рождаются; лейбл version оправдан только там, где он реально нужен (канарейка), и с пониманием цены.

Что делать, если уже взорвалось: найти виновника (topk(10, count by (__name__)({__name__=~".+"})) и страница /tsdb-status в Prometheus), убрать лейбл в коде, а на время переходного периода отрезать его через metric_relabel_configs на стороне скрейпа. И запомнить правило: высокая кардинальность живёт в логах и трейсах, а не в метриках. Искать по order_id надо в логах, а долю ошибок смотреть в метриках.

Гистограммы, бакеты и откуда берётся p99

Гистограмма работает не как «распределение» из учебника. В Prometheus это набор обычных счётчиков: для каждой границы le хранится, сколько наблюдений оказалось не больше неё. Счётчики кумулятивные, поэтому le="+Inf" равен общему числу наблюдений. Ни одного исходного значения не сохраняется - и в этом весь фокус: 10 000 запросов сжимаются в шесть чисел, которые можно складывать между инстансами.

10 000 запросов, сохранённых как шесть кумулятивных счётчиков …_bucket{le="0.05"} 4200 …_bucket{le="0.1"} 8900 …_bucket{le="0.25"} 9700 …_bucket{le="0.5"} 9930 …_bucket{le="1"} 9975 …_bucket{le="+Inf"} 10000 Каждый бакет - обычный counter: «сколько наблюдений было НЕ БОЛЬШЕ этой границы». Счётчики кумулятивные и монотонные, поэтому их можно складывать по всем подам - и получать перцентиль по сервису целиком. Рядом лежат ещё _sum и _count - для среднего. histogram_quantile(0.99, …) считает так: 1. ранг = 0.99 × 10 000 = 9900 наблюдений 2. первый бакет, где счётчик ≥ 9900 → le = 0.5 (там 9930); предыдущий - le = 0.25 (там 9700) 3. значит, искомое значение лежит внутри интервала (0.25, 0.5] 4. ЛИНЕЙНАЯ интерполяция внутри бакета: 0.25 + (0.5 − 0.25) × (9900 − 9700) / (9930 − 9700) ≈ 0.467 с истина где-то здесь p99 ≈ 0.467 с - оценка 0 0.05 0.1 0.25 0.5 1.0 с Ошибка ограничена шириной бакета: реальный p99 - любое значение из (0.25, 0.5]. Функция предполагает, что внутри бакета значения распределены равномерно, а это почти никогда не так. Чем шире бакет, тем грубее ответ - отсюда и правила выбора границ.
p99 из гистограммы остаётся оценкой, а не измерением. Точность равна ширине бакета, в который попал перцентиль. Если все интересные значения проваливаются в один широкий бакет, histogram_quantile будет уверенно возвращать красивое, но бессмысленное число.
# Канонический запрос: сначала rate по бакетам, потом сумма по le, потом квантиль
histogram_quantile(0.99,
  sum by (le, route) (
    rate(http_request_duration_seconds_bucket{service="api"}[5m])
  )
)

Порядок здесь не декоративный: rate() нужен, чтобы счётчики стали «в секунду» и пережили рестарты подов; sum by (le) складывает бакеты всех инстансов, и именно это делает гистограмму агрегируемой; и только потом считается квантиль. Чаще всего забывают le в by, и тогда запрос вернёт пустой результат, а Prometheus 3 добавит к нему только предупреждение о пропавшем бакетном лейбле.

Как выбирать бакеты
  • От SLO, а не от красоты. Если цель «p99 < 300 мс», обязательно нужны границы вокруг 300 мс: .2, .25, .3, .4, .5. Тогда и перцентиль точен там, где важно, и запрос «доля запросов быстрее 300 мс» считается прямым делением бакетов, без всякой интерполяции.
  • Логарифмическая сетка по умолчанию: prometheus.DefBuckets (.005 … 10) годится для HTTP «в среднем по больнице», но для быстрого gRPC-сервиса с ответами по 2 мс от неё толку нет, всё упадёт в первый бакет. ExponentialBuckets(0.001, 2, 14) покрывает 1 мс … 8 с.
  • Считать цену. Каждый бакет заводит отдельный временной ряд и умножается на все остальные лейблы. 15 бакетов × 20 маршрутов × 5 методов = 1500 рядов с одной метрики, и ещё 200 добавят суммы и счётчики. Обычно 8–12 бакетов достаточно.
  • Хвост обязателен. Если самый большой бакет стоит на 1 с, а таймаут 30 с, то всё между ними попадает в +Inf, и p99, попав туда, упрётся в 1 с, сколько бы ни шли запросы на самом деле. Верхняя граница должна быть больше таймаута.
  • Native histograms (эксперимент с Prometheus 2.40, стабильны с 3.8, но включаются отдельной настройкой скрейпа) решают проблему выбора бакетов принципиально: экспоненциальные бакеты генерируются автоматически с заданной относительной точностью, и всё это в одном временном ряду.

Метрики HTTP-сервиса: RED и USE, перцентили против среднего

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

  • RED = Rate (частота), Errors (ошибки), Duration (длительность). Придумал Том Уилки. Это метод про сервис: что видит пользователь, снаружи. Отвечает на «сколько к нам приходит, сколько мы ломаем, как долго отвечаем».
  • USE = Utilization (утилизация, доля занятого времени), Saturation (насыщение, очередь тех, кто не поместился), Errors (ошибки). Придумал Брендан Грегг. Это метод про ресурс: про CPU, диск, пул соединений, то есть про то, что внутри.

Они дополняют друг друга и применяются в таком порядке: RED говорит, что плохо, а USE говорит почему. Пользователь жалуется на медленные ответы (это RED сказал: Duration выросла), и мы идём смотреть, какой ресурс насытился (это скажет USE: очередь к пулу БД длиной 40).

Про Saturation скажу отдельно, потому что именно её пропускают. Утилизация отвечает на вопрос «занят ли ресурс», а насыщение отвечает на «сколько народу стоит в очереди к нему». Разница принципиальная: диск, занятый на 100 %, ещё может справляться, если очередь к нему пустая; а вот очередь из десяти ожидающих уже больно бьёт, даже если утилизация 70 %. Бытовая аналогия: утилизация кассы в супермаркете показывает, сколько времени кассир пробивает товары, а насыщение показывает, сколько человек стоит в очереди. Покупатель чувствует второе.

RED - для каждого сервиса (Rate, Errors, Duration)
  • Rate считает запросы в секунду. sum(rate(http_requests_total[5m]))
  • Errors - доля неуспешных. sum(rate(...{code=~"5.."}[5m])) / sum(rate(...[5m]))
  • Duration показывает время ответа перцентилями.
USE - для каждого ресурса (Utilization, Saturation, Errors)
  • Utilization - доля времени, когда ресурс занят (CPU, диск).
  • Saturation меряет длину очереди к ресурсу: run queue, ожидание в пуле, лаг консьюмера.
  • Errors ловит ошибки самого ресурса: сетевые дропы, ошибки диска.

Есть ещё «четыре золотых сигнала» Google SRE: latency, traffic, errors, saturation. Это тот же RED плюс насыщение, и именно насыщение чаще всего забывают, хотя именно оно предсказывает проблему до того, как она станет видна пользователю. Пул соединений к БД, занятый на 95 %, означает «через десять минут всё встанет», а никакая доля ошибок этого ещё не покажет.

Конкретный минимум для Go-HTTP-сервиса:

// RED: одна гистограмма закрывает rate, errors и duration сразу,
// у histogram есть _count (это rate) и лейбл code (это errors).
httpDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
    Name:    "http_request_duration_seconds",
    Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},
}, []string{"route", "method", "code"})   // шаблон пути, не URL

// Saturation: предупреждает заранее
dbPoolInUse   = prometheus.NewGaugeFunc(..., func() float64 { return float64(db.Stats().InUse) })
inFlight      = prometheus.NewGauge(...)  // сколько запросов обрабатывается прямо сейчас
queueDepth    = prometheus.NewGauge(...)

// Плюс бесплатно из коробки: go_goroutines, go_memstats_*, go_gc_duration_seconds,
// process_cpu_seconds_total, process_open_fds, их даёт стандартный коллектор.

Почему среднее врёт

Перцентиль показывает место в строю: выстрой все замеры времени ответа за минуту по возрастанию, как детей по росту, и p99 окажется значением, которое стоит на 99-м месте из ста. p99 = 400 мс читается как «99 из каждых 100 запросов уложились в 400 мс, один не уложился», и при 1000 запросов в секунду это десять недовольных пользователей каждую секунду, 36 тысяч за час. Среднее же описывает распределение одним числом и потому пригодно только для симметричных распределений. Latency симметричной не бывает никогда: у неё есть жёсткий пол (быстрее сети не ответишь) и длинный правый хвост (ретраи, промахи кэша, паузы GC, блокировки в БД).

Совсем на пальцах: если в комнате девять человек с зарплатой 100 тысяч и один миллиардер, то средняя зарплата уйдёт в десятки миллионов, и это число не описывает никого из присутствующих. Медиана останется 100 тысяч, и вот она описывает девятерых. С временем ответа то же самое, только «миллиардеры» и есть те пользователи, которые пишут в поддержку.

Классический пример, который стоит уметь рассказать за десять секунд: 989 запросов по 10 мс и 11 запросов по 5 секунд. Считаем среднее: (989 × 10 мс + 11 × 5000 мс) / 1000 = 65 мс, то есть «всё отлично, укладываемся в SLO». А p99 покажет 5 секунд, потому что на 990-м месте в строю стоит уже пятисекундный запрос. Одна и та же минута трафика, два вывода противоположных. Правы те 11 человек, которые ждали пять секунд.

Второе, что делает среднее опасным: оно неустойчиво к выбросам и при этом их прячет. Если каждый пятидесятый запрос на полсекунды застревает в блокировке базы, среднее при обычных 50 мс сдвинется на 10 мс, и на графике это теряется в обычных колебаниях. p99 и p99.9 покажут это сразу, потому что они смотрят именно туда, в хвост.

  • p50 (медиана) описывает типичный опыт: «как обычно работает сервис».
  • p95/p99 показывают опыт худших пользователей. Именно они формируют мнение о продукте.
  • p99.9 и max - редкие аномалии: паузы GC, попадание в блокировку, «холодный» под после деплоя.
  • Правило хвоста: если страница делает 20 параллельных запросов к бэкенду, то вероятность, что ни один не попал в p99, равна 0.99²⁰ ≈ 82 %. То есть почти каждый пятый показ страницы увидит «одну сотую худших» запросов. Хвост здесь не редкий случай, а массовое явление.
Два запрета, которые надо назвать вслух
  • Нельзя усреднять перцентили. avg(p99 по подам) не значит ничего: перцентиль не аддитивен. Правильно будет сложить бакеты всех подов (sum by (le)) и посчитать квантиль от суммы. Именно поэтому histogram агрегируется, а summary нет.
  • Нельзя складывать p99 разных сервисов. «p99 gateway = p99 orders + p99 payments» неверно, потому что медленные запросы в разных сервисах происходят в разные моменты и на разных запросах. Сумма перцентилей не равна p99 цепочки: обычно она его завышает, но может и занизить; настоящий ответ на «где потерялось время» даёт не арифметика, а трейс.

SLI, SLO и error budget

Три аббревиатуры, которые звучат как юридический жаргон, а на деле описывают одну простую цепочку: что меряем → какую цифру считаем нормой → сколько нам разрешено сломать. Все три начинаются с SL, то есть Service Level, «уровень обслуживания». Дальше меняется только последняя буква.

  • SLI, Service Level Indicator, «показатель уровня обслуживания». Это то, что меряем: конкретная измеримая величина, отражающая качество глазами пользователя. Например: доля успешных запросов, доля запросов быстрее 300 мс, свежесть данных в отчёте. Держись за «глазами пользователя»: загрузка CPU показателем уровня обслуживания быть не может, потому что клиенту на неё наплевать.
  • SLO - Service Level Objective, «цель по уровню обслуживания». Это цифра, которую мы себе назначили для SLI на конкретном окне времени: «99.9 % успешных запросов за 30 дней». Внутренний инженерный документ, никаких юристов.
  • SLA, Service Level Agreement, «соглашение об уровне обслуживания». То же самое, но записанное в договор с клиентом, с деньгами и штрафами за нарушение. Инженерное SLO делают строже SLA, чтобы, пробив свою внутреннюю цель, успеть починиться до того, как придётся платить.

Error budget следует из SLO арифметически, и это переворот в мышлении, ради которого всё и затевалось. Если цель 99.9 %, то 0.1 % запросов разрешено завершиться неуспешно. За 30 дней это 43 минуты полной недоступности или, при 1000 rps, около 2.6 млн неуспешных запросов. Это не «допустимый брак», а бюджет на риск: ресурс, который тратится на выкатки, эксперименты и миграции. Бюджет цел, значит можно рисковать и катить смелее; бюджет сожжён, значит рискованные выкатки замораживают, пока окно не сдвинется.

SLOНедоступность в месяцВ неделюЧто это значит на практике
99 %7 ч 12 мин1 ч 41 минвнутренний инструмент
99.9 %43 мин10 минтипичный продовый сервис
99.95 %22 мин5 минтребует автоматики отката и дежурства
99.99 %4 мин 19 с1 минрезервирование, мультизона, очень дорого
Как это меняет разговор с продуктом

До SLO спор звучит так: «надо ускорять» - «нет, надо делать фичи», и побеждает тот, кто громче. С SLO разговор становится арифметическим: «бюджет ошибок за месяц израсходован на 80 %, из них 60 % съел один инцидент с миграцией, поэтому до конца окна рискованные выкатки замораживаем и чиним причину». Это меняет три вещи. Во-первых, у надёжности появляется цена и потолок: 100 % перестаёт быть целью, потому что каждая девятка стоит кратно дороже, и «недоиспользованный» бюджет означает, что команда двигается слишком осторожно. Во-вторых, у смены приоритетов есть объективный триггер, который не зависит от настроения. В-третьих, разговор идёт на языке пользователя, а не на языке CPU: SLO меряется по тому, что видит клиент, и это ровно то, что можно обсуждать с продактом.

Практическая деталь, которая показывает опыт: алертить надо не на «SLO нарушено» (это уже поздно), а на скорость сжигания бюджета с несколькими окнами: быстрое сжигание (14.4× за час) будит дежурного немедленно, медленное (3× за сутки) ложится задачей на утро. Многооконный подход одновременно ловит и резкие обвалы, и медленную деградацию, и почти не даёт ложных срабатываний.

Алертинг: симптомы, а не причины

Главное правило: алерт должен срабатывать на то, что чувствует пользователь, а не на то, что происходит внутри. «Доля 5xx выше 1 % пять минут» и «p99 оформления заказа выше 2 с» говорят о симптомах. «CPU 90 %», «мало места в /var», «перезапустился под» называют причины, и они не обязаны означать проблему.

Алерт на симптом
  • срабатывает, только когда реально плохо
  • не зависит от того, какая именно из десяти причин случилась
  • переживает переезд на новое железо и рефакторинг
  • один алерт вместо двадцати
Алерт на причину
  • CPU 90 % бывает штатной работой под нагрузкой
  • под перезапустился, и это нормальная жизнь кластера
  • один инцидент рождает шторм из 40 алертов от всех связанных систем
  • ловит только те причины, которые уже были

Одно исключение назвать стоит. Причины годятся для алертов, когда они предсказывают отказ с большим запасом времени и требуют действия заранее: «диск заполнится через 4 часа при текущем темпе», «сертификат истекает через 7 дней», «пул соединений занят на 95 %». Такие алерты идут не в звонок, а в тикет.

Алерт-фатиг и что с ним делать

Алерт-фатиг наступает, когда дежурный перестаёт реагировать, потому что 90 % срабатываний ничего не значат. Это опаснее отсутствия мониторинга: создаёт иллюзию контроля. Лечение:

  • Каждый алерт обязан требовать действия. Тест простой: «что я сделаю, когда он придёт в 4 утра?» Нет ответа, значит это не алерт, а график на дашборде.
  • Разделение срочности. Page (звонок, реагировать сейчас) / ticket (посмотреть в рабочее время) / dashboard-only. Большинство существующих алертов после честной ревизии переезжают вниз.
  • Ревизия по итогам дежурства. Регулярный разбор: какие алерты сработали, какие из них были ложными, что удалить. Алерт, который за квартал ни разу не привёл к действию, удаляем.
  • Группировка и подавление. Alertmanager: group_by, чтобы 50 подов дали одно уведомление; inhibit_rules, чтобы «нода недоступна» глушила все алерты про поды на ней; silence на время запланированных работ.
  • Runbook в каждом алерте. Ссылка на инструкцию «что проверить, что сделать» лежит прямо в тексте уведомления.
Что делать с флапающими алертами
  • for: лечит в первую очередь. Условие должно держаться непрерывно заданное время: for: 5m отсекает одиночные всплески. Поставишь мало, получишь флап; поставишь много, узнаешь поздно. Но он задерживает только срабатывание: горящий алерт гаснет на первой же проверке, где условие ложно. Задержку на выключение даёт keep_firing_for (Prometheus 2.42+).
  • Гистерезис. Порог включения и выключения должны различаться: зажигать при > 5 %, гасить при < 2 %. Иначе метрика, колеблющаяся вокруг единственного порога, будет слать «горит/потухло» каждые полминуты.
  • Окно усреднения шире. rate(...[5m]) вместо [1m] сглаживает шум; для редких событий короткое окно даёт скачки от нуля до бесконечности.
  • Минимальный объём. При 3 запросах в минуту одна ошибка даёт 33 % ошибок. Условие вида and sum(rate(requests[5m])) > 1 отсекает «статистику» на пустом трафике.
  • Многооконный burn rate вместо одного порога: длинное окно подтверждает, что бюджет сгорел заметно и это не всплеск, а короткое - что он горит прямо сейчас, поэтому после починки алерт гаснет за минуты, а не через час.
  • Если флапает по-настоящему, значит, система действительно балансирует на границе, и чинить надо систему, а не алерт. Заглушить проще всего, и это же худший вариант.

Distributed tracing: trace, span, propagation

Когда запрос проходит через шесть сервисов, ни метрики, ни логи не отвечают на вопрос «где потерялись 900 миллисекунд». Трейсинг отвечает: он собирает все операции одного запроса в дерево с временными интервалами. Нарисованное дерево спанов похоже на диаграмму Ганта: вложенные полоски разной длины, и сразу видно, какая из них съела 900 мс из секунды.

  • Trace покрывает весь путь одного запроса через систему; опознают его по trace_id (16 байт), одинаковому у всех участников.
  • Span закрывает одну операцию внутри трейса: HTTP-хендлер, запрос в БД, вызов соседа, кусок вычисления. У спана есть span_id, parent_span_id, имя, время начала и конца, статус, атрибуты (ключ-значение) и события.
  • Context propagation передаёт trace_id и span_id родителя следующему сервису. По стандарту W3C это HTTP-заголовок traceparent (и tracestate для вендорских данных). В Kafka это заголовки сообщения, в gRPC метаданные.
Один запрос - один trace_id на всю цепочку gateway orders payments postgres hdr hdr ctx traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 версия · trace-id: ОДИН на весь запрос · span-id ОТПРАВИТЕЛЯ (станет parent у получателя) · флаги: 01 = сэмплирован Потеряли заголовок на одном хопе - трейс разрывается на два несвязанных куска, и восстановить связь уже нечем. Что из этого собирает бэкенд трейсинга span 1 · gateway GET /orders/{id} · parent = ø (root) span 2 · orders.GetOrder · parent = span 1 span 3 · payments.Status · parent = span 2 span 4 · db.query · parent = 3 время эти 100 мс не покрыты ни одним спаном - либо код не проинструментирован, либо ждал в очереди Длина полосы = длительность спана. Вложенность = кто кого вызвал. Дыры между родителем и детьми - самое интересное место на всей картинке.
Trace context идёт вместе с запросом. Каждый сервис берёт traceparent из входящего запроса, делает свой спан ребёнком пришедшего span-id и кладёт уже свой span-id в исходящие вызовы. Дерево собирается на бэкенде постфактум, по trace_id и parent_span_id.
// Инструментирование в Go: контекст и есть носитель трейса
tp := sdktrace.NewTracerProvider(
    sdktrace.WithBatcher(otlpExporter),                       // батчинг: не по спану на запрос
    sdktrace.WithSampler(sdktrace.ParentBased(               // решение родителя главнее
        sdktrace.TraceIDRatioBased(0.05),                     // своих корневых 5 %
    )),
    sdktrace.WithResource(res),                               // service.name, version, env
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
    propagation.TraceContext{}, propagation.Baggage{},         // W3C traceparent
))

// Входящий HTTP: middleware достаёт traceparent и создаёт серверный спан
handler := otelhttp.NewHandler(mux, "api")

// Исходящий HTTP: транспорт сам кладёт traceparent в заголовки
client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}

// Свой спан вокруг значимой операции
func (s *Service) Charge(ctx context.Context, id string) error {
    ctx, span := otel.Tracer("billing").Start(ctx, "Charge")
    defer span.End()
    span.SetAttributes(attribute.String("order.id", id))       // высокая кардинальность здесь уместна

    if err := s.gw.Do(ctx, id); err != nil {                   // ctx передаём дальше, иначе разрыв
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        return err
    }
    return nil
}

Сэмплирование: зачем и какое

Хранить все трейсы невозможно: при 10 000 rps и десяти спанах на запрос это 100 000 спанов в секунду, то есть терабайты в сутки, и львиная доля из них описывает совершенно обычные успешные запросы, на которые никто никогда не посмотрит. Сэмплирование ищет компромисс между стоимостью и полнотой, и плата за него очевидна: конкретного запроса, который ты ищешь, в системе может не оказаться.

Head-basedTail-based
Когда решаемв самом начале, на первом сервисекогда трейс, по расчёту, завершился: коллектор выжидает заданное время после первого спана (по умолчанию 30 с)
Какпо trace_id, вероятностно; решение едет во флаге traceparentколлектор буферизует все спаны трейса и решает по результату
Плюсдёшево, ничего не буферизуется, решение согласовано между сервисамиможно сохранить все ошибки и все медленные запросы - то есть именно то, что нужно
Минусинтересные трейсы теряются наравне с обычными: ошибка попала в несохранённые 95 %дорого: коллектор держит спаны в памяти до решения, нужен stateful-слой

Практика: ParentBased обязателен в любом варианте. Решение принимают один раз на входе, и дальше его уважают все, иначе получатся «дырявые» трейсы, где часть спанов есть, а часть нет. Дальше берут либо простой TraceIDRatioBased(0.01…0.1), либо OpenTelemetry Collector с tail-based-политикой «сохраняем 100 % трейсов с ошибкой, 100 % медленнее 1 с и 1 % остальных». Отдельно стоит debug-флаг: заголовок от внутреннего клиента, который форсирует сэмплирование конкретного запроса, чтобы можно было воспроизвести проблему и гарантированно получить трейс.

Что ломает трейсинг на практике
  • Потерянный context.Context. Ушли в горутину с context.Background(), вызвали HTTP-клиент без ctx, положили задачу в пул без переноса контекста, и трейс обрывается. Это причина номер один.
  • Прокси, вырезающий заголовки. Строгие allow-list в API Gateway выкидывают traceparent - и цепочка распадается на границе.
  • Асинхронные границы. Через Kafka контекст надо руками положить в заголовки сообщения и достать на стороне консьюмера. Связывают спаны по умолчанию не parent-child, а через span link: консьюмер часто обрабатывает пачку сообщений из разных трейсов, а родитель у спана один (для одиночного сообщения конвенции OpenTelemetry допускают и родителя).
  • Синхронный экспорт. Отправишь спан в коллектор в том же запросе, и его латентность ляжет на пользовательскую. Только batch-экспортер, и только с ограничением очереди - иначе недоступный коллектор станет твоей утечкой памяти.
  • Кардинальность в имени спана. GET /users/12345 вместо GET /users/{id} - и агрегированная статистика по спанам превращается в мусор. Идентификаторы идут в атрибуты, не в имя.

Профили в проде: pprof и охота на утечки горутин

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

Профиль показывает, как некий ресурс распределён по строчкам кода прямо сейчас. Собирается он сэмплированием: рантайм не записывает каждое событие, а раз в N миллисекунд или каждые N килобайт останавливается и запоминает текущий стек вызовов. Накопив тысячи таких замеров, получаем статистику: «40 % замеров пришлись на json.Marshal». Поэтому профиль в проде почти бесплатен: единицы процентов накладных расходов, а не трассировка каждого вызова.

В Go профилировщик встроен в рантайм, включать нечего: достаточно импортировать net/http/pprof, и все профили появятся на служебном порту.

import (
    "net/http"
    _ "net/http/pprof"   // регистрирует /debug/pprof/* в http.DefaultServeMux
)

// Служебный порт отдельный от пользовательского и наружу не торчит:
// профиль отдаёт имена функций, аргументы командной строки и адреса, это разведданные.
go func() { _ = http.ListenAndServe("127.0.0.1:6060", nil) }()
ПрофильНа какой вопрос отвечаетКогда за ним идут
profile (CPU)какие функции занимали процессорCPU упёрся в лимит, выросла латентность
heapкакие функции держат живую память сейчасRSS растёт, приближается OOMKilled
allocsкакие функции аллоцировали за всё времявысокая нагрузка на GC при стабильном RSS
goroutineсколько горутин и где они стоятgo_goroutines растёт монотонно
goroutineleakкакие горутины заблокированы навсегдато же самое, но без ручного чтения стеков
block *где ждут на каналах и мьютексахCPU простаивает, а латентность высокая
mutex *кто, держа мьютекс, дольше всех заставляет ждать остальныхне масштабируется по ядрам

* Два профиля со звёздочкой по умолчанию выключены и не собираются, пока их не включишь явно: runtime.SetBlockProfileRate(n) и runtime.SetMutexProfileFraction(n). Оба стоят накладных расходов, поэтому их обычно включают только на время расследования: у мьютексов ставят большой делитель, у блокировок - редкую выборку, там параметр задаёт наносекунды простоя на одно событие. Остальные доступны без настройки.

# CPU за 30 секунд под реальной нагрузкой
go tool pprof -http=: "http://127.0.0.1:6060/debug/pprof/profile?seconds=30"

# Утечка памяти: два снимка с интервалом, дифф между ними покажет, что выросло
curl -s http://127.0.0.1:6060/debug/pprof/heap > h1.out
sleep 600
curl -s http://127.0.0.1:6060/debug/pprof/heap > h2.out
go tool pprof -http=: -base h1.out h2.out

# Горутины: debug=1 агрегирует по стекам, debug=2 даёт полный дамп со статусом каждой
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=1'
Go 1.27: профиль goroutineleak стал общедоступным

Классически утечку горутин ищут по графику go_goroutines: когда он монотонно растёт, вручную читают /debug/pprof/goroutine?debug=2, отыскивая среди тысяч стеков те, что висят в chan receive подозрительно долго. Работает, но это ручной труд с большой долей догадок: «висит 40 минут» ещё не значит «висит навсегда», так выглядит и обычный воркер, ждущий работы.

С Go 1.27 у этого есть штатный инструмент. Профиль goroutineleak (в 1.26 он был экспериментом за GOEXPERIMENT=goroutineleakprofile, в 1.27 включён всегда) находит горутины, заблокированные на примитивах синхронизации, до которых уже никто не может дотянуться. Канал, на котором висит получатель, недостижим из живого кода, значит, писать в него некому и получатель не проснётся никогда. Это не эвристика по времени, а факт, который устанавливает сборщик мусора по достижимости, той же логикой, которой он ищет мусор.

// Профиль виден в pprof.Profiles() наравне с heap и goroutine
p := pprof.Lookup("goroutineleak")
p.WriteTo(os.Stdout, 1)   // стеки: на чём стоят утёкшие горутины
fmt.Println(p.Count())    // считать после WriteTo: профиль наполняется при сборе

// вывод на программе с тремя утёкшими горутинами (go1.27):
// goroutineleak profile: total 3
// 3 @ 0x88148 0x220a0 0x21c24 0xdbd84 0x8df64
// #	0xdbd83	main.leak.func1+0x23	/tmp/x/main.go:12
//
// 3
// Адреса и пути свои у каждого запуска; значение имеет первая строка (сколько
// горутин утекло) и сгруппированный стек под ней. Count() до WriteTo вернул бы 0.

// И по HTTP, если импортирован net/http/pprof:
//   curl 'http://127.0.0.1:6060/debug/pprof/goroutineleak?debug=1'

На выходе обычный pprof-профиль со стеками: 3 @ … main.leak.func1, то есть «три утёкшие горутины, и все висят вот здесь». Разница с goroutine принципиальная: тот показывает все горутины и оставляет разбираться самому, а этот только те, которые точно уже не проснутся. Для собеса формулировка такая: «до 1.26 утечки горутин ловили runtime.NumGoroutine(), графиком go_goroutines и go.uber.org/goleak в тестах; с 1.27 в рантайме есть профиль goroutineleak, который делает это в проде и по факту недостижимости, а не по таймауту».

Что ещё изменилось в наблюдаемости рантайма в 1.26 и 1.27
  • Go 1.26: метрики планировщика в runtime/metrics. Общее число живых горутин (/sched/goroutines:goroutines) отдаётся оттуда с Go 1.16, а вот подробности раньше приходилось выуживать из GODEBUG=schedtrace=1000 или из трейса. Теперь это обычные метрики, которые можно отдать в Prometheus: /sched/goroutines-created:goroutines (сколько горутин создано с запуска) и разбивка живых по состояниям: /sched/goroutines/running:goroutines, /sched/goroutines/runnable:goroutines (готовы, но ждут процессора, то есть чистое насыщение из USE), /sched/goroutines/waiting:goroutines (ждут ввода-вывода или примитива синхронизации), /sched/goroutines/not-in-go:goroutines (внутри системного или cgo-вызова), плюс /sched/threads/total:threads (сколько потоков ОС держит рантайм). Растущий runnable при упёртом CPU и есть тот сигнал, ради которого в USE придумали saturation. В описании самих метрик есть оговорка: разбивка приблизительная и не обязана суммироваться в /sched/goroutines, потому что снимается без остановки мира; для тренда на графике годится, для арифметики нет.
  • Go 1.26: go tool pprof -http открывает flame graph по умолчанию, а не граф вызовов. Мелочь, но если на собесе описываешь «открываю pprof и вижу граф», то с 1.26 картинка другая.
  • Go 1.27: метки горутин попадают в заголовок трейсбека. Метки, которые ты ставишь через pprof.Labels + pprof.SetGoroutineLabels (ими же режут CPU-профиль по типам запросов), теперь печатаются при панике и в дампе горутин, если в go.mod стоит версия 1.27 или новее:
    goroutine 1 [running] {request_id: "abc-123", route: "/users/{id}"}:
    main.main()
    Из паники в логе сразу видно, какой запрос обрабатывала упавшая горутина; раньше это приходилось выцарапывать из соседних строк лога по времени. Отключается GODEBUG=tracebacklabels=0.
Грабли профилирования в контейнере
  • Порт pprof наружу открывает дыру. /debug/pprof/cmdline отдаёт командную строку со всеми флагами, профили выдают имена функций и структуру кода. Только localhost или отдельный порт за сетевой политикой.
  • CPU-профиль сам стоит CPU (единицы процентов на время сбора) и снимается ровно seconds секунд; на поде, который и так троттлится, это заметно.
  • Профиль снят не с того пода. Через Service попадёшь в случайную реплику, а течёт конкретная. kubectl port-forward pod/<имя> 6060:6060 цепляется к поду по имени.
  • Один снимок heap ничего не доказывает. Утечка проявляется как рост, поэтому смысл имеет только дифф двух снимков (-base).
  • Continuous profiling (Pyroscope, Parca, облачные profiler'ы) снимает профили постоянно и хранит историю: после инцидента можно посмотреть профиль того момента, а не «сейчас, когда всё уже починилось».

Health checks: /health против /ready

Два эндпоинта отвечают на два разных вопроса, и путать их дороже всего в этой главе (см. каскадный CrashLoopBackOff в главе 3.1).

/health (liveness)/ready (readiness)
Вопрос«процесс жив или его надо убить?»«можно ли слать мне трафик прямо сейчас?»
Последствие провалаперезапуск контейнерапод убирают из балансировки
Обратимонетда, автоматически
Проверятьпрактически ничего: 200 OK; максимум - «сердцебиение» внутренних воркеровпул БД, соединение с брокером, прогрет ли кэш, не идёт ли shutdown
Зависимостиникогдада - но осторожно и с разделением на обязательные и нет
// liveness: плоский ответ. Проверяем только то, что чинится перезапуском.
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
    if time.Since(worker.LastTick()) > 5*time.Minute {  // воркер завис, рестарт поможет
        http.Error(w, "worker stalled", http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
})

// readiness: реальная готовность. Значения обновляет фоновая проверка,
// чтобы проба не ходила в БД на каждый вызов kubelet.
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
    switch {
    case !ready.Load():                       // идёт старт или получен SIGTERM
        http.Error(w, "not ready", http.StatusServiceUnavailable)
    case !dbHealthy.Load():                   // обязательная зависимость
        http.Error(w, "db unavailable", http.StatusServiceUnavailable)
    default:
        w.WriteHeader(http.StatusOK)          // необязательные (кэш, рекомендации) не валят ready
    }
})
Почему нельзя проверять БД в liveness, и что развернуть на собесе

Потому что перезапуск процесса не чинит недоступную базу, а вреда наносит максимум. База легла на несколько минут → liveness падает одновременно у всех реплик (они видят одну и ту же базу) → kubelet убивает их все → поды поднимаются, снова не видят базу, снова умирают → CrashLoopBackOff: пауза перед каждым следующим перезапуском удваивается, 10 с, 20 с, 40 с и так до пяти минут. База вернулась, а сервис лежит дальше, до нескольких минут, потому что поды досиживают свою паузу. Плюс потеряны все установленные соединения, прогретые кэши и незавершённые запросы. Правильно так: БД идёт в readiness. Поды остаются живыми, пул переустанавливает соединения сам, и трафик возвращается ровно тогда, когда база оживёт, без единого рестарта.

Тонкости, которые отличают продуманный health check
  • Отдельный порт и отдельный http.Server. Если пробы обслуживает тот же сервер, что забит пользовательскими запросами, они начнут падать по таймауту именно тогда, когда сервис под нагрузкой, и добьют его перезапусками.
  • Проба должна быть дешёвой. Фоновая горутина раз в 5 секунд проверяет зависимости и обновляет атомарные флаги; хендлер только читает флаги.
  • Разделять обязательные и необязательные зависимости. Redis-кэш недоступен, но сервис умеет ходить в БД напрямую, значит, это degraded, а не not ready. Уронив ready из-за необязательной зависимости, ты своими руками превращаешь частичную деградацию в полный отказ.
  • readiness стоит ронять при SIGTERM, до того как сервер начнёт закрываться. Из балансировки Kubernetes выводит удаляемый под и сам, без всякой пробы, но 503 увидят и балансировщики, которые проверяют здоровье пода сами. Это первая строчка graceful shutdown.
  • Не делать health-эндпоинты публичными и не отдавать в них внутренние детали (версии, адреса, статусы зависимостей) без авторизации: это разведданные.
  • Не проверять зависимость зависимости. Если твой ready ходит в соседний сервис, а тот в свой, то один сбой внизу цепочки выведет из ротации всех, и получится каскад. Проверяем только то, без чего мы сами не работаем.

Дашборд сервиса: что должно быть на первом экране

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

Первый экран: RED + насыщение + бюджет ошибок RATE - запросов в секунду 1 240 rps стек по коду ответа, аннотации деплоев ERRORS - доля неуспешных 0.42 % · 5xx отдельно 4xx: ломается контракт, не сервис DURATION - перцентили 18 / 120 / 410 мс p50 / p95 / p99, линия SLO пунктиром SLO 99.9 % за 30 дней бюджет: 62 % burn rate за 1 ч и за 6 ч SATURATION - предупреждает заранее пул БД 78 %, in-flight 84, очередь 12, горутины 4.2k, CFS-троттлинг 0 % ЗАВИСИМОСТИ - их RED, нашими глазами p99 запросов в БД, ошибки соседних сервисов, лаг консьюмера Kafka РАЗБИВКА по маршрутам топ по времени и по ошибкам - отвечает на «кому именно плохо» РЕСУРСЫ подов RSS против limit, CPU против limit, рестарты, OOMKilled, число реплик СОБЫТИЯ деплои, миграции, переключения флагов - вертикальными линиями на ВСЕХ графиках Правило первого экрана: он отвечает на «плохо или нет и кому», а не «почему». Причина - на втором экране и в трейсах. Каждая панель должна быть кликабельна вниз: график → экземпляры трейсов → логи с тем же trace_id.
Скелет дашборда сервиса. Наверху RED, то, что видит пользователь. Посередине бюджет ошибок и насыщение: они предсказывают будущее. Внизу разбивки и события, с которых начинается расследование.
Правила, по которым дашборд остаётся полезным
  • Единое время на всех панелях и общий диапазон, иначе сравнивать графики между собой нельзя.
  • Аннотации деплоев стоят дёшево и окупаются лучше всех. Половина расследований заканчивается на «а, вот тут был релиз».
  • Пороги и линии SLO на графиках, чтобы не держать цифры в голове.
  • Одна страница на сервис, а не двадцать панелей всего на свете. Дашборд, который надо скроллить пять экранов, ночью не читается.
  • Дашборды как код. JSON в git (Grafonnet, terraform-provider-grafana), а не «Вася накликал и уволился».
  • Ссылки: дашборд → runbook → трейсы → логи. Инструмент ценен ровно настолько, насколько быстро с него удаётся спуститься на уровень ниже.

Вопросы

16
Суть: метрика говорит, что сломалось и насколько, трейс - где в цепочке, лог - что именно произошло. Границы: метрики не знают ничего про конкретный запрос (в них нет и не должно быть user_id), логи не дают общей картины и врут при сэмплировании, трейсы неполны из-за сэмплирования и слепы там, где код не проинструментирован.

Каждый в отдельности

  • Метрики хранят числовые ряды, агрегаты по времени. Дёшевы и, что важнее, предсказуемо дёшевы: стоимость не зависит от rps, потому что хранишь ты не события, а счётчики. Отсюда их роль: обнаружение и алертинг. Упираются они в жёсткую кардинальность: как только захочешь разложить метрику по пользователям, система умрёт.
  • Логи состоят из дискретных событий с произвольным содержимым и произвольной кардинальностью. Дают точную причину: сообщение об ошибке, стек, значения полей. Ломаются на объёме (стоимость растёт линейно с трафиком) и на том, что общей картины не дают: по логам невозможно ответить «стало ли хуже, чем на прошлой неделе», а если логи сэмплируются, то счёт по ним ещё и неверен.
  • Трейсы собирают дерево спанов одного запроса с таймингами. Единственный инструмент, который отвечает на «где потерялось время» в распределённой системе. Стоят дорого, поэтому почти всегда сэмплируются: конкретного запроса пользователя может просто не оказаться. Плюс слепые зоны: то, что не обёрнуто спаном, выглядит как дыра.

Чего не даёт каждый - то, ради чего задают вопрос

ИнструментСлепая зонаЧем закрывается
Метрики«почему упал этот заказ» - в метрике нет идентификаторовлоги по order_id, трейс по trace_id
Логитренды и сравнение с нормой; при сэмплировании - ещё и точность счётаметрики
Трейсыполнота (сэмплирование) и всё, что не проинструментированометрики для общей картины, tail-based сэмплирование для ошибок
Что действительно проверяют этим вопросом

Понимаешь ли ты, что три столпа складываются в лестницу, а не в три параллельные системы. Инцидент разбирают сверху вниз: алерт по метрике → дашборд → трейсы за этот интервал (в идеале прямо через exemplars, привязанные к точкам на графике) → логи по trace_id. Зрелую наблюдаемость узнают по одному признаку: между уровнями переходишь без ручного копирования айдишников. Три несвязанные системы дают не observability, а три вкладки, между которыми дежурный перепечатывает строки в четыре утра. Упомяни заодно четвёртый и пятый столпы: continuous profiling (какая строка кода жрёт CPU прямо сейчас) и события (деплои и миграции), без которых график «стало плохо в 14:03» не с чем сопоставить.

Суть: структурированный лог - это событие с типизированными полями, а не отформатированная строка. Его можно фильтровать, считать и связывать с трейсами. В log/slog архитектура из двух частей: Logger - фасад для места вызова, Handler - то, что решает формат, вывод, фильтрацию по уровню и санитайзинг. With добавляет постоянные поля, Group - вложенность.

Зачем структура

log.Printf("user %s failed: %v", id, err) нельзя ни отфильтровать по пользователю, ни посчитать «сколько таких за час», ни связать с трейсом. Остаётся грепать регулярками по чужому форматированию. Для бэкенда логов структурированная запись ({"level":"error","msg":"payment failed","user_id":"u-42","trace_id":"4bf9…"}) уже документ, по которому работают индексы. Отдельный нюанс: msg должен быть постоянным, а всё переменное уходит в атрибуты, иначе одинаковые по смыслу события не сгруппируются.

Архитектура slog

  • У Handler четыре метода (Enabled, Handle, WithAttrs, WithGroup). Именно он решает, какой будет формат (JSONHandler для прода, TextHandler для локальной разработки), какой минимальный уровень, добавлять ли source и как через ReplaceAttr централизованно вырезать секреты.
  • With(attrs...) возвращает новый Logger с предвычисленными атрибутами. Записи от него дешёвые (Handler получает атрибуты через WithAttrs, и встроенные хендлеры форматируют их один раз, прямо при этом вызове), поэтому это правильный способ носить контекст: service, version, request_id, trace_id.
  • Group заворачивает атрибуты во вложенный объект JSON: slog.Group("http", slog.String("method", …), slog.Int("status", …)) даёт "http": {"method": …, "status": …}. В бэкенде с маппингом полей получатся http.method и db.method: одна тема лежит под общим префиксом, и одноимённые ключи разных групп не сталкиваются, хотя полей в маппинге от этого не меньше.
  • LogAttrs(ctx, level, msg, attrs...) обходится без промежуточных аллокаций и потому годится для горячего пути.
  • LogValuer даёт типу самому решать, как он выглядит в логе. Тип Secret, чей LogValue() возвращает [REDACTED], прячет значение от логгера по построению, а не по дисциплине. Но структуру с таким полем JSON-хендлер отдаст в encoding/json, и без своего MarshalJSON секрет уйдёт в лог.
  • Контекстный логгер. Middleware кладёт логгер с полями запроса в context.Context, а нижние слои достают его оттуда, и trace_id попадает в каждую запись без ручной передачи.

Уровни: правило выбора

  • DEBUG нужен только разработчику и только на время. В проде выключен; slog.LevelVar позволяет включить его на лету через админ-эндпоинт, не перезапуская под. Приём выручает.
  • INFO отмечает значимые события нормальной работы: старт с версией, смену конфигурации, важные бизнес-события. Не «зашли в функцию».
  • WARN ставят на аномалию, с которой справились: ретрай удался, ушли на фолбэк, приблизились к лимиту. Никого не будим, но следим за частотой.
  • ERROR значит, что операция не выполнена и кто-то пострадал. Алертить надо по частоте ERROR, а не по каждой записи.
Три ошибки, за которые цепляются
  • ERROR на ожидаемое. 404 «пользователь не найден» ошибкой сервиса не считается. Постоянно горящий ERROR обесценивает уровень целиком.
  • Логирование одной ошибки на каждом уровне стека. Один сбой даёт пять записей, и масштаб проблемы уже не измерить. Правило: либо обработал, либо обернул и вернул выше; логирует тот, кто принял решение.
  • Логирование в горячем цикле. Даже отключённый по уровню вызов не бесплатен, если аргументы вычисляются: logger.Debug("x", "dump", expensive()) вызовет expensive() всегда - и LogAttrs тут не поможет, он экономит только аллокации на упаковке значений в интерфейс. От самого вычисления спасают LogValuer с ленивым вычислением и проверка logger.Enabled(ctx, slog.LevelDebug).
Суть: критерий один - по логам должно быть можно восстановить, что случилось с конкретным запросом, не имея доступа к данным пользователя. Значит: идентификаторы, тайминги, исход, коды ошибок, request_id и trace_id - да. Пароли, токены, PII, тела запросов и ответов целиком, полные URL с query - нет, и это не вопрос вкуса, а вопрос GDPR/152-ФЗ и PCI DSS.

Обязательный минимум записи

  • timestamp в UTC (RFC3339 с миллисекундами), level, постоянный msg.
  • service, version, env, pod, иначе в общем хранилище логов непонятно, чей это лог и какой версии.
  • request_id и trace_id/span_id.
  • Идентификаторы сущностей: order_id, внутренний user_id (внутренний UUID, не email и не телефон).
  • Исход операции: status, duration_ms, route как шаблон, размер ответа.
  • Для ошибок нужен классифицированный тип/код (timeout, conflict) плюс AddSource, чтобы знать файл и строку.
  • События жизненного цикла: старт с версией и конфигурацией (без секретов), получен SIGTERM, завершён shutdown. Именно это спасает, когда разбираешь странные рестарты.

Чего быть не должно - и почему это не паранойя

  • Секреты: пароли, токены, ключи API, Authorization, куки сессии. Лог с токеном даёт действующий доступ, и лежит он в системе, открытой всей команде и всем подрядчикам по мониторингу.
  • Платёжные данные: PAN и CVV в логе нарушают PCI DSS напрямую, вплоть до потери возможности принимать карты.
  • Персональные данные: email, телефон, адрес, ФИО, документы. Помимо закона есть практическая проблема: удалить пользователя по запросу «право на забвение» из логов почти невозможно.
  • Тела запросов и ответов целиком. Самая частая причина утечки: сегодня в теле безобидный JSON, завтра туда добавили паспорт. Плюс непредсказуемый объём: один эндпоинт с большим ответом может кратно увеличить счёт за логи.
  • Полные URL с query-параметрами: туда попадают токены сброса пароля, поисковые запросы, реферальные метки.
  • %+v по структуре даёт то же самое, только с отсрочкой: структура обрастёт полями, и никто не вспомнит про лог.
Как это обеспечить технически, а не уговорами
  • Санитайзер в Handler (ReplaceAttr): вырезает поля по списку ключей независимо от места вызова. Дисциплина каждого разработчика не масштабируется.
  • Типы, которые не печатаются: type Secret string с String() и LogValue(), возвращающими [REDACTED]. Тогда ни логгер, ни %v значение не покажут (но структуру с таким полем JSON-хендлер отдаст в encoding/json, и без своего MarshalJSON секрет уйдёт в лог).
  • Хеш вместо значения, если нужно только сравнивать или группировать; маскирование (+7***4567), если нужно узнавать глазами.
  • Аллоу-лист заголовков при логировании HTTP, а не блок-лист: новый чувствительный заголовок не должен попадать в лог по умолчанию.
  • Ретеншн и разделение потоков: логи с любыми чувствительными полями уходят в отдельный индекс с коротким сроком хранения и узким доступом.
  • Проверка в CI: линтер на fmt.Sprintf с подозрительными именами полей и на логирование целых структур запроса.
Вторая половина ответа: объём

Из трёх систем логи самые дорогие, и растут они линейно с трафиком. Практики, которые реально помогают: одна итоговая запись на запрос (access log со всеми полями) вместо пяти промежуточных; сэмплирование повторяющихся записей (первые 10 одинаковых в секунду, дальше счётчик); DEBUG, включаемый динамически и точечно; и понимание, что считать по логам - значит делать метрику дорогим способом. Если строишь график из логов, скорее всего, тебе нужен counter.

Суть: request_id и trace_id решают одну задачу «собрать всё об одном запросе», но живут на разных уровнях: request id - человеческий, его отдают клиенту и называют в тикете; trace id - часть W3C traceparent, по нему собирается дерево спанов. Правильная практика - писать оба в каждую запись лога, и тогда работают переходы в обе стороны. Носитель обоих - context.Context; потеряли контекст - порвали связь.

Механика

  1. Генерируем на входе. Первый компонент (ingress или edge-сервис) проверяет X-Request-Id; если его нет, генерирует UUID. Доверять входящему значению от внешнего клиента можно только с валидацией (длина, алфавит), иначе получишь инъекцию мусора в логи.
  2. Кладём в контекст и в логгер. Middleware создаёт логгер slog.With("request_id", …, "trace_id", …) и кладёт его в context.Context. Все слои ниже берут логгер из контекста и получают поля автоматически.
  3. Возвращаем клиенту. X-Request-Id в заголовке ответа и в теле ошибки. Тогда пользователь из поддержки называет одну строку, и по ней находится всё.
  4. Прокидываем дальше. В исходящие HTTP-запросы кладём заголовками (traceparent добавляет otelhttp сам, если задан глобальный пропагатор, иначе заголовка не будет; X-Request-Id ставит свой middleware на транспорте), в gRPC метаданными, в Kafka заголовками сообщения.
// Транспорт сам кладёт request_id во все исходящие вызовы
type ridTransport struct {
    base http.RoundTripper
}

func (t ridTransport) RoundTrip(r *http.Request) (*http.Response, error) {
    if id := RequestIDFrom(r.Context()); id != "" {
        r = r.Clone(r.Context())
        r.Header.Set("X-Request-Id", id)
    }
    return t.base.RoundTrip(r)
}

client := &http.Client{
    Transport: otelhttp.NewTransport(ridTransport{http.DefaultTransport}),
}

Связь лога и трейса - в обе стороны

  • Трейс → логи. В UI трейсинга виден медленный или упавший спан; по его trace_id открываешь фильтр в логах и получаешь все записи всех сервисов по этому запросу. Grafana умеет делать это одной кнопкой (настройка trace to logs у источника данных Tempo).
  • Логи → трейс. Нашёл ошибку в логе и оттуда же открыл трейс. Работает только если trace_id есть в логе и этот трейс был сохранён при сэмплировании (см. флаг sampled в traceparent: логировать его тоже полезно, чтобы понимать, почему трейс не нашёлся).
  • Метрика → трейс. Exemplars: к точке гистограммы прикрепляется trace_id одного из наблюдений. Кликнул по всплеску на графике p99 и сразу оказался в конкретном медленном трейсе. Это лучший из существующих переходов, и его мало кто настраивает.
Где связь рвётся
  • Горутина без контекста. go doWork() вместо go doWork(ctx), и вся асинхронная часть выпадает из картины. Отдельная тонкость: если работа переживает запрос, нельзя просто передать ctx запроса, его отменят. Нужен context.WithoutCancel(ctx), он сохраняет значения и трейс, но не отмену.
  • Пулы и очереди задач. Задача, положенная в общий пул воркеров, теряет контекст, если его не сохранить в структуре задачи явно.
  • Прокси с allow-list заголовков вырезают traceparent и X-Request-Id на границе.
  • Разные имена полей в разных сервисах (request_id, requestId, rid) ломают фильтр в логах поперёк системы. Лечится общей библиотекой логирования и middleware на всю компанию.
Суть: pull даёт три вещи, которые в push приходится строить руками: бесплатный health-check (не ответил на скрейп - up = 0), контроль частоты на стороне мониторинга (приложение не может залить систему частыми отправками) и тривиальную отладку (curl /metrics показывает те же значения, что увидит Prometheus). Платим за это необходимостью service discovery и проблемой короткоживущих задач - для них есть Pushgateway.

Что даёт pull

  • Мониторинг доступности бесплатно. Синтетическая метрика up появляется сама: цель не ответила, значит, недоступна. В push-модели «сервис молчит» и «сервис умер» неразличимы без отдельного механизма heartbeat.
  • Нельзя завалить отправками. Частоту сбора определяет Prometheus. В push-модели взбесившийся сервис (или сотня новых подов) уложит приёмник, а это ровно тот момент, когда мониторинг нужнее всего. От взрыва числа рядов pull, правда, не защищает.
  • Простота приложения. Оно держит счётчики в памяти и умеет отрендерить текст. Никаких очередей, ретраев, буферов и «что делать, если бэкенд лежит».
  • Отладка и воспроизводимость. curl localhost:8081/metrics покажет те же значения, что видит мониторинг (exemplars - только с заголовком Accept для OpenMetrics). В push эту точку наблюдения приходится изобретать.
  • Естественная работа с несколькими потребителями: два Prometheus (прод и разработчик) независимо скрейпят одну цель, ничего не настраивая.

Чем платим

  • Service discovery обязателен. В динамическом кластере адреса меняются постоянно. В Kubernetes это решено из коробки (kubernetes_sd_configs, ServiceMonitor у оператора), вне его берут Consul, файлы, DNS.
  • Сетевая связность в нужную сторону. Мониторинг должен достучаться до цели: за NAT, за файрволом, на площадке клиента, в браузере пользователя pull не работает в принципе.
  • Дискретизация. Всё, что произошло между скрейпами, счётчик покажет только в агрегате. Пик длиной 5 секунд при интервале 30 секунд покажется не пиком, а вшестеро меньшим ростом среднего. Всплеск gauge такой длины не виден совсем.
  • Короткоживущие задачи могут завершиться раньше первого скрейпа.

Что делать с короткоживущими задачами

  1. Pushgateway работает как шлюз: задача перед смертью пушит туда метрики, Prometheus скрейпит шлюз. Работает, но у него есть неприятные свойства, о которых надо знать: значения живут вечно, пока их не удалят (метрика умершего задания продолжает отдаваться и выглядит как «всё хорошо»), сам шлюз становится точкой отказа и не даёт up по исходной задаче. Правило: Pushgateway берут только для батчей уровня сервиса, никогда для обычных сервисов и никогда как «мы хотим push».
  2. Для CronJob в Kubernetes часто честнее не метрики задачи, а метрики о задаче: kube_job_status_succeeded, время последнего успеха, и алерт «задача не завершалась успешно дольше N».
  3. Паттерн «время последнего успеха». Батч пишет batch_last_success_timestamp_seconds, а алерт смотрит time() - batch_last_success_timestamp_seconds > 26h. Это устойчивее, чем ловить сам факт запуска.
  4. OpenTelemetry Collector как компромисс: приложение шлёт OTLP push, коллектор отдаёт Prometheus-эндпоинт для скрейпа. Так живут serverless и всё, до чего мониторингу не дотянуться.
  5. remote_write отдаёт уже собранные данные в долговременное хранилище (Mimir, Thanos, VictoriaMetrics). Это не «push вместо pull»: модель сбора остаётся pull.
Чем добить

«Спор pull против push во многом исторический: OpenTelemetry по умолчанию push (OTLP), и это удобнее для serverless и для мультитенантных облаков. Но главное эксплуатационное свойство Prometheus держится именно на pull: мониторинг остаётся живым, когда всё остальное падает. Он ни от кого не зависит, ничего не принимает, и сервисы, которые сходят с ума, не завалят его отправками. Плюс он переживает рестарты: counter только растёт, а rate() умеет распознать сброс на нуль, поэтому потеря пары скрейпов не искажает данные.»

Суть: counter - «сколько раз случилось» (только растёт, читается через rate()); gauge - «сколько сейчас» (растёт и падает); histogram - распределение через кумулятивные бакеты, перцентиль считается на сервере и потому агрегируется между инстансами; summary - перцентили, посчитанные в приложении с настраиваемой погрешностью, но несуммируемые. Почти всегда нужен histogram.

Counter и gauge

  • Counter монотонно растёт и сбрасывается только при рестарте процесса. Смысл имеет не значение, а производная: rate(x[5m]) даёт «в секунду за последние 5 минут», increase(x[1h]) - «сколько за час». Читать абсолютное значение counter-а почти всегда ошибка. Счётчики безопасны при перезапусках подов именно потому, что rate() переживает сброс на нуль.
  • Gauge показывает мгновенный снимок: горутины, соединения в пуле, длина очереди, число реплик, свободное место. Здесь rate() бессмысленен, зато осмысленны max_over_time, avg_over_time, deriv() и predict_linear() («когда кончится диск»).
  • Частая ошибка: делать gauge из того, что должно быть counter-ом («текущее число ошибок в минуту, посчитанное приложением»). Тогда всё, что произошло между скрейпами, теряется, и данные зависят от того, как приложение считает окно. Правильно тут только counter с rate().

Histogram против summary - главная часть вопроса

HistogramSummary
Что хранитсясчётчики _bucket{le} + _sum + _countготовые квантили {quantile="0.99"} + _sum + _count
Где считается перцентильна сервере, во время запросав приложении, по скользящему окну
Агрегация между подамида: sum by (le) и затем квантильнет: перцентили нельзя ни складывать, ни усреднять
Точностьприблизительно, в пределах ширины бакетапогрешность задаётся в настройках и обычно мала
Гибкостьлюбой квантиль и любое окно задним числомтолько те квантили, что заданы в коде заранее
Цена в приложениидёшево: инкремент нескольких счётчиковдороже: скользящее окно и вычисления на каждое наблюдение
Цена в хранилищеряд на каждый бакетряд на каждый квантиль

Спрашивают всё это ради одного: перцентиль не аддитивен. Из «p99 первого пода = 100 мс» и «p99 второго = 200 мс» нельзя получить p99 сервиса никакой арифметикой. Histogram решает это тем, что хранит не перцентиль, а сырые кумулятивные счётчики. Их складывать законно, и уже из суммы считается общий перцентиль. Поэтому в мире с горизонтальным масштабированием summary почти всегда неправильный выбор: он отвечает на вопрос, который никто не задавал («какой p99 у пода номер 7»).

Когда summary всё-таки уместен: одиночный инстанс (агент, демон на ноде), заранее неизвестный диапазон значений, нужна точность, а агрегировать не надо. И не забудь native histograms (эксперимент с Prometheus 2.40, стабильны с 3.8, но включаются отдельной настройкой скрейпа): они снимают главный недостаток гистограмм, подбирая экспоненциальные бакеты автоматически с заданной относительной точностью и укладывая всё в один временной ряд.

Практические детали
  • У histogram есть _count и _sum, поэтому одна гистограмма длительности запросов бесплатно даёт rate (rate(..._count[5m])) и среднее (rate(_sum)/rate(_count)). Вместе с лейблом code она закрывает весь RED одной метрикой.
  • Не путать rate и irate: irate берёт две последние точки и годится для быстрых графиков, но для алертов он слишком дёрганый.
  • Counter должен называться с суффиксом _total, единицы измерения берутся базовые (секунды, байты, а не миллисекунды и мегабайты). Это соглашение, на которое опираются все готовые дашборды и правила.
Суть: каждая уникальная комбинация значений лейблов - это отдельный временной ряд со своим индексом и своими чанками в памяти. Кардинальность метрики в худшем случае равна произведению мощностей её лейблов, поэтому растёт мультипликативно и очень быстро. Один лейбл с user_id превращает 200 рядов в миллионы и убивает Prometheus по OOM - ровно в тот момент, когда он нужнее всего.

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

# Норма: 8 маршрутов × 5 методов × 6 кодов = 240 рядов
http_requests_total{route="/users/{id}", method="GET", code="200"}

# Добавили «на всякий случай» user_id и сырой path:
http_requests_total{path="/users/12345", method="GET", code="200", user_id="u-12345"}
#   1 000 000 путей × 1 000 000 user_id × 5 × 6 ≈ рядов больше, чем атомов в бюджете.
#   На практике: через час Prometheus занимает 40 ГиБ, свопится, потом OOMKilled.
#   Мониторинг мёртв. Инцидент разбирать нечем.

Порядок величин, полезный для ответа: один активный ряд стоит примерно 1–4 КиБ оперативной памяти в Prometheus (индекс + текущий чанк). Миллион рядов съедает несколько гигабайт только под «сейчас», плюс диск, плюс замедление всех запросов, потому что каждый селектор идёт по раздутому инвертированному индексу. Отдельно бьёт churn: ряды, которые постоянно рождаются и умирают (лейбл с версией пода, с id задачи), плохи даже при небольшом мгновенном числе, потому что индекс растёт кумулятивно за весь период хранения.

Что нельзя класть в лейблы

  • Идентификаторы: user_id, order_id, session_id, request_id, trace_id.
  • Сырой URL вместо шаблона маршрута. Нужен /users/{id}, а не /users/12345. И осторожно с шаблонами: если маршрут не найден, многие библиотеки подставляют сырой путь - и получается взрыв от сканера уязвимостей, который перебирает тысячи несуществующих путей. Лечится подстановкой route="__unmatched__" для 404.
  • err.Error() целиком. В тексте ошибки живут айдишники, IP и таймстемпы, а нужен короткий классифицированный код: timeout, conflict, invalid_input.
  • Email, IP, User-Agent дают кардинальность и заодно тащат персональные данные в мониторинг.
  • Всё, что меняется при каждом деплое, если это не нужно осознанно (лейбл version оправдан для канареечного анализа, но за него платят).

Как чинить

  1. Найти виновника. Страница /tsdb-status в Prometheus показывает топ метрик и топ лейблов по кардинальности. Запрос topk(10, count by (__name__)({__name__=~".+"})) делает то же самое на скорую руку. Ещё помогает promtool tsdb analyze.
  2. Убрать лейбл в коде. Только это и чинит по-настоящему.
  3. Отрезать на скрейпе как временная мера: metric_relabel_configs с действием labeldrop или drop по всей метрике. Работает мгновенно и не требует релиза. Но ряды, которые без лейбла совпали, Prometheus молча схлопнет в один, и значения остальных пропадут.
  4. Поставить ограничения как страховку: sample_limit и label_limit в конфиге скрейпа. Цель, которая начала отдавать миллион рядов, будет отброшена целиком, и упадёт только она, а не весь мониторинг.
  5. Перенести измерение туда, где ему место. Разрез по конкретному пользователю смотрят в логе или трейсе, не в метрике. А «топ-10 медленных запросов» даёт трейсинг с tail-based сэмплированием.
Формулировка, которая закрывает вопрос

«Метрики работают с ограниченным алфавитом измерений. Их сила именно в том, что стоимость не зависит от трафика, а расплата - в том, что нельзя добавлять произвольные разрезы. Поэтому я держу правило: лейбл допустим, если множество его значений конечно, невелико и известно заранее: методы, коды, маршруты, имена очередей. Всё остальное живёт в логах и трейсах, а переход туда дают exemplars и trace_id, не лейблы.»

Суть: RED (Rate, Errors, Duration) - методология для сервиса, то есть для того, что видит пользователь. USE (Utilization, Saturation, Errors) - для ресурса: CPU, диск, пул соединений. Они дополняют друг друга: RED говорит, что плохо, USE - почему. Плюс «четыре золотых сигнала» = RED + saturation, и именно saturation предсказывает проблему заранее.

RED - обязательный минимум любого сервиса

# Rate
sum(rate(http_requests_total{service="api"}[5m]))

# Errors: доля, а не абсолютное число. 100 ошибок при 1 rps и при 10 000 rps значат разное
sum(rate(http_requests_total{service="api",code=~"5.."}[5m]))
  / sum(rate(http_requests_total{service="api"}[5m]))

# Duration
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))

Практический трюк: одна гистограмма закрывает весь RED. У http_request_duration_seconds есть _count (это rate) и лейбл code (это errors), плюс сами бакеты (это duration). Одна метрика с тремя лейблами (route, method, code) собирает весь верхний ряд дашборда.

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

  • Utilization показывает долю времени, когда ресурс занят: rate(process_cpu_seconds_total[5m]), использование диска.
  • Saturation меряет длину очереди к ресурсу: run queue, ожидание в пуле соединений, лаг консьюмера, глубина очереди задач, container_cpu_cfs_throttled_seconds_total.
  • Errors считает ошибки самого ресурса: сетевые дропы, ошибки диска, отказы в выдаче соединения из пула.

Что конкретно инструментировать в Go-сервисе

ГруппаМетрикиЗачем
Входящиеhttp_request_duration_seconds (histogram, лейблы route/method/code), http_requests_in_flight (gauge), размер ответавесь RED + насыщение обработчика
Исходящиете же три для каждого клиента: БД, соседние сервисы, кэш«тормозим мы или зависимость» - самый частый вопрос инцидента
Пулыdb.Stats(): InUse, Idle, WaitCount, WaitDurationпредсказывает деградацию за минуты до неё
Очереди/консьюмерылаг, длина очереди, время обработки сообщения, число ретраев и попаданий в DLQасинхронную часть RED не покрывает
Рантайм (бесплатно)go_goroutines, go_memstats_heap_inuse_bytes, go_gc_duration_seconds, process_open_fdsутечки горутин, памяти и дескрипторов
Бизнесоплаченные заказы, зарегистрированные пользователи, доля пустых выдачловит то, что технически «зелёное», но сломано по сути
Метаbuild_info{version, commit}«какая версия сейчас в проде» и аннотации деплоев
Типичные пробелы
  • Меряют только входящие запросы. Тогда на инциденте невозможно отличить «мы тормозим» от «нас тормозит база», и расследование начинается с догадок.
  • Забывают saturation. Rate, errors и duration чаще работают постфактум: показывают уже случившееся. Пул на 95 % - сигнал, который приходит до отказа.
  • Нет метрик для фоновой части. Консьюмеры, крон-задачи и воркеры обычно вообще не покрыты, и про их отказ узнают через сутки по жалобам.
  • Ошибки считают абсолютным числом, а не долей. Алерт «больше 100 ошибок в минуту» срабатывает и на реальный сбой, и на нормальный рост трафика.
  • 4xx смешивают с 5xx. Рост 4xx означает обычно сломанный клиент или изменённый контракт. Тоже инцидент, но другого рода и с другим адресатом.
Суть: среднее описывает распределение одним числом и годится только для симметричных распределений. Latency симметричной не бывает никогда: жёсткий пол снизу и длинный хвост справа. Пример: 989 запросов по 10 мс и 11 запросов по 5 с дают среднее 65 мс («отлично») при p99 5 секунд - и это те пользователи, которые пишут в поддержку. Отдельно: перцентиль не аддитивен, поэтому его нельзя ни усреднять между подами, ни складывать между сервисами.

Почему среднее скрывает именно то, что важно

  • Форма распределения. У latency есть физический минимум (сеть, диск) и нет максимума: ретраи, промахи кэша, паузы GC, блокировки в БД, «холодный» под после деплоя. Распределение всегда с длинным правым хвостом, а среднее в таком распределении даёт величину, которую не испытывает почти никто.
  • Мультимодальность. Реально там обычно две горки: попадания в кэш (2 мс) и промахи (200 мс). Среднее 40 мс не соответствует ни одному реальному запросу.
  • Устойчивость к деградации. Если 1 % запросов стал в 50 раз медленнее, среднее вырастет на десятки процентов - и это легко списать на шум. p99 вырастет в разы, и это видно сразу.
  • Хвост не редкость, а массовое явление. Если страница делает 20 обращений к бэкенду, вероятность, что ни одно не попало в «худший процент», равна 0.99²⁰ ≈ 82 %. То есть почти каждый пятый показ страницы сталкивается с p99. При 50 обращениях получается 1 − 0.99⁵⁰ ≈ 40%, а до половины доходит только к 69 обращениям.

Что смотреть вместо: p50 даёт типичный опыт; p95/p99 показывают опыт худших пользователей, по которому и формируется мнение о продукте; p99.9 и max ловят аномалии (GC-пауза, блокировка, только что стартовавший под). Лучше держать три линии сразу: расхождение p50 и p99 само по себе диагностично. Ровное p50 при растущем p99 означает «проблема у части запросов» (одна нода, один шард, один тип запроса), а одновременный рост обоих читается как «плохо всем, ищи общий ресурс».

Почему нельзя усреднять перцентили

Перцентиль считается как порядковая статистика, а не как сумма или доля. Из p99 отдельных подов невозможно вывести p99 сервиса никакой арифметикой: среднее из «100 мс» и «200 мс» даёт 150 мс, тогда как настоящий p99 объединённой выборки зависит от того, сколько запросов обработал каждый под и как распределены их хвосты. Он может оказаться и 105, и 195 мс, но за пределы 100–200 мс не выйдет.

# Неправильно: усреднять уже посчитанные перцентили
avg(histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m])))

# Правильно: сначала складываем бакеты всех подов, потом считаем квантиль от суммы
histogram_quantile(0.99,
  sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)

Поэтому histogram и агрегируется, а summary нет: histogram хранит складываемые счётчики, summary хранит уже вычисленный несуммируемый ответ.

Почему нельзя складывать p99 разных сервисов

Фраза «p99 gateway = p99 orders + p99 payments» неверна, и по двум причинам сразу. Во-первых, медленные запросы в разных сервисах случаются в разные моменты и с разными запросами: тот запрос, который попал в хвост orders, почти наверняка не тот, который попал в хвост payments. Сумма перцентилей поэтому обычно завышает реальный p99 цепочки, хотя может и занизить. Во-вторых, вызовы могут идти параллельно, и тогда общее время не сумма, а максимум.

На вопрос «где потерялось время» отвечает не арифметика по метрикам, а трейс: он показывает распределение времени внутри конкретных медленных запросов. Метрики говорят «стало хуже», трейс говорит «вот здесь».

Чем добить
  • Перцентиль зависит от окна. «p99 за 5 минут» и «p99 за час» дают разные величины: короткий всплеск в часовом окне размывается, зато потом ещё час держит часовой p99 выше пятиминутного. Сравнивать можно только одинаковые окна, и это частая ошибка при разборе «а вчера было лучше».
  • Среднее всё-таки полезно, но для другого: rate(_sum)/rate(_count) даёт среднюю латентность, а rate(_sum) - суммарное потраченное время. Второе и отвечает на вопрос «на что уходит ресурс»: по закону Литтла это ровно конкурентность, то есть rps, умноженные на среднюю латентность.
  • SLO лучше формулировать долями, а не перцентилями: «99 % запросов быстрее 300 мс» вместо «p99 меньше 300 мс». Математически это почти одно и то же, но первое считается прямым делением бакетов, без интерполяции, и потому точнее и агрегируется тривиально.
Суть: гистограмма не хранит ни одного исходного значения - только кумулятивные счётчики «сколько наблюдений было ≤ le». histogram_quantile находит бакет, в который попадает нужный ранг, и линейно интерполирует внутри него. Отсюда приблизительность: точность равна ширине бакета, а предположение о равномерности внутри бакета почти всегда неверно. Бакеты выбирают от SLO, а не от красоты.

Алгоритм по шагам

Пусть в окне 10 000 наблюдений и такие бакеты: le=0.05 → 4200, le=0.1 → 8900, le=0.25 → 9700, le=0.5 → 9930, le=1 → 9975, +Inf → 10000.

  1. Ранг: 0.99 × 10000 = 9900.
  2. Счётчик ≥ 9900 впервые встречается у le=0.5 (там 9930), предыдущий бакет le=0.25 (9700).
  3. Значит, искомое значение лежит внутри (0.25, 0.5].
  4. Линейная интерполяция: 0.25 + (0.5 − 0.25) × (9900 − 9700) / (9930 − 9700) ≈ 0.467 с.

Почему приблизительный: внутри бакета лежит 230 наблюдений, и о них известно ровно одно - что все они в интервале (0.25, 0.5]. Функция предполагает, что они распределены равномерно, а в реальности latency скошена, и значения прижаты к левому краю. Реальный p99 тут может быть любым числом из (0.25, 0.5]: из-за перекоса он скорее ниже 0.467, но насколько, по бакетам не узнать.

Три случая, когда ответ становится совсем неверным
  • Перцентиль попал в самый широкий бакет. Если бакеты 0.1, 1, 10, а p99 внутри (1, 10], то «истина» лежит где угодно в девятисекундном интервале. Число вернётся красивое и бессмысленное.
  • Перцентиль попал в +Inf. Тогда histogram_quantile возвращает верхнюю конечную границу (последний le) - то есть заведомо заниженное значение. Верхний бакет обязан быть больше таймаута, иначе p99 будет вечно «упираться в потолок» и выглядеть стабильным.
  • Слишком мало наблюдений. На 30 запросах в окне p99 по сути показывает бакет самого медленного запроса, то есть чистый шум. Для перцентилей нужен объём; на низком трафике честнее смотреть максимум или расширять окно.

Как выбирать бакеты

  • От SLO. Цель «p99 < 300 мс» → обязательно нужны границы 0.2, 0.25, 0.3, 0.4, 0.5. Тогда, во-первых, перцентиль точен именно там, где это важно, во-вторых, можно вообще уйти от интерполяции и считать долю напрямую: sum(rate(..._bucket{le="0.3"}[5m])) / sum(rate(..._count[5m])) даёт «долю запросов быстрее 300 мс». Это и есть правильная формулировка SLI.
  • Покрыть реальный диапазон. Дефолтные DefBuckets (0.005…10) годятся для «среднего HTTP», но для gRPC с ответами по 2 мс всё упадёт в первый бакет, а для тяжёлых отчётов уйдёт в +Inf. Посмотри на реальное распределение, прежде чем фиксировать границы.
  • Экспоненциальная сетка как разумный дефолт: prometheus.ExponentialBuckets(0.001, 2, 14) покрывает от 1 мс до ~8 с. Логарифмическое разрешение соответствует тому, как мы воспринимаем latency: разница между 10 и 20 мс важна так же, как между 1 и 2 с.
  • Считать цену. Каждый бакет становится временным рядом, умноженным на все остальные лейблы. 15 бакетов × 20 маршрутов × 5 методов = 1500 рядов с одной метрики, и ещё 200 добавят суммы и счётчики. Обычно достаточно 8–12 бакетов.
  • Смена бакетов рвёт историю. Старые ряды с прежними le остаются, новые появляются; сравнение «до и после» ломается. Поэтому границы стоит продумать один раз.
  • Native histograms (эксперимент с Prometheus 2.40, стабильны с 3.8, но включаются отдельной настройкой скрейпа) снимают эту проблему: экспоненциальные бакеты подбираются автоматически по заданной относительной точности, всё лежит в одном ряду, кардинальность не растёт.
Суть: SLI - измеримый показатель качества глазами пользователя (доля успешных запросов, доля быстрее 300 мс). SLO - целевое значение SLI на окне («99.9 % за 30 дней»). Error budget - арифметическое следствие: 0.1 % запросов разрешено сломать, это 43 минуты в месяц. Смысл конструкции в том, что она превращает спор «надёжность против фич» в расчёт: бюджет есть - выкатываем, бюджет кончился - чиним.

Три уровня

  • SLI. Хороший SLI меряется на границе с пользователем и выражается долей: успешные запросы / все запросы, запросы быстрее 300 мс / все запросы, свежесть данных < 5 мин. В плохие SLI попадают CPU, память, «сервис запущен»: пользователю от этого ни тепло, ни холодно.
  • SLO. Цель на окне: «99.9 % за 30 дней скользящим окном». Скользящее окно лучше календарного месяца, иначе первого числа бюджет магически обнуляется и первого числа же начинается вакханалия релизов.
  • SLA. То же самое в договоре с деньгами за нарушение. Внутреннее SLO делают строже SLA, чтобы был запас до штрафов.
SLOБюджет в месяцВ неделюЧто требуется
99 %7 ч 12 мин1 ч 41 минничего особенного
99.9 %43 мин10 миндежурство, автоматический откат
99.95 %22 мин5 минканареечные выкатки, резерв
99.99 %4 мин 19 с1 минмультизона, автоматика на всё, очень дорого

Эта таблица работает как аргумент. «Хотим 99.99 %» перестаёт быть красивой фразой, когда видно, что это четыре минуты в месяц на всё: на деплои, на миграции, на сбои облака, на человеческие ошибки. Обычно после этого разговор переходит к тому, какие именно операции критичны и какое SLO нужно им, а не сервису целиком.

Как это меняет разговор с продуктом

  1. Появляется общая единица измерения. Раньше спор был про ощущения («стало медленно» против «надо фичи»), теперь спорят про число: «бюджет израсходован на 80 %, из них 60 % съел инцидент с миграцией».
  2. У заморозки есть объективный триггер. Политику фиксируют заранее: бюджет исчерпан → заморозка рискованных выкаток, приоритет на надёжность, пока бюджет не восстановится. Это решение принято до инцидента и не зависит от того, кто сегодня громче.
  3. У надёжности появляется потолок. К 100 % стремиться не надо. Более того, неизрасходованный бюджет тоже плох: значит, команда движется слишком осторожно и могла бы быстрее поставлять ценность. Для многих это неожиданный аргумент.
  4. Разговор идёт на языке пользователя. SLO меряется по тому, что видит клиент, и потому обсуждаем с продактом без перевода. «Сколько заказов в месяц мы согласны потерять» звучит как вопрос, на который у продукта есть мнение.
Практические детали, которые показывают опыт
  • Алертить на burn rate, а не на нарушение SLO. Когда SLO нарушено, уже поздно. Многооконный подход: сжигание 14.4× за 1 час будит дежурного (при таком темпе месячный бюджет кончится за 2 дня); 3× за сутки даёт задачу на утро. Два окна одновременно ловят и обвал, и медленную деградацию, и почти не дают ложных срабатываний.
  • SLO на операцию, а не на сервис. «Оформление заказа» и «получение списка рекомендаций» имеют разную критичность; общий SLO по всем запросам размывает и то и другое.
  • Считать по доле запросов, а не по «минутам простоя». Полная недоступность случается редко; реальная деградация выглядит как «3 % запросов падает четыре часа», и в минутах простоя это не выражается.
  • SLO без последствий бесполезен. Если при исчерпании бюджета ничего не происходит, это просто ещё один график. Работает только зафиксированная и согласованная с продуктом политика.
Суть: алерт должен срабатывать на то, что чувствует пользователь: доля 5xx, latency, падение конверсии. Причины (CPU 90 %, перезапуск пода, рост очереди) сами по себе не означают проблему - они порождают шум, из которого рождается алерт-фатиг: дежурный перестаёт реагировать вообще. Главный тест для любого алерта: «что я сделаю, когда он придёт в 4 утра?» Нет ответа - это не алерт, а панель на дашборде.

Симптом против причины

  • Алерт на симптом срабатывает, только когда действительно плохо; он не зависит от того, какая именно из десяти возможных причин случилась; он переживает рефакторинг, переезд на новое железо и смену архитектуры; и он один, а не двадцать.
  • Алерт на причину ловит только те причины, которые уже были. «CPU 90 %» может быть штатной работой под нагрузкой; «под перезапустился» вообще обычная жизнь кластера; а один настоящий инцидент рождает шторм из сорока уведомлений от всех связанных систем, в котором тонет главное.
  • Законное исключение: причины годятся, когда предсказывают отказ с большим запасом и требуют действий заранее: «диск заполнится через 4 часа при текущем темпе» (predict_linear), «сертификат истекает через 7 дней», «пул соединений занят на 95 %». Такие алерты идут в тикет, а не в звонок.

Алерт-фатиг

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

  • Разделить по срочности: page (звонок, реагировать сейчас) / ticket (в рабочее время) / dashboard-only. После честной ревизии большинство существующих алертов переезжает вниз.
  • Пересматривать список после каждого дежурства: что сработало, что было ложным, что удалить. Алерт, который за квартал ни разу не привёл к действию, удаляем. Это не потеря, а возврат внимания.
  • Группировка и подавление в Alertmanager: group_by, чтобы 50 подов дали одно уведомление; inhibit_rules, чтобы «нода недоступна» глушила все алерты про поды на ней; silence на плановые работы.
  • Runbook в тексте алерта. Ссылка на «что проверить, что сделать». Алерт без инструкции стоит дежурному 20 минут на то, чтобы вспомнить контекст.
  • Метрики про сам алертинг: сколько сработало, сколько привело к действию, сколько ложных. Без этих цифр разговор о качестве алертов превращается в обмен мнениями.

Что делать с флапающими алертами

  1. for: идёт первым номером. Условие должно держаться непрерывно заданное время: for: 5m отсекает одиночные всплески. Поставишь мало, получишь флап; поставишь много, узнаешь поздно. Но он задерживает только срабатывание: горящий алерт гаснет на первой же проверке, где условие ложно. Задержку на выключение даёт keep_firing_for (Prometheus 2.42+).
  2. Гистерезис. Порог зажигания и погашения должны различаться: загорается при > 5 %, гаснет при < 2 %. Иначе метрика, колеблющаяся вокруг единственного порога, шлёт «горит/потухло» каждые полминуты.
  3. Шире окно усреднения. rate(...[5m]) вместо [1m]; для редких событий короткое окно швыряет значение между нулём и бесконечностью.
  4. Условие на минимальный объём. При 3 запросах в минуту одна ошибка даёт 33 %. Добавляем and sum(rate(http_requests_total[5m])) > 1, чтобы не алертить по статистике на пустом трафике.
  5. Многооконный burn rate вместо одного порога: длинное окно подтверждает, что бюджет сгорел заметно и это не всплеск, а короткое - что он горит прямо сейчас, поэтому после починки алерт гаснет за минуты, а не через час. Против ложных срабатываний ничего лучше пока не придумали.
  6. Если флапает по-настоящему, значит, система реально балансирует на границе, и чинить надо систему: добавить ресурсов, поправить пороги автоскейлера, убрать источник пилы. Заглушить алерт проще всего и хуже всего.
Чем добить

«Хорошему сервису хватает трёх-пяти алертов, а не пятидесяти. Обычно: доля ошибок выше SLO (по burn rate, два окна), latency выше SLO, сервис недоступен целиком (up == 0 или нет ни одной готовой реплики), плюс одна-две бизнес-метрики вроде “оплат за 10 минут меньше N”. Всё остальное уходит в дашборды и тикеты. Новый алерт я завожу по такому правилу: он появляется после инцидента и отвечает на вопрос “как бы мы узнали об этом на 20 минут раньше”, а не “давайте на всякий случай помониторим”.»

Суть: trace - весь путь одного запроса через систему, span - одна операция внутри него (с parent_span_id, таймингами, статусом и атрибутами), propagation - передача trace_id и текущего span_id следующему сервису, по W3C - заголовком traceparent. Хранить все трейсы невозможно, поэтому сэмплируют: head-based решает в начале (дёшево, но теряет ошибки), tail-based - в коллекторе, выждав время после первого спана трейса (дорого, зато можно сохранить все ошибки и все медленные).

Модель данных

  • trace_id занимает 16 байт и одинаков у всех спанов запроса, span_id - 8 байт, свой у каждой операции. parent_span_id связывает спаны в дерево.
  • Span несёт: имя (низкой кардинальности - GET /users/{id}, не конкретный URL), время начала и конца, статус (unset/ok/error), атрибуты (сюда как раз можно и нужно класть высококардинальные user_id и order_id - в трейсах это не проблема) и события (точки во времени: «retry», «cache miss»).
  • Span links нужны для асинхронных границ: консьюмер Kafka часто обрабатывает пачку сообщений из разных трейсов, а родитель у спана один, поэтому по конвенциям OpenTelemetry вместо parent-child по умолчанию ставят link (для одиночного сообщения допускается и родитель).
  • Baggage везёт произвольные пары ключ-значение вместе с контекстом через все сервисы (например, «это тестовый трафик»). Мощно и опасно: растёт размер заголовков, и туда легко утекает лишнее.

Propagation

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             ^^ версия
                ^^^^ trace-id: ОДИН на весь запрос
                                                 ^^^^ span-id отправителя
                                                 (у получателя станет parent_span_id)
                                                                  ^^ флаги: 01 = sampled

В Go всё это едет в context.Context. На входе middleware (otelhttp.NewHandler) достаёт заголовок и создаёт серверный спан; на выходе транспорт (otelhttp.NewTransport) кладёт уже свой span_id в исходящий запрос. В gRPC то же самое едет в метаданных, а в Kafka - в заголовках сообщения. От кода требуются две вещи: один раз зарегистрировать пропагатор (otel.SetTextMapPropagator; по умолчанию он пустой, и middleware с транспортом заголовки не читают и не пишут) и передавать ctx везде. Ушёл в горутину с context.Background() - и трейс порвался. Это причина обрывов номер один.

Есть ещё исторические форматы: b3 (Zipkin), x-datadog-*, jaeger-заголовки. В смешанной инфраструктуре настраивают композитный пропагатор, который умеет читать несколько форматов, иначе трейс рвётся ровно на границе со старым сервисом.

Сэмплирование: зачем

При 10 000 rps и десяти спанах на запрос набегает 100 000 спанов в секунду, терабайты в сутки, и подавляющее большинство описывает обычные успешные запросы, на которые никто никогда не посмотрит. Плюс накладные расходы в самом приложении и на сети. Поэтому вопрос не «сэмплировать или нет», а «как выбрать, что сохранить».

Head-basedTail-based
Когда решениев начале, на первом сервисекогда трейс, по расчёту, завершился: коллектор выжидает заданное время после первого спана (по умолчанию 30 с)
Кто решаетSDK приложения, по trace_idколлектор, накопив спаны трейса за время ожидания
Плюсдёшево, без состояния, решение согласовано между сервисами через флагможно сохранить 100 % ошибок и медленных запросов
Минусошибка с равной вероятностью попадёт в выброшенные 99 %дорого: коллектор буферизует спаны, нужен stateful-слой и маршрутизация спанов одного трейса в один инстанс

Практический рецепт: ParentBased обязателен всегда. Решение принимается один раз на входе и уважается всеми участниками, иначе трейсы получаются дырявыми (часть спанов есть, часть нет - и картинка врёт). Дальше берут либо TraceIDRatioBased(0.01…0.1) для дешёвого варианта, либо OpenTelemetry Collector с политикой «все трейсы с ошибкой, все медленнее 1 с, 1 % остальных». Помогает и форсирование трейса для конкретного запроса по внутреннему заголовку: так проблему можно воспроизвести и гарантированно получить трейс.

Что ломается на практике
  • Контекст теряется в горутинах, пулах задач, очередях. Если работа переживает запрос, нужен context.WithoutCancel(ctx): он сохраняет значения и трейс, но убирает отмену.
  • Прокси с allow-list заголовков вырезают traceparent.
  • Синхронный экспорт добавляет латентность коллектора к пользовательской. Только batch-экспортер, и обязательно с ограниченной очередью - иначе недоступный коллектор превращается в утечку памяти твоего сервиса.
  • Слишком много спанов. Спан на каждый вызов функции выходит дорого и нечитаемо. Спан ставят на границу: входящий запрос, исходящий вызов, запрос в БД, значимый блок бизнес-логики.
  • Высокая кардинальность в имени спана вместо атрибутов убивает агрегированную статистику по спанам.
Суть: /health (liveness) отвечает на вопрос «процесс жив или его надо убить», и провал означает перезапуск. /ready (readiness) отвечает «можно ли слать трафик сейчас», и провал означает лишь вывод из балансировки, обратимо. Отсюда правило: в liveness - почти ничего, в readiness - реальные зависимости. БД в liveness превращает затянувшийся сбой базы в CrashLoopBackOff всего сервиса, и он может продлиться ещё несколько минут после того, как база вернулась.

Что проверять

  • /health отдаёт плоский 200 OK. Добавлять туда стоит только признаки состояния, которые лечатся перезапуском: зависший фоновый воркер (последний тик был больше N минут назад), обнаруженный дедлок, исчерпание внутреннего ресурса, из которого нет выхода. Всё это значит «процесс сломан изнутри», и рестарт действительно помогает.
  • /ready проверяет реальную готовность обслуживать: соединение с БД, подключение к брокеру, прогретый кэш или загруженный справочник, признак «идёт graceful shutdown». Проверки должны быть дешёвыми: фоновая горутина раз в несколько секунд опрашивает зависимости и обновляет атомарные флаги, хендлер только читает их.

Почему БД в liveness - катастрофа

  1. База недоступна несколько минут (переключение реплики, сетевой сбой).
  2. Liveness падает одновременно у всех реплик, ведь они смотрят в одну и ту же базу.
  3. kubelet убивает все контейнеры. Теряются установленные соединения, прогретые кэши, все запросы в обработке.
  4. Поды стартуют, снова не видят базу, снова падают. Первый перезапуск идёт сразу, а дальше Kubernetes удваивает паузу перед каждым следующим: 10 с, 20 с, 40 с и так до 5 минут.
  5. База вернулась, а сервис лежит дальше, до нескольких минут, потому что поды досиживают свою паузу.

Итог: перезапуск не чинит недоступную базу, зато многократно увеличивает ущерб. Правильно держать БД в readiness: поды остаются живыми, пул соединений переустанавливает их сам, трафика на них временно нет, и как только база вернулась, readiness проходит и трафик возвращается - без единого рестарта.

Тонкости, которые выдают опытную руку
  • Отдельный порт и отдельный http.Server. Если пробы обслуживает тот же сервер, что забит запросами, они начнут падать по таймауту именно под нагрузкой - и добьют сервис перезапусками. Заодно так пробы не проходят через общий middleware, rate limit и очередь.
  • Разделять обязательные и необязательные зависимости. Redis-кэш недоступен, но можно ходить в БД напрямую, и это degraded, а не not ready. Уронив ready из-за необязательной зависимости, ты своими руками превращаешь частичную деградацию в полный отказ.
  • Не проверять зависимость зависимости. Если твой ready ходит в соседний сервис, а тот дальше в свой, один сбой внизу выведет из ротации всю систему. Проверяем только то, без чего мы сами не работаем.
  • readiness стоит ронять при SIGTERM ещё до закрытия сервера. Из балансировки Kubernetes выводит удаляемый под и сам, без всякой пробы, но 503 увидят и балансировщики, которые проверяют здоровье пода сами. Это первый шаг graceful shutdown.
  • startupProbe снимает конфликт «медленный старт против быстрого обнаружения зависаний»: пока идёт старт, liveness просто не выполняется, и её тайминги можно делать жёсткими.
  • Не отдавать наружу детали (версии, адреса, статусы зависимостей) без авторизации: страница со списком внутренних систем становится готовой картой для атакующего.
Суть: дашборд - инструмент дежурного в три часа ночи, а не витрина. Критерий первого экрана: за десять секунд без скроллинга ответить на вопрос «сейчас плохо или нет и кому». Это верхний ряд RED (rate, errors, duration), средний ряд - бюджет ошибок и насыщение, нижний - разбивки и события (деплои). Причины - на втором экране и в трейсах.

Первый экран, по рядам

  1. RED. Rate (rps со стеком по кодам ответа), Errors (доля 5xx, отдельно 4xx), Duration (p50/p95/p99 тремя линиями плюс пунктир SLO). Три панели - и уже понятно, есть проблема или нет.
  2. SLO и бюджет ошибок. Сколько бюджета осталось на окне и текущий burn rate за 1 час и за 6 часов. Это переводит «плохо» в «насколько срочно».
  3. Saturation. Пул соединений к БД, запросы в обработке (in-flight), длина очередей, число горутин, CFS-троттлинг, память относительно лимита. Этот ряд предупреждает заранее, а не констатирует.
  4. Зависимости нашими глазами. Latency и ошибки запросов в БД, к соседним сервисам, лаг консьюмера. Отвечает на главный вопрос первых двух минут инцидента: «это мы или это нас».
  5. Разбивки. Топ маршрутов по времени и по ошибкам показывает, «кому именно плохо».
  6. Ресурсы и события. RSS против лимита, CPU против лимита, рестарты и OOMKilled, число реплик - и аннотации деплоев, миграций и переключений флагов вертикальными линиями на всех графиках.
Аннотации деплоев - самая окупаемая панель дашборда

Половина расследований заканчивается на фразе «а, вот здесь был релиз». Технически это одна метрика build_info{version, commit} плюс аннотация в Grafana. Стоит полчаса, экономит часы на каждом инциденте.

Правила, по которым дашборд остаётся полезным
  • Одна страница на сервис. Дашборд, который надо скроллить пять экранов, ночью не читается.
  • Единый временной диапазон на всех панелях, иначе графики нельзя сопоставлять.
  • Пороги и линии SLO прямо на графиках, чтобы не держать цифры в голове и не спорить, «120 мс - это много или нет».
  • Кликабельность вниз: график → exemplars → трейс → логи с тем же trace_id. Дашборд тем ценнее, чем быстрее с него спускаешься на уровень ниже.
  • Дашборды как код (JSON в git, Grafonnet, terraform-провайдер), а не «Вася накликал и уволился». Тогда новый сервис получает дашборд из шаблона в день создания.
  • Отдельно нужен обзорный дашборд по всем сервисам (по одной строке на сервис: rate, errors, p99, бюджет), с которого начинается дежурство.
Как рассказать про свой опыт с Prometheus и Grafana

Работает конкретика, а не перечисление панелей. Хорошая структура: что было плохо («узнавали об ошибках от поддержки»), что сделали («гистограмма длительности с лейблами route/code, дашборд RED из шаблона, три алерта по burn rate»), какой был эффект («время обнаружения с 40 минут до 3»), и конкретный случай: как по дашборду дошли до причины, например: «p99 вырос вдвое при ровном p50 и ровном CPU; saturation показал пул БД на 100 %; в трейсе видно, что новая версия делает N+1 запрос в цикле; откатили за минуту, починили за день». Такой рассказ показывает, что ты не настраивал мониторинг, а пользовался им.

Суть: сигнал - монотонно растущий go_goroutines. Классический путь: /debug/pprof/goroutine?debug=2 и ручной поиск стеков, висящих в chan receive или select. С Go 1.27 есть штатный профиль goroutineleak (в 1.26 был за GOEXPERIMENT=goroutineleakprofile, в 1.27 общедоступен): он показывает только те горутины, которые заблокированы на недостижимых примитивах, то есть разбудить их уже некому. Это факт по достижимости, а не догадка по времени ожидания.

Порядок действий на инциденте

  1. Подтвердить по метрике. go_goroutines растёт и не возвращается после спада трафика - значит, утечка, а не всплеск нагрузки. С Go 1.26 то же самое видно детальнее через runtime/metrics: к /sched/goroutines:goroutines (он там с 1.16) добавились разбивка по состояниям (/sched/goroutines/running:goroutines, runnable, waiting, not-in-go; она приблизительная, суммироваться в общее число не обязана) и /sched/threads/total:threads.
  2. Взять профиль с нужного пода, а не через Service: kubectl port-forward pod/<имя> 6060:6060.
  3. Сначала goroutineleak. Он короткий и содержит только безнадёжные: curl 'localhost:6060/debug/pprof/goroutineleak?debug=1' или в коде pprof.Lookup("goroutineleak"). В выводе будут стеки, на которых горутины стоят: «столько-то горутин утекло, и все висят вот здесь».
  4. Потом goroutine?debug=2. Там видны все горутины со статусом и временем ожидания; нужен, если утечка не «навсегда», а «очень надолго» (горутина ждёт ответа без таймаута - примитив-то достижим, поэтому goroutineleak её не покажет).
  5. Починить причину, а не симптом: почти всегда это горутина без context.Context, отправка в канал, который никто не читает после ранней ошибки, или цикл по тикеру из time.NewTicker без выхода по ctx.Done().
Чем goroutineleak отличается от goroutine

goroutine показывает все горутины и оставляет решать самому, какие из них зависли, а какие просто ждут работы. goroutineleak применяет к примитивам синхронизации ту же логику достижимости, по которой работает сборщик мусора: если канал, на котором висит получатель, недостижим из живого кода, то писать в него физически некому - значит, горутина не проснётся никогда. Ложных срабатываний по построению нет; зато нет и «висит без таймаута на живом канале» - это ловится по-старому.

Ещё две мелочи из 1.26 и 1.27, которые приятно назвать
  • Go 1.27: метки горутин в трейсбеках. Если проставлять pprof.Labels + pprof.SetGoroutineLabels на входе в запрос, то при панике заголовок горутины печатается вместе с метками (если в go.mod стоит версия 1.27 или новее): goroutine 1 [running] {request_id: "abc-123"}. Из лога паники сразу видно, какой запрос упал. Отключается через GODEBUG=tracebacklabels=0.
  • Go 1.26: go tool pprof -http открывает flame graph по умолчанию вместо графа вызовов.
  • В тестах утечки по-прежнему ловит go.uber.org/goleak, а детерминированно воспроизводить конкурентные сценарии помогает testing/synctest (стабилен с 1.25).

Что стоит запомнить

  • Метрики показывают агрегированное изменение, логи - события, трейсы - путь конкретного запроса.
  • Гистограмма сохраняет распределение наблюдений для вычисления квантилей после агрегации.
  • Readiness управляет допуском трафика, liveness проверяет, может ли процесс восстановиться сам.

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

  • Не добавляй user_id и request_id в labels метрики: кардинальность может вырасти вместе с трафиком.
  • Среднее время ответа может скрыть медленный хвост, важный для p99.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Проследи контекст через HTTP client

Входящий middleware создаёт server span. Запрос к каталогу уходит как отдельный trace. Найди отсутствующую инструментализацию клиента и проверь, что request создаётся с текущим контекстом.

handler := otelhttp.NewHandler(mux, "orders")
transport := http.DefaultTransport
client := &http.Client{Transport: transport}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, catalogURL, nil)
resp, err := client.Do(req)
Ответ и разбор

Request сохраняет ctx, но transport не создаёт client span и не инжектирует trace headers. Оберни транспорт через otelhttp.NewTransport(http.DefaultTransport) и используй его в client. Убедись, что ошибки создания и выполнения request обрабатываются: иначе диагностика завершится раньше ответа downstream.

Код-ревью

2. Ограничь кардинальность метрик

В middleware к счётчику добавили метки из каждого запроса. Какое поле опасно и как сохранить полезную детализацию без серии на каждый запрос?

requests.WithLabelValues(r.URL.Path, r.Header.Get("X-Request-ID")).Inc()
Ответ и разбор

X-Request-ID почти уникален для каждого запроса, поэтому создаёт новую временную серию и раздувает память Prometheus. Используй ограниченный набор нормализованных route, method и code; идентификатор запроса оставь в логах и trace attributes, где он относится к отдельному событию.

Применение

3. Сформулируй SLI для поиска заказов

Команда обещает, что поиск заказов «быстрый и работает». Предложи один SLI доступности и один SLI задержки с измеряемым источником, не подставляя произвольный порог SLO.

Ответ и разбор

Например, доступность - доля валидных запросов поиска, завершившихся успешным ответом; источник - счётчик запросов с route и code. Задержка - доля успешных запросов, завершившихся быстрее согласованного порога; источник - histogram по route. Сам порог и целевую долю надо согласовать с ожиданиями продукта и измерениями, а не придумывать из графика.

Применение

4. Разведи readiness и liveness

Сервис временно потерял соединение с базой, но продолжает отвечать на health endpoint и восстановит пул после возврата базы. Что должна делать readiness, а что liveness, чтобы не устроить цикл рестартов?

Ответ и разбор

Readiness должна убрать Pod из балансировки, пока он не может обслуживать запросы с нужной зависимостью. Liveness не должна падать только из-за временно недоступной базы, иначе кластер будет бесконечно перезапускать исправный процесс. Проверь, что приложение может восстановить соединение без рестарта.