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/podrequest_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 секрет уйдёт в лог);
хеширование вместо значения, если нужно только сравнивать;
и ретеншн: логи с любыми персональными данными живут строго ограниченное время.
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 считает только «сколько наблюдений попало в бакет ≤ 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 запросов сжимаются в шесть чисел,
которые можно складывать между инстансами.
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 %. Бытовая аналогия: утилизация кассы в супермаркете показывает, сколько времени кассир пробивает товары, а насыщение показывает, сколько человек стоит в очереди. Покупатель чувствует второе.
- Rate считает запросы в секунду.
sum(rate(http_requests_total[5m])) - Errors - доля неуспешных.
sum(rate(...{code=~"5.."}[5m])) / sum(rate(...[5m])) - Duration показывает время ответа перцентилями.
- 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 метаданные.
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-based | Tail-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'
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, который делает это в проде и по факту
недостижимости, а не по таймауту».
- 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 падает одновременно у всех реплик
(они видят одну и ту же базу) → kubelet убивает их все → поды поднимаются, снова
не видят базу, снова умирают → CrashLoopBackOff: пауза перед каждым
следующим перезапуском удваивается, 10 с, 20 с, 40 с и так до пяти минут. База вернулась,
а сервис лежит дальше, до нескольких минут, потому что поды досиживают свою паузу.
Плюс потеряны все установленные
соединения, прогретые кэши и незавершённые запросы. Правильно так: БД идёт в readiness.
Поды остаются живыми, пул переустанавливает соединения сам, и трафик возвращается
ровно тогда, когда база оживёт, без единого рестарта.
- Отдельный порт и отдельный
http.Server. Если пробы обслуживает тот же сервер, что забит пользовательскими запросами, они начнут падать по таймауту именно тогда, когда сервис под нагрузкой, и добьют его перезапусками. - Проба должна быть дешёвой. Фоновая горутина раз в 5 секунд проверяет зависимости и обновляет атомарные флаги; хендлер только читает флаги.
- Разделять обязательные и необязательные зависимости. Redis-кэш недоступен, но сервис умеет ходить в БД напрямую, значит, это degraded, а не not ready. Уронив ready из-за необязательной зависимости, ты своими руками превращаешь частичную деградацию в полный отказ.
- readiness стоит ронять при SIGTERM, до того как сервер начнёт закрываться. Из балансировки Kubernetes выводит удаляемый под и сам, без всякой пробы, но 503 увидят и балансировщики, которые проверяют здоровье пода сами. Это первая строчка graceful shutdown.
- Не делать health-эндпоинты публичными и не отдавать в них внутренние детали (версии, адреса, статусы зависимостей) без авторизации: это разведданные.
- Не проверять зависимость зависимости. Если твой ready ходит в соседний сервис, а тот в свой, то один сбой внизу цепочки выведет из ротации всех, и получится каскад. Проверяем только то, без чего мы сами не работаем.
Дашборд сервиса: что должно быть на первом экране
Дашборд делают для дежурного в три часа ночи, а не для витрины. Отсюда критерий: первый экран, без скроллинга, должен за десять секунд отвечать на вопрос «сейчас плохо или нет, и кому». Причины лежат ниже и на соседних дашбордах.
- Единое время на всех панелях и общий диапазон, иначе сравнивать графики между собой нельзя.
- Аннотации деплоев стоят дёшево и окупаются лучше всех. Половина расследований заканчивается на «а, вот тут был релиз».
- Пороги и линии SLO на графиках, чтобы не держать цифры в голове.
- Одна страница на сервис, а не двадцать панелей всего на свете. Дашборд, который надо скроллить пять экранов, ночью не читается.
- Дашборды как код. JSON в git (Grafonnet, terraform-provider-grafana), а не «Вася накликал и уволился».
- Ссылки: дашборд → runbook → трейсы → логи. Инструмент ценен ровно настолько, насколько быстро с него удаётся спуститься на уровень ниже.
Вопросы
16Каждый в отдельности
- Метрики хранят числовые ряды, агрегаты по времени. Дёшевы и, что важнее, предсказуемо дёшевы: стоимость не зависит от 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).
Обязательный минимум записи
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; потеряли контекст - порвали связь.Механика
- Генерируем на входе. Первый компонент (ingress или edge-сервис) проверяет
X-Request-Id; если его нет, генерирует UUID. Доверять входящему значению от внешнего клиента можно только с валидацией (длина, алфавит), иначе получишь инъекцию мусора в логи. - Кладём в контекст и в логгер. Middleware создаёт логгер
slog.With("request_id", …, "trace_id", …)и кладёт его вcontext.Context. Все слои ниже берут логгер из контекста и получают поля автоматически. - Возвращаем клиенту.
X-Request-Idв заголовке ответа и в теле ошибки. Тогда пользователь из поддержки называет одну строку, и по ней находится всё. - Прокидываем дальше. В исходящие 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 на всю компанию.
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 такой длины не виден совсем.
- Короткоживущие задачи могут завершиться раньше первого скрейпа.
Что делать с короткоживущими задачами
- Pushgateway работает как шлюз: задача перед смертью пушит туда метрики,
Prometheus скрейпит шлюз. Работает, но у него есть неприятные свойства,
о которых надо знать: значения живут вечно, пока их не удалят
(метрика умершего задания продолжает отдаваться и выглядит как «всё хорошо»),
сам шлюз становится точкой отказа и не даёт
upпо исходной задаче. Правило: Pushgateway берут только для батчей уровня сервиса, никогда для обычных сервисов и никогда как «мы хотим push». - Для CronJob в Kubernetes часто честнее не метрики задачи, а метрики
о задаче:
kube_job_status_succeeded, время последнего успеха, и алерт «задача не завершалась успешно дольше N». - Паттерн «время последнего успеха». Батч пишет
batch_last_success_timestamp_seconds, а алерт смотритtime() - batch_last_success_timestamp_seconds > 26h. Это устойчивее, чем ловить сам факт запуска. - OpenTelemetry Collector как компромисс: приложение шлёт OTLP push, коллектор отдаёт Prometheus-эндпоинт для скрейпа. Так живут serverless и всё, до чего мониторингу не дотянуться.
- remote_write отдаёт уже собранные данные в долговременное хранилище (Mimir, Thanos, VictoriaMetrics). Это не «push вместо pull»: модель сбора остаётся pull.
«Спор pull против push во многом исторический: OpenTelemetry по умолчанию
push (OTLP), и это удобнее для serverless и для мультитенантных облаков.
Но главное эксплуатационное свойство Prometheus держится именно на pull:
мониторинг остаётся живым, когда всё остальное падает. Он ни от кого
не зависит, ничего не принимает, и сервисы, которые сходят с ума,
не завалят его отправками. Плюс он переживает рестарты: counter только растёт,
а rate() умеет распознать сброс на нуль, поэтому потеря пары
скрейпов не искажает данные.»
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 - главная часть вопроса
| Histogram | Summary | |
|---|---|---|
| Что хранится | счётчики _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, единицы измерения берутся базовые (секунды, байты, а не миллисекунды и мегабайты). Это соглашение, на которое опираются все готовые дашборды и правила.
Конкретный пример взрыва
# Норма: 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оправдан для канареечного анализа, но за него платят).
Как чинить
- Найти виновника. Страница
/tsdb-statusв Prometheus показывает топ метрик и топ лейблов по кардинальности. Запросtopk(10, count by (__name__)({__name__=~".+"}))делает то же самое на скорую руку. Ещё помогаетpromtool tsdb analyze. - Убрать лейбл в коде. Только это и чинит по-настоящему.
- Отрезать на скрейпе как временная мера:
metric_relabel_configsс действиемlabeldropилиdropпо всей метрике. Работает мгновенно и не требует релиза. Но ряды, которые без лейбла совпали, Prometheus молча схлопнет в один, и значения остальных пропадут. - Поставить ограничения как страховку:
sample_limitиlabel_limitв конфиге скрейпа. Цель, которая начала отдавать миллион рядов, будет отброшена целиком, и упадёт только она, а не весь мониторинг. - Перенести измерение туда, где ему место. Разрез по конкретному пользователю смотрят в логе или трейсе, не в метрике. А «топ-10 медленных запросов» даёт трейсинг с tail-based сэмплированием.
«Метрики работают с ограниченным алфавитом измерений. Их сила именно в том, что стоимость не зависит от трафика, а расплата - в том, что нельзя добавлять произвольные разрезы. Поэтому я держу правило: лейбл допустим, если множество его значений конечно, невелико и известно заранее: методы, коды, маршруты, имена очередей. Всё остальное живёт в логах и трейсах, а переход туда дают exemplars и trace_id, не лейблы.»
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 есть физический минимум (сеть, диск) и нет максимума: ретраи, промахи кэша, паузы 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 мс». Математически это почти одно и то же, но первое считается прямым делением бакетов, без интерполяции, и потому точнее и агрегируется тривиально.
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.
- Ранг:
0.99 × 10000 = 9900. - Счётчик ≥ 9900 впервые встречается у
le=0.5(там 9930), предыдущий бакетle=0.25(9700). - Значит, искомое значение лежит внутри
(0.25, 0.5]. - Линейная интерполяция:
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. Хороший 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 нужно им, а не сервису целиком.
Как это меняет разговор с продуктом
- Появляется общая единица измерения. Раньше спор был про ощущения («стало медленно» против «надо фичи»), теперь спорят про число: «бюджет израсходован на 80 %, из них 60 % съел инцидент с миграцией».
- У заморозки есть объективный триггер. Политику фиксируют заранее: бюджет исчерпан → заморозка рискованных выкаток, приоритет на надёжность, пока бюджет не восстановится. Это решение принято до инцидента и не зависит от того, кто сегодня громче.
- У надёжности появляется потолок. К 100 % стремиться не надо. Более того, неизрасходованный бюджет тоже плох: значит, команда движется слишком осторожно и могла бы быстрее поставлять ценность. Для многих это неожиданный аргумент.
- Разговор идёт на языке пользователя. SLO меряется по тому, что видит клиент, и потому обсуждаем с продактом без перевода. «Сколько заказов в месяц мы согласны потерять» звучит как вопрос, на который у продукта есть мнение.
- Алертить на burn rate, а не на нарушение SLO. Когда SLO нарушено, уже поздно. Многооконный подход: сжигание 14.4× за 1 час будит дежурного (при таком темпе месячный бюджет кончится за 2 дня); 3× за сутки даёт задачу на утро. Два окна одновременно ловят и обвал, и медленную деградацию, и почти не дают ложных срабатываний.
- SLO на операцию, а не на сервис. «Оформление заказа» и «получение списка рекомендаций» имеют разную критичность; общий SLO по всем запросам размывает и то и другое.
- Считать по доле запросов, а не по «минутам простоя». Полная недоступность случается редко; реальная деградация выглядит как «3 % запросов падает четыре часа», и в минутах простоя это не выражается.
- SLO без последствий бесполезен. Если при исчерпании бюджета ничего не происходит, это просто ещё один график. Работает только зафиксированная и согласованная с продуктом политика.
Симптом против причины
- Алерт на симптом срабатывает, только когда действительно плохо; он не зависит от того, какая именно из десяти возможных причин случилась; он переживает рефакторинг, переезд на новое железо и смену архитектуры; и он один, а не двадцать.
- Алерт на причину ловит только те причины, которые уже были. «CPU 90 %» может быть штатной работой под нагрузкой; «под перезапустился» вообще обычная жизнь кластера; а один настоящий инцидент рождает шторм из сорока уведомлений от всех связанных систем, в котором тонет главное.
- Законное исключение: причины годятся, когда предсказывают отказ
с большим запасом и требуют действий заранее: «диск заполнится через 4 часа
при текущем темпе» (
predict_linear), «сертификат истекает через 7 дней», «пул соединений занят на 95 %». Такие алерты идут в тикет, а не в звонок.
Алерт-фатиг
Это состояние, когда 90 % срабатываний ничего не значат и дежурный перестаёт на них смотреть. Опаснее полного отсутствия мониторинга, потому что создаёт иллюзию контроля. Что помогает:
- Разделить по срочности: page (звонок, реагировать сейчас) / ticket (в рабочее время) / dashboard-only. После честной ревизии большинство существующих алертов переезжает вниз.
- Пересматривать список после каждого дежурства: что сработало, что было ложным, что удалить. Алерт, который за квартал ни разу не привёл к действию, удаляем. Это не потеря, а возврат внимания.
- Группировка и подавление в Alertmanager:
group_by, чтобы 50 подов дали одно уведомление;inhibit_rules, чтобы «нода недоступна» глушила все алерты про поды на ней;silenceна плановые работы. - Runbook в тексте алерта. Ссылка на «что проверить, что сделать». Алерт без инструкции стоит дежурному 20 минут на то, чтобы вспомнить контекст.
- Метрики про сам алертинг: сколько сработало, сколько привело к действию, сколько ложных. Без этих цифр разговор о качестве алертов превращается в обмен мнениями.
Что делать с флапающими алертами
for:идёт первым номером. Условие должно держаться непрерывно заданное время:for: 5mотсекает одиночные всплески. Поставишь мало, получишь флап; поставишь много, узнаешь поздно. Но он задерживает только срабатывание: горящий алерт гаснет на первой же проверке, где условие ложно. Задержку на выключение даётkeep_firing_for(Prometheus 2.42+).- Гистерезис. Порог зажигания и погашения должны различаться: загорается при > 5 %, гаснет при < 2 %. Иначе метрика, колеблющаяся вокруг единственного порога, шлёт «горит/потухло» каждые полминуты.
- Шире окно усреднения.
rate(...[5m])вместо[1m]; для редких событий короткое окно швыряет значение между нулём и бесконечностью. - Условие на минимальный объём. При 3 запросах в минуту одна ошибка
даёт 33 %. Добавляем
and sum(rate(http_requests_total[5m])) > 1, чтобы не алертить по статистике на пустом трафике. - Многооконный burn rate вместо одного порога: длинное окно подтверждает, что бюджет сгорел заметно и это не всплеск, а короткое - что он горит прямо сейчас, поэтому после починки алерт гаснет за минуты, а не через час. Против ложных срабатываний ничего лучше пока не придумали.
- Если флапает по-настоящему, значит, система реально балансирует на границе, и чинить надо систему: добавить ресурсов, поправить пороги автоскейлера, убрать источник пилы. Заглушить алерт проще всего и хуже всего.
«Хорошему сервису хватает трёх-пяти алертов, а не пятидесяти.
Обычно: доля ошибок выше SLO (по burn rate, два окна), latency выше SLO,
сервис недоступен целиком (up == 0 или нет ни одной готовой реплики),
плюс одна-две бизнес-метрики вроде “оплат за 10 минут меньше N”.
Всё остальное уходит в дашборды и тикеты. Новый алерт я завожу
по такому правилу: он появляется после инцидента и отвечает на вопрос
“как бы мы узнали об этом на 20 минут раньше”, а не “давайте на всякий случай
помониторим”.»
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-based | Tail-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 - катастрофа
- База недоступна несколько минут (переключение реплики, сетевой сбой).
- Liveness падает одновременно у всех реплик, ведь они смотрят в одну и ту же базу.
- kubelet убивает все контейнеры. Теряются установленные соединения, прогретые кэши, все запросы в обработке.
- Поды стартуют, снова не видят базу, снова падают. Первый перезапуск идёт сразу, а дальше Kubernetes удваивает паузу перед каждым следующим: 10 с, 20 с, 40 с и так до 5 минут.
- База вернулась, а сервис лежит дальше, до нескольких минут, потому что поды досиживают свою паузу.
Итог: перезапуск не чинит недоступную базу, зато многократно увеличивает ущерб. Правильно держать БД в readiness: поды остаются живыми, пул соединений переустанавливает их сам, трафика на них временно нет, и как только база вернулась, readiness проходит и трафик возвращается - без единого рестарта.
- Отдельный порт и отдельный
http.Server. Если пробы обслуживает тот же сервер, что забит запросами, они начнут падать по таймауту именно под нагрузкой - и добьют сервис перезапусками. Заодно так пробы не проходят через общий middleware, rate limit и очередь. - Разделять обязательные и необязательные зависимости. Redis-кэш недоступен, но можно ходить в БД напрямую, и это degraded, а не not ready. Уронив ready из-за необязательной зависимости, ты своими руками превращаешь частичную деградацию в полный отказ.
- Не проверять зависимость зависимости. Если твой ready ходит в соседний сервис, а тот дальше в свой, один сбой внизу выведет из ротации всю систему. Проверяем только то, без чего мы сами не работаем.
- readiness стоит ронять при SIGTERM ещё до закрытия сервера. Из балансировки Kubernetes выводит удаляемый под и сам, без всякой пробы, но 503 увидят и балансировщики, которые проверяют здоровье пода сами. Это первый шаг graceful shutdown.
- startupProbe снимает конфликт «медленный старт против быстрого обнаружения зависаний»: пока идёт старт, liveness просто не выполняется, и её тайминги можно делать жёсткими.
- Не отдавать наружу детали (версии, адреса, статусы зависимостей) без авторизации: страница со списком внутренних систем становится готовой картой для атакующего.
Первый экран, по рядам
- RED. Rate (rps со стеком по кодам ответа), Errors (доля 5xx, отдельно 4xx), Duration (p50/p95/p99 тремя линиями плюс пунктир SLO). Три панели - и уже понятно, есть проблема или нет.
- SLO и бюджет ошибок. Сколько бюджета осталось на окне и текущий burn rate за 1 час и за 6 часов. Это переводит «плохо» в «насколько срочно».
- Saturation. Пул соединений к БД, запросы в обработке (in-flight), длина очередей, число горутин, CFS-троттлинг, память относительно лимита. Этот ряд предупреждает заранее, а не констатирует.
- Зависимости нашими глазами. Latency и ошибки запросов в БД, к соседним сервисам, лаг консьюмера. Отвечает на главный вопрос первых двух минут инцидента: «это мы или это нас».
- Разбивки. Топ маршрутов по времени и по ошибкам показывает, «кому именно плохо».
- Ресурсы и события. RSS против лимита, CPU против лимита, рестарты и OOMKilled, число реплик - и аннотации деплоев, миграций и переключений флагов вертикальными линиями на всех графиках.
Половина расследований заканчивается на фразе «а, вот здесь был релиз».
Технически это одна метрика build_info{version, commit}
плюс аннотация в Grafana. Стоит полчаса, экономит часы на каждом инциденте.
- Одна страница на сервис. Дашборд, который надо скроллить пять экранов, ночью не читается.
- Единый временной диапазон на всех панелях, иначе графики нельзя сопоставлять.
- Пороги и линии SLO прямо на графиках, чтобы не держать цифры в голове и не спорить, «120 мс - это много или нет».
- Кликабельность вниз: график → exemplars → трейс → логи с тем же
trace_id. Дашборд тем ценнее, чем быстрее с него спускаешься на уровень ниже. - Дашборды как код (JSON в git, Grafonnet, terraform-провайдер), а не «Вася накликал и уволился». Тогда новый сервис получает дашборд из шаблона в день создания.
- Отдельно нужен обзорный дашборд по всем сервисам (по одной строке на сервис: rate, errors, p99, бюджет), с которого начинается дежурство.
Работает конкретика, а не перечисление панелей. Хорошая структура: что было плохо («узнавали об ошибках от поддержки»), что сделали («гистограмма длительности с лейблами 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 общедоступен): он показывает
только те горутины, которые заблокированы на недостижимых примитивах, то есть
разбудить их уже некому. Это факт по достижимости, а не догадка по времени ожидания.Порядок действий на инциденте
- Подтвердить по метрике.
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. - Взять профиль с нужного пода, а не через Service:
kubectl port-forward pod/<имя> 6060:6060. - Сначала
goroutineleak. Он короткий и содержит только безнадёжные:curl 'localhost:6060/debug/pprof/goroutineleak?debug=1'или в кодеpprof.Lookup("goroutineleak"). В выводе будут стеки, на которых горутины стоят: «столько-то горутин утекло, и все висят вот здесь». - Потом
goroutine?debug=2. Там видны все горутины со статусом и временем ожидания; нужен, если утечка не «навсегда», а «очень надолго» (горутина ждёт ответа без таймаута - примитив-то достижим, поэтомуgoroutineleakеё не покажет). - Починить причину, а не симптом: почти всегда это горутина без
context.Context, отправка в канал, который никто не читает после ранней ошибки, или цикл по тикеру изtime.NewTickerбез выхода поctx.Done().
goroutineleak отличается от goroutine
goroutine показывает все горутины и оставляет решать самому,
какие из них зависли, а какие просто ждут работы. goroutineleak
применяет к примитивам синхронизации ту же логику достижимости, по которой
работает сборщик мусора: если канал, на котором висит получатель, недостижим
из живого кода, то писать в него физически некому - значит, горутина не проснётся
никогда. Ложных срабатываний по построению нет; зато нет и «висит без таймаута
на живом канале» - это ловится по-старому.
- 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 не должна падать только из-за временно недоступной базы, иначе кластер будет бесконечно перезапускать исправный процесс. Проверь, что приложение может восстановить соединение без рестарта.