Observability
Как понять, что происходит с сервисом, не заходя на сервер руками. Три столпа
наблюдаемости — логи, метрики и трейсы: что даёт каждый, гистограммы и p99, RED и USE,
SLI/SLO и error budget, алерты по симптомам и разница между /health и
/ready. Потом язык запросов Prometheus и его эксплуатация: rate и
экстраполяция, сопоставление векторов, правила и Alertmanager, хранение. И OpenTelemetry в
Go-сервисе: SDK, propagation, сэмплирование, Collector и связка трейсов с логами и метриками.
Знаешь ли ты, зачем тут каждый инструмент, или помнишь только названия. Кандидат, который говорит «поставим Prometheus и Grafana», и кандидат, который говорит «p99 считаем по гистограмме с бакетами под наш SLO, алерт вешаем на error budget, а не на CPU» — у второго дашборд показывает, как живётся пользователю. У первого — насколько занято железо.
5.1Логи, метрики, трейсы и алерты
Самая «взрослая» глава темы. Мониторинг говорит, работает сервис или нет. Observability отвечает на вопрос «почему», причём для проблемы, которую ты не предвидел заранее. Разница между кандидатами видна сразу: один перечисляет инструменты, другой рассказывает, как от графика дошёл до строки кода. Второй нанимается.
Сначала — пять слов, без которых глава не читается
Дальше в главе постоянно встречаются пять терминов. Произносят их все, а объясняет мало кто. Разберёмся сразу, чтобы потом не спотыкаться.
1. Метрика — это число, снимаемое регулярно
У метрики одно число, и раз в 15–30 секунд записывают его текущее значение. Получается ряд «время → значение», по которому рисуют график: «сколько запросов в секунду», «сколько горутин живёт». Метрика ничего не помнит про отдельные события, она их суммирует. Отсюда её главное свойство: она стоит одинаково при 10 и при 100 000 запросах в секунду, потому что записывается всё равно одно число.
2. Спан — это отрезок времени с именем, трейс — дерево таких отрезков
Спан (span, дословно «промежуток») выглядит так: «операция такая-то шла с 12:00:03.100 по 12:00:03.412». На одну операцию приходится один спан: обработка HTTP-запроса, поход в базу, вызов соседнего сервиса. У спана есть имя, время начала и конца, статус и ссылка на родителя, то есть на тот спан, изнутри которого его запустили.
Трейс собирает все спаны одного запроса по ссылкам на родителей. Получается дерево: наверху «весь запрос целиком», под ним лежат куски, из которых он состоял. Нарисованное дерево спанов выглядит как диаграмма Ганта: полоски разной длины, вложенные друг в друга. Смотришь на такую картинку — и сразу видишь, какая именно полоска съела 900 мс из секунды.
3. Перцентиль — это «сколько процентов оказалось не хуже»
Возьми все 10 000 замеров времени ответа за минуту и выстрой их по возрастанию, как детей по росту. Тогда p99 покажет значение того, кто стоит на 99-м месте из ста: 99 % замеров оказались не больше него, 1 % оказался больше. Всё, никакой другой математики за этим нет.
Физический смысл проще, чем формулировка: p99 = 400 мс означает «из каждой сотни запросов девяносто девять уложились в 400 мс, а один был медленнее». При 1000 запросов в секунду это 10 недовольных пользователей каждую секунду, то есть 36 тысяч за час. p50 (он же медиана) показывает «типичный» запрос, ровно середину строя. p99.9 говорит про один запрос из тысячи.
Почему тогда не пользоваться средним? Потому что среднее берёт все значения и размазывает их в одно число, а время ответа устроено крайне несимметрично: снизу его подпирает жёсткий пол (быстрее скорости сети не ответишь), а сверху хвост уходит в бесконечность из-за ретраев, промахов кэша, пауз GC, блокировок в базе. Один запрос на 5 секунд в компании тысячи быстрых почти не сдвинет среднее, но он реально был, и его кто-то ждал.
Совсем на пальцах: если в комнате девять человек с зарплатой 100 тысяч и один миллиардер, то средняя зарплата уйдёт в десятки миллионов, и это число не описывает никого из присутствующих. Медиана останется 100 тысяч, и вот она описывает девятерых. С временем ответа то же самое, только «миллиардеры» и есть те пользователи, которые пишут в поддержку.
4. Кардинальность — это сколько разных графиков породит одна метрика
К метрике можно прицепить лейблы (метки) и считать запросы в разрезе: «число запросов с методом GET по маршруту /users с кодом 200». Проблема в том, что каждая уникальная комбинация значений лейблов даёт отдельный график, который надо хранить. Кардинальность и есть количество таких комбинаций, то есть число разных временных рядов, порождённых одной метрикой.
Считается умножением: 8 маршрутов × 5 методов × 6 кодов ответа = 240 рядов, нормально.
Добавили лейбл user_id с миллионом значений, и получилось 240 миллионов рядов,
а мониторинг умер. «Взрыв кардинальности» случается, когда кто-то положил в лейбл величину,
у которой много разных значений.
5. Сэмплирование — это «сохраняем не всё, а выборку»
Сэмплирование (sampling, от «sample», образец или выборка) означает сознательное решение хранить только часть данных: например, один трейс из ста, а остальные девяносто девять выбрасывать сразу. Делают это потому, что хранить все трейсы физически дорого. Плата очевидна: конкретного запроса, который ты ищешь, в системе может просто не оказаться — его выбросили. Отсюда все ухищрения дальше в главе: как выбрасывать так, чтобы интересное оставалось.
Три столпа: что каждый даёт и чего не даёт
Логи, метрики и трейсы не заменяют друг друга: они отвечают на разные вопросы и стоят принципиально по-разному. Отвечая, называй и сильную сторону, и границу применимости каждого.
| Метрики | Логи | Трейсы | |
|---|---|---|---|
| Отвечают на | что происходит и насколько плохо | что именно случилось с конкретным запросом | где в цепочке сервисов потерялось время |
| Природа данных | агрегаты, числовой ряд во времени | дискретные события с текстом | дерево спанов одного запроса |
| Стоимость | низкая и предсказуемая: не зависит от rps | высокая, растёт линейно с трафиком | высокая — поэтому почти всегда сэмплируются |
| Кардинальность | жёстко ограничена (лейблы) | любая | любая |
| Чего НЕ дают | не расскажут про конкретный запрос: в них нет user_id и order_id — только «0.4 % запросов упало» | не дают общей картины: по логам нельзя понять, стало ли хуже, чем вчера, и они врут при сэмплировании | не видят того, чего не проинструментировали, и почти всегда неполны из-за сэмплирования |
| Роль в инциденте | обнаружение и алерт | последняя миля: точная причина | локализация: какой сервис/запрос виноват |
«Метрика говорит, что что-то сломалось. Трейс говорит, где. Лог говорит, что именно.
Инцидент разбирается по этой лестнице сверху вниз, и хорошая наблюдаемость тем и ценна,
что по ней можно спуститься без прыжков: с графика перейти
к экземплярам трейсов (exemplars), из трейса уйти к логам с тем же trace_id.
Три системы без связей между ними дают не observability, а три отдельных сайта,
между которыми дежурный копирует айдишники руками в три часа ночи.»
Иногда добавляют четвёртый и пятый столп: профили (continuous profiling, то есть pprof, который собирают постоянно; отвечает на вопрос «какая строчка кода жрёт 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 = 400 мс читается как «99 из каждых 100 запросов уложились в 400 мс, один не уложился». Среднее же описывает распределение одним числом и потому пригодно только для симметричных распределений. Latency симметричной не бывает никогда: у неё есть жёсткий пол (быстрее сети не ответишь) и длинный правый хвост (ретраи, промахи кэша, паузы GC, блокировки в БД).
Классический пример, который стоит уметь рассказать за десять секунд: 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 нарушено» (это уже поздно), а на скорость сжигания бюджета (burn rate) с несколькими окнами: быстрое сжигание (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 миллисекунд». Трейсинг отвечает: он собирает все операции одного запроса в дерево с временными интервалами.
- 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, но включаются отдельной настройкой скрейпа) снимают эту проблему: экспоненциальные бакеты подбираются автоматически по заданной относительной точности, всё лежит в одном ряду, кардинальность не растёт. Если упомянуть native histograms, сразу видно, что человек следит за темой.
Три уровня
- 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).
5.2PromQL и Prometheus в работе
Какие метрики снимать, разобрано в главе 5.1. Дальше их путь: PromQL превращает точки опроса в скорости и доли, правило становится алертом, алерт уведомлением, а сами точки Prometheus складывает на диск. Выводы сняты на стенде с Prometheus 3.14 и Alertmanager 0.34.
- Как
rateдостраивает прирост до границ окна, переживает сброс счётчика и почему окно не короче четырёх интервалов опроса. - Почему сначала
rate, потомsum, и как приклеить к доле ошибок версию черезgroup_left. - Куда девается серия пропавшей цели и чем ловить отсутствие данных.
- Как алерт проходит pending, firing, группировку и inhibition и как проверить правило в
promtool test rules. - Что дают нативные гистограммы, что лежит в каталоге TSDB и как найти метрику, раздувшую кардинальность.
Стенд и модель данных
Стенд собран из контейнеров: Prometheus 3.14.0 и Alertmanager 0.34.0 из образов
quay.io/prometheus/*, два экземпляра сервиса orders на Go 1.27.1 с
prometheus/client_golang 1.24.1 и генератор нагрузки: 8 запросов в секунду к
app-1, 4 к app-2. Сервис отдаёт счётчик http_requests_total{route, code},
гистограмму http_request_duration_seconds{route} и info-метрику
orders_build_info{version} со значением 1. Prometheus опрашивает его раз в 15 секунд
(scrape_interval: 15s) и с тем же интервалом вычисляет правила.
Серия (time series) задаётся именем и полным набором меток. Имя хранится как метка
__name__, метки job и instance добавляет Prometheus при опросе,
другое значение любой метки даёт другую серию со своей историей. Запрос по имени возвращает
instant vector: по одной точке на серию, самую свежую не старше пяти минут. Суффикс
[1m] делает range vector (тип matrix) со всеми точками окна. Ответы API,
сокращённо:
GET /api/v1/query?query=http_requests_total
{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"__name__":"http_requests_total","code":"200","instance":"app-2:8080","job":"orders","route":"/api/orders"},
"value":[1789604917.035,"1426"]},
…ещё три серии]}}
GET /api/v1/query?query=http_requests_total{instance="app-1:8080",route="/api/orders",code="200"}[1m]
{"status":"success","data":{"resultType":"matrix","result":[{"metric":{…},
"values":[[1789604863.373,"2685"],[1789604878.373,"2745"],[1789604893.375,"2805"],[1789604908.373,"2866"]]}]}}
В минутное окно попали четыре опроса, отметки гуляют на пару миллисекунд. С Prometheus 3.0 окно открыто
слева: точка ровно на левой границе в него не попадает. Range vector не нарисуешь, не сложишь и не
агрегируешь, его принимают функции вроде rate и *_over_time:
http_requests_total[1m] + 1 parse error: binary expression must contain only scalar and instant vector types
sum(http_requests_total[1m]) parse error: expected type instant vector in aggregation expression, got range vector
На каждый опрос Prometheus пишет и синтетические серии: up (1 при удачном опросе),
scrape_duration_seconds, scrape_samples_scraped. У app-1 это 2 мс
и 256 точек, большая часть от метрик рантайма Go.
rate, irate и increase: что происходит внутри окна
rate(v[5m]) считает среднюю скорость роста счётчика в секунду. На границы окна точки почти
никогда не ложатся, и зазоры до границ Prometheus достраивает. Серия app-1 с 4 запросами в
секунду в момент T = 00:28:17.121:
s = http_requests_total{instance="app-1:8080",route="/api/orders",code="200"}
s[1m] 2625 (T−48.745 с) 2685 2745 2805 (T−3.746 с)
increase(s[1m]) 240.0053334518545
rate(s[1m]) 4.000088890864242
irate(s[1m]) 3.9994667377682975
increase(s[5m]) 1198.9305413257357
s - s offset 5m 1200
increase(s[5m] anchored) 1200
Четыре точки покрывают 44,999 с из 60, прирост между крайними 180. Зазор слева 11,255 с, справа
3,746 с, оба меньше 1,1 среднего интервала между точками (16,5 с), и Prometheus считает, что серия шла
через всё окно: 180 × 60 / 44,999 = 240,0053. Это increase, а rate делит его на
60. Отсюда дробные числа на целочисленном счётчике: increase оценивает прирост по средней
скорости между крайними точками, и за пять минут оценка 1198,93 разошлась с разностью значений 1200.
В extrapolatedRate (promql/functions.go) ещё два правила: при зазоре больше
1,1 среднего интервала серия, видимо, началась или кончилась внутри окна, и прирост тянут на половину
интервала; слева не заходят дальше момента, где счётчик был бы нулём. По умолчанию float-серии в 3.14
считаются как в 2.53. irate берёт две последние точки без
достройки, 60 / 15,002 с = 3,9995: он ловит всплеск за один опрос, хорош на графике и плох в алерте.
Модификатор anchored (экспериментальный, с 3.7 за флагом
--enable-feature=promql-extended-range-selectors) берёт последнюю точку до окна и последнюю в
нём без экстраполяции и возвращает целое.
Сброс счётчика и длина окна
В 00:28:37 app-2 заменили контейнером с новой версией, счётчик нового процесса начался с нуля:
s2 = http_requests_total{instance="app-2:8080",route="/api/orders",code="200"}
s2[3m] в 00:29:52.672 1246 1276 1306 1336 1366 1396 1426 | 5 33 62 92 121
increase(s2[3m]) 328.3616462930528
resets(s2[3m]) 1
increase считает падение значения
сбросом до нуля и прибавляет значение перед падением, а прирост между крайними точками растягивает на
всё окно. Запросы, которые старый процесс обслужил после своего последнего опроса, не видит никто.
Любое уменьшение rate и increase принимают за сброс, так что на gauge они выдают
мусор. Пять запросов нового процесса до первого опроса учтены, а 12,7 секунды трафика
старого после опроса в 00:28:24 пропали.
Теперь длина окна. За десять минут в каждый из 601 момента в [15s] попадала одна точка, в
[30s] две, в [1m] четыре. rate(s[15s]) не вернул ни одного
значения: ему нужны две точки. [30s] работает, пока не пропал ни один опрос: один таймаут,
и на графике дыра, а алерт гаснет. Отсюда правило четырёх интервалов: потеряешь опрос, и в окне ещё три
точки. Grafana держит для этого $__rate_interval, по её документации это
max($__interval + scrape_interval, 4 × scrape_interval).
Агрегация, подзапросы и сдвиг во времени
sum by (job) оставляет перечисленные метки, sum without (instance) убирает
перечисленные. В правилах удобнее without: до алерта доедут метки, о которых автор не
подумал. Порядок функций важен, через 75 секунд после замены app-2:
sum by (job) (rate(http_requests_total{job="orders"}[3m])) 11.684251307748013
rate(sum by (job) (http_requests_total{job="orders"})[5m]) parse error: ranges only allowed for vector selectors
rate(sum by (job) (http_requests_total{job="orders"})[3m:15s]) 46.43030303030302
sum by (job) (http_requests_total{job="orders"})[3m:15s] 7501 7681 … 8401 8582 5861 6040 … 6580
rate по каждой серии сам разбирается со сбросом app-2, сумма скоростей
около 12 в секунду. А сумма счётчиков разных процессов уже не счётчик: она упала с 8582 до 5861,
rate принял это за сброс, прибавил 8582 и завысил ответ вчетверо. Документация
rate и irate прямо требует: сначала скорость, потом агрегация.
[3m:15s] задаёт подзапрос: внутреннее выражение вычисляется с шагом 15 с и даёт range
vector. offset сдвигает выборку назад, @ ставит её на Unix-момент, оба пишутся
сразу после селектора или подзапроса:
max_over_time(sum(rate(http_requests_total[1m]))[1h:1m]) 12.023648103284325
min_over_time(sum(rate(http_requests_total[1m]))[1h:1m]) 7.977777777777777
count_over_time(sum(rate(http_requests_total[1m]))[1h:1m]) 60
sum(rate(http_requests_total[5m])) / sum(rate(http_requests_total[5m] offset 10m)) 1.000240920794823
sum(http_requests_total @ 1789604793) 7141
sum(rate(http_requests_total[5m])) offset 10m
parse error: offset modifier must be preceded by an instant vector selector or range vector selector or a subquery
Минимум 7,98 пришёлся на минуты, когда app-2 стоял. Точек в [1h:1m]
60, а не 61: левая граница подзапроса тоже открыта, и по миграционному гайду Prometheus 3
выровненный foo[1m:1m] теперь даёт одну точку. Без шага подзапрос берёт evaluation_interval.
Сопоставление векторов: доля ошибок и метки из info-метрики
Бинарная операция ищет пары серий с одинаковым набором меток без учёта имени. Новая версия на
app-2 отдаёт 3 % ответов 5xx, и доля «в лоб» получается единицей:
rate(http_requests_total{code="500"}[2m]) / rate(http_requests_total[2m])
{code="500", instance="app-2:8080", job="orders", route="/api/orders"} 1
В пару числителю нашёлся он сам. ignoring (code) исключает метку из сравнения
(on (…), наоборот, оставляет только перечисленные), но тогда справа на группу две серии,
200 и 500, и Prometheus откажется выбирать. Поэтому обе стороны сначала агрегируют до одного
набора меток, а версию приклеивают тут же:
(
sum by (instance) (rate(http_requests_total{code=~"5.."}[2m]))
or
sum by (instance) (rate(http_requests_total[2m])) * 0
)
/ sum by (instance) (rate(http_requests_total[2m]))
* on (instance) group_left (version) orders_build_info
{instance="app-2:8080", version="1.5.0"} 0.026190476190476195
{instance="app-1:8080", version="1.4.2"} 0
Без or … * 0 app-1 из результата пропадает: ошибок у него не было, а
CounterVec заводит серию при первом обращении к этим меткам (здесь это
Inc), так что паре в делении взяться неоткуда, и на дашборде вместо нуля «нет данных». Info-метрику держат ради меток, как
kube_pod_info с узлом пода или target_info из OpenTelemetry. Умножение на её единицу значение не меняет, а
group_left (version) переносит метку справа. Слева на группу on (instance) может
быть несколько серий, справа только одна:
rate(http_requests_total[2m]) * on (instance) orders_build_info
multiple matches for labels: many-to-one matching must be explicit (group_left/group_right)
sum by (instance) (rate(http_requests_total[2m])) * on (job) group_left (version) orders_build_info
found duplicate series for the match group {job="orders"} on the right hand-side of the operation: […];
many-to-many matching not allowed: matching labels must be unique on one side
В проде вторую ошибку ловят, когда у info-метрики ненадолго две серии на объект и старая не помечена
устаревшей: по документации экспериментальной info() такой запрос падает до пяти минут, а
сама info() в этом конфликте выбирает новую серию.
Staleness: куда девается серия пропавшей цели
В 00:36:59 контейнер app-2 остановили, последняя точка счётчика записана в 00:36:54. Через
20 секунд:
up{instance="app-2:8080"} 0
s2 пусто
last_over_time(s2[10m]) 960
absent(up{job="orders",instance="app-2:8080"}) пусто
absent(s2) {code="200", instance="app-2:8080", route="/api/orders"} 1
absent_over_time(s2[5m]) пусто, через 5 мин после последней точки: 1
Серия пропала из instant-запросов сразу, а не через пять минут. Когда опрос не удался или серия исчезла из
ответа цели, Prometheus пишет в прежние серии staleness marker, особое значение NaN, и после него
селектор считает серию закончившейся; так же при исчезновении цели из service discovery и с сериями,
которые правило перестало возвращать. Пять минут --query.lookback-delta остаются пределом
поиска точки для серий без маркера. Prometheus передаёт маркеры и по remote write, а
promtool push metrics их не шлёт: записанная им серия отвечала через 290 секунд и пропала к 310-й. absent возвращает 1 для пустого вектора,
absent_over_time для окна без точек; алерты на пропажу данных во втором вопросе.
Recording rules и правила алертов
Recording rule раз в evaluation_interval вычисляет выражение и сохраняет результат
новой серией: тяжёлый запрос считается один раз, алерт и дашборд смотрят на одно число. Имена по
соглашению документации Prometheus: level:metric:operations, то есть оставшиеся метки, имя
метрики без _total, операции от последней к первой.
groups:
- name: orders-recording
rules:
- record: instance_code:http_requests:rate1m
expr: sum without (route) (rate(http_requests_total{job="orders"}[1m]))
- record: instance:http_requests_5xx_per_requests:ratio_rate1m
expr: |2
sum without (code) (instance_code:http_requests:rate1m{code=~"5.."})
/
sum without (code) (instance_code:http_requests:rate1m)
- record: job:http_requests_5xx_per_requests:ratio_rate1m
expr: |2
sum without (instance, code) (instance_code:http_requests:rate1m{code=~"5.."})
/
sum without (instance, code) (instance_code:http_requests:rate1m)
- name: orders-alerts
rules:
- alert: OrdersInstanceErrors
expr: instance:http_requests_5xx_per_requests:ratio_rate1m > 0.05
for: 3m
labels: {severity: ticket}
annotations:
summary: "{{ $labels.instance }}: {{ $value | humanizePercentage }} ответов 5xx"
- alert: OrdersErrorBudgetBurn
expr: job:http_requests_5xx_per_requests:ratio_rate1m > 0.2
for: 1m
keep_firing_for: 2m
labels: {severity: page}
annotations:
summary: "{{ $labels.job }}: {{ $value | humanizePercentage }} ответов 5xx"
Для доли по job делят сумму на сумму, а не усредняют доли экземпляров, иначе 8 и 4 запроса
в секунду весили бы одинаково. Пока выражение возвращает серию, а for не истёк, алерт
pending, потом firing. Условие пропало, и алерт гаснет на первом же вычислении,
а с keep_firing_for горит ещё заданное время. $labels и $value берутся
из сработавшей серии, активные алерты пишутся в серию ALERTS.
$ promtool check rules bad.yml
Checking bad.yml
FAILED:
bad.yml: 5:15: group "bad", rule 1, "instance:http_requests:rate5m": could not parse expression: 1:49: parse error: unclosed left parenthesis
bad.yml: group "bad", rule 2, "Broken": annotation "summary": template: __alert_Broken:1: unexpected "}" in operand
promtool test rules гоняет правила на синтетических сериях. В тесте стенда
'0+150x40' даёт 10 запросов в секунду, а '_x7 0+30x33' добавляет 2 ошибки в
секунду начиная с 1m45s. Ожидание «в 6m горит OrdersInstanceErrors» проходит, в 5m падает:
$ promtool test rules tests/orders_test.yml
SUCCESS
$ promtool test rules tests/orders_5m_test.yml
FAILED:
alertname: OrdersInstanceErrors, time: 5m,
exp:[
0:
Labels:{alertname="OrdersInstanceErrors", instance="app-1:8080", job="orders", severity="ticket"}
…
got:[]
В 2m у серии ошибок точки 0 и 30, экстраполяция влево упирается в ноль, rate даёт
0,5 вместо 2, и доля 4,76 % ниже порога. В 2m15s доля 9,1 % и алерт в pending, в 5m15s он firing. В CI такой тест ловит правку, после которой алерт перестал срабатывать.
Alertmanager: маршруты, группировка, inhibition и silence
После вычисления Prometheus шлёт в Alertmanager алерты в firing и погасшие, один и тот
же не чаще --rules.alert.resend-delay (минута), а кому и в каком составе прийти
уведомлению, решает Alertmanager. Получатель на стенде печатает строку на каждый webhook:
route:
receiver: team
group_by: [alertname, job]
group_wait: 30s
group_interval: 2m
repeat_interval: 4h
routes:
- matchers: ['severity="page"']
receiver: pager
group_wait: 10s
receivers:
- name: team
webhook_configs: [{url: "http://hook:9000/hook"}]
- name: pager
webhook_configs: [{url: "http://hook:9000/hook"}]
inhibit_rules:
- source_matchers: ['alertname="OrdersErrorBudgetBurn"']
target_matchers: ['severity="ticket"']
equal: [job]
Алерт спускается в первый подходящий дочерний маршрут (дальше только при continue: true), и
алерты с одинаковыми метками из group_by составляют группу. group_wait
держит её первое уведомление, чтобы дождаться соседей и подавляющих алертов. По тику
group_interval уходят изменения, а repeat_interval повторяет то, что не менялось. Inhibition глушит цели,
пока горит источник с теми же метками из equal, silence глушит по матчерам на время.
Сценарий: в 00:46:33 обе реплики отдают 40 % ответов 5xx, в 00:51:04 10 %, в 00:58:04 на
app-2 ставят silence, в 00:58:09 ошибки кончаются. Лог получателя:
00:48:25 receiver=pager status=firing group={alertname=OrdersErrorBudgetBurn,job=orders} alerts=1
00:54:15 receiver=team status=firing group={alertname=OrdersInstanceErrors,job=orders} alerts=2
00:54:25 receiver=pager status=resolved group={alertname=OrdersErrorBudgetBurn,job=orders} alerts=1
01:00:15 receiver=team status=resolved group={alertname=OrdersInstanceErrors,job=orders} alerts=1
resolved {alertname=OrdersInstanceErrors,instance=app-1:8080,severity=ticket}
for и group_wait. Тикеты
загораются позже и молчат, пока горит page, включая keep_firing_for; потом уходят одним
уведомлением на тике group_interval. Алерт под silence не попадает даже в уведомление о погасании.
Без inhibition команде пришло бы второе уведомление о том же сбое. Тикеты ушли в 00:54:15, а не в
00:53:45, когда погас page: группа появилась в 00:49:45, её тики пришлись на 00:50:15, 00:52:15 и
00:54:15, и на первых двух всё было подавлено. Оба экземпляра пришли одним уведомлением, потому что
instance нет в group_by. Silence ставили так:
$ amtool --alertmanager.url=http://localhost:9093 silence add \
alertname=OrdersInstanceErrors instance=app-2:8080 \
--duration=1h --author=oncall --comment="canary rollback in progress"
c1d66a93-b559-41e2-b482-dcc7f8304d12
Нативные гистограммы
Классическая гистограмма из главы 5.1 состоит из серий _bucket{le} с заранее выбранными
границами. Нативная укладывает распределение в одну серию: границы растут в геометрической
прогрессии, а пустые бакеты не хранятся вовсе. В client_golang её включают поля опций:
Buckets: []float64{.01, .025, .05, .1, .25, .5, 1}, // классические остаются
NativeHistogramBucketFactor: 1.1, // соседние границы отличаются не больше чем на 10%
NativeHistogramMaxBucketNumber: 160, // потолок числа бакетов
NativeHistogramMinResetDuration: time.Hour, // не сбрасывать чаще раза в час
Библиотека берёт ближайший не больший множитель вида 22−n, для 1.1 это
21/8 ≈ 1,0905, восемь бакетов на удвоение. Сверх потолка гистограмма сбрасывается,
а если MinResetDuration не прошёл, снижает разрешение; без потолка документация поля
называет её вектором для DoS. В client_golang 1.24 поля ещё экспериментальные, в
Prometheus нативные гистограммы стабильны с 3.8 и включаются scrape_native_histograms.
Нативную часть client_golang отдаёт только в protobuf, curl /metrics покажет классические
бакеты, а always_scrape_classic_histograms сохраняет обе формы.
Для сравнения 3 % запросов замедлили до 260–320 мс. Генератор нагрузки считал точный p99 по своим замерам, поминутно от 281 до 298 мс:
histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) 0.39941170380740243
histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m]))) 0.29715458167274456
histogram_fraction(0, 0.3, sum(rate(http_request_duration_seconds[5m]))) 0.9910951475092551
count by (__name__) ({__name__=~"http_request_duration_seconds.*"})
http_request_duration_seconds 4 _bucket 32 _count 4 _sum 4
Медленные запросы попали в классический бакет (0.25, 0.5], и интерполяция дала 399 мс, на 40 % больше
правды. У нативной вокруг 300 мс бакеты шириной 23–27 мс, и ошибка меньше ширины бакета. На вопрос
«какая доля быстрее 300 мс» histogram_fraction ответит при любом пороге, а классическая
знает ответ только для своих le. Серия одна на маршрут и экземпляр, в каждой около 40
занятых бакетов, и sum складывает гистограммы целиком, без by (le). Одна
ловушка: remote_write с протоколом 1.0 по умолчанию их не отправляет
(send_native_histograms: false), с сообщением 2.0 отправляет всегда.
Хранение и кардинальность
Свежие точки живут в head, в памяти, а перед этим дописываются в WAL (журнал упреждающей
записи), по которому head восстанавливается после рестарта; заполненные чанки head сбрасывает в
chunks_head и читает через mmap. Двухчасовыми порциями данные уходят в неизменяемые
блоки с чанками, индексом и meta.json, позже блоки сливаются. Через 46 минут работы
блоков ещё нет. Снапшот через админский API (флаг --web.enable-admin-api) сбрасывает head в
блок; снапшотами же документация советует делать резервные копии:
$ docker exec dv-c2-prom du -sh /prometheus/wal /prometheus/chunks_head
1.7M /prometheus/wal
128.0K /prometheus/chunks_head
$ curl -s -XPOST localhost:19090/api/v1/admin/tsdb/snapshot
{"status":"success","data":{"name":"20260917T010210Z-322a559e0a48212c"}}
В каталоге блока лежат chunks/, index, meta.json и
tombstones. В meta.json 1395 серий и 239 712 точек за 45,5 минуты, чанки заняли 711 КБ, около трёх
байт на точку (документация для оценки диска называет в среднем 1–2). Retention по умолчанию
15 дней, удаляются только целиком устаревшие блоки. В 3.14 флаги --storage.tsdb.retention.time
и .size в --help помечены [DEPRECATED]: настройки переехали в
перечитываемый конфиг, и экземпляр с настройкой ниже отдаёт в /api/v1/status/runtimeinfo
"30d or 50GiB".
storage:
tsdb:
retention:
time: 30d
size: 50GB
Лимит размера учитывает WAL, но удаляет только блоки, а компакция временно держит и старые
блоки, и новый: отсюда совет документации ставить не выше 80–85 % диска. Долгое хранение и общий вид по
многим серверам даёт remote_write: Prometheus читает свой WAL и шлёт точки во
внешнее хранилище, а недоступность приёмника переживает без потерь около двух часов, пока WAL не обрезан.
Режим --agent оставляет только опрос и remote_write.
| Thanos | Grafana Mimir | VictoriaMetrics | |
|---|---|---|---|
| Приём | sidecar выгружает двухчасовые блоки в объектное хранилище; Receive принимает remote_write | remote_write в distributor; с 3.0 рекомендована схема с Kafka перед ingester | remote_write в single-node или vminsert |
| Хранение | блоки TSDB в объектном хранилище | блоки TSDB в объектном хранилище | свой формат на дисках vmstorage, MetricsQL поверх PromQL |
| HA-пара Prometheus | склейка на чтении по --query.replica-label | HA tracker берёт одну реплику по cluster и __replica__ | -dedup.minScrapeInterval при одинаковых внешних метках |
Почему user_id в метке убивает Prometheus, разобрано в главе 5.1. Виновника дешевле всего
искать на странице TSDB status (/tsdb-status) или в её API: числа берутся из индекса head.
$ curl -s 'localhost:19090/api/v1/status/tsdb?limit=3' \
| jq -r '.data | .seriesCountByMetricName[], .labelValueCountByLabelName[] | "\(.value) \(.name)"'
80 prometheus_http_request_duration_seconds_bucket
72 prometheus_http_response_size_bytes_bucket
65 prometheus_http_requests_total
428 __name__
92 le
63 handler
Лидирует сам Prometheus: счётчик запросов он заводит
сразу на все 63 своих HTTP-обработчика, а у вызванных гистограммы множатся на бакеты. Запрос
topk(3, count by (__name__) ({__name__=~".+"})) вернул те же три метрики, но читает точку
из каждой серии, а promtool tsdb analyze по блоку показывает ещё метки с наибольшим
churn, рождением и смертью серий.
Метрики из Go: свой Registry, promhttp и метрики рантайма
reg := prometheus.NewRegistry()
requests := promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
Name: "http_requests_total",
Help: "HTTP requests by route and status code.",
}, []string{"route", "code"})
reg.MustRegister(
collectors.NewGoCollector(collectors.WithGoCollectorRuntimeMetrics(collectors.MetricsAll)),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
mux.Handle("GET /metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{Registry: reg}))
promauto.NewCounter без With регистрирует метрику в глобальном
DefaultRegisterer, а promhttp.Handler() отдаёт глобальный реестр, где уже есть
коллекторы Go и процесса. Это удобно, пока конструктор вызывается один раз; второй вызов, например во
втором тесте пакета, падает с panic: duplicate metrics collector registration attempted. Свой
реестр, переданный в конструктор, это снимает: promauto.With(reg) сохраняет удобство, а
коллекторы в пустой реестр добавляют явно. HandlerFor договаривается о
формате, включая protobuf, а HandlerOpts.Registry заводит счётчик ошибок сбора
promhttp_metric_handler_errors_total.
GoCollector по умолчанию 29 семейств go_*, 35 строк: go_memstats_*, go_goroutines, go_gc_duration_seconds…
+ WithGoCollectorRuntimeMetrics 133 семейства, 219 строк: всё из runtime/metrics Go 1.27
histogram_quantile(0.99, sum by (instance, le) (rate(go_sched_latencies_seconds_bucket[5m])))
app-1 0.000574 app-2 0.000464 prometheus 0.000703
Самая полезная из новых go_sched_latencies_seconds: сколько горутины провели runnable до
получения процессора, то есть насыщение CPU из USE. Рядом go_cpu_classes_gc_total_cpu_seconds_total
для доли CPU на сборку мусора и go_sync_mutex_wait_total_seconds_total для конкуренции за
мьютексы. go_memstats_* в client_golang тоже считаются через runtime/metrics;
сопровождающие советуют их оставить, а остальное включать по надобности правилами
MetricsScheduler, MetricsGC, ведь MetricsAll добавил
восемь классических гистограмм по 8–12 бакетов.
Вопросы
4for добавляются отставание выражения (опрос, окно
rate, расписание правил) и таймеры Alertmanager. На стенде 42 секунды ушли, пока доля ошибок в
окне [1m] перешла порог и правило это увидело, 60 на for, 10 на
group_wait.Разбор по этапам
| Этап и что его задаёт | На стенде |
|---|---|
ошибки попали в серию: scrape_interval | до 15 с после 00:46:33 |
| выражение перешло порог: доля растёт, пока заполняется окно | 5,1 % в 00:46:58, 24,6 % в 00:47:13 |
алерт вычислен: evaluation_interval, смещение группы | pending в 00:47:15 |
pending → firing: for | firing в 00:48:15 |
уведомление: group_wait или тик group_interval | webhook в 00:48:25 |
По умолчанию каждый этап длиннее: scrape_interval и evaluation_interval
минута, group_wait 30 секунд, group_interval 5 минут. Гаснет алерт тоже
с опозданием: page стал inactive в 00:53:45, а resolved ушёл на тике группы в 00:54:25.
Что крутить
- Окно не короче четырёх интервалов опроса,
forкороткий только при высоком пороге, как у быстрого burn rate из главы 5.1. - Группы правил. Правила группы выполняются по порядку, группы параллельно: алерт после своего recording rule в той же группе видит результат этого цикла, в другой может отстать до интервала.
- Таймеры по маршрутам. Для page короткие; новый алерт в группе, о которой уже было
уведомление, ждёт тика
group_interval.
Одно пустое вычисление (упал опрос, в окне меньше двух точек), и pending удаляется, отсчёт идёт
заново: в rules/alerting.go keep_firing_for проверяется только у firing.
После рестарта Prometheus восстанавливает for из серии ALERTS_FOR_STATE, если
простой короче --rules.alert.for-outage-tolerance (1h), и только у правил с
for от --rules.alert.for-grace-period (10m).
up == 0, absent, absent_over_time, число живых реплик с
or vector(0).Почему гаснет
Счётчик остановленного app-2 пропал из запросов за 20 секунд. up остался с
нулём потому, что цель статическая; в Kubernetes под уходит из discovery вместе с
up, и up == 0 молчит. keep_firing_for только откладывает ложное погасание.
Чем ловить
up{job="orders"} == 0 цель в discovery есть, но не отвечает
absent(up{job="orders"}) пропали все цели job
(count(up{job="orders"} == 1) or vector(0)) < 2 живых реплик меньше двух
absent_over_time(batch_last_success_timestamp_seconds{job="batch"}[26h])
count(up{job="nope"} == 1) < 2 на стенде ничего не вернул: count от пустоты
пуст, а с or vector(0) пришло {} 0. Метки absent берёт только из
равенств: absent(up{job="nope",instance=~"app.*"}) дал {job="nope"} 1,
absent(sum(…)) дал {} 1, поэтому его пишут на каждую важную серию.
Серии из promtool push, OTLP из SDK и от целей с явными отметками
времени видны до --query.lookback-delta после последней точки, и absent по ним срабатывает с
этой задержкой. Свежесть таких данных проверяют по отметке внутри значения:
time() - batch_last_success_timestamp_seconds > ….
alert_relabel_configs,
Alertmanager собирают в кластер и шлют алерты во все экземпляры, а графики склеивает долговременное
хранилище.Опыт на стенде
Две реплики с правилом Watchdog: vector(1) и external_labels: {replica: a|b}
шлют алерт в один Alertmanager:
replica=a и replica=b
/api/v2/alerts: [{"alertname":"Watchdog","replica":"a",…},{"alertname":"Watchdog","replica":"b",…}]
01:22:08 receiver=team status=firing group={alertname=Watchdog} alerts=2
те же реплики с alert_relabel_configs: [{action: labeldrop, regex: replica}]
/api/v2/alerts: [{"alertname":"Watchdog","severity":"none"}]
01:23:08 receiver=team status=firing group={alertname=Watchdog} alerts=1
Дубли пришли одним уведомлением только потому, что replica нет в group_by;
при другой группировке это два звонка.
Alertmanager и данные
- Кластер Alertmanager (флаги
--cluster.*) реплицирует через gossip silence и журнал отправленных уведомлений; при разрыве сети он по документации лучше пришлёт дубль, чем потеряет уведомление. Балансировщик перед Alertmanager README запрещает: Prometheus перечисляет все экземпляры. - Графики двоятся, потому что серии различаются меткой
replica. Склеивает их долговременное хранилище (таблица в разделе про хранение), причём не усреднением: реплики опрашивают цели в разные моменты, и VictoriaMetrics, например, оставляет на интервал-dedup.minScrapeIntervalодну точку с наибольшей отметкой времени.
increase оценивает прирост по
точкам опроса с экстраполяцией, не видит инкрементов после последнего опроса перед рестартом и первого
значения серии, родившейся посреди окна. Точные бизнес-числа берут из базы, метрики отвечают на
«сколько в секунду и как меняется».Что показал стенд
http_requests_total{instance="app-1:8080",code="500",route="/api/orders"}
родилась с первыми ошибками в 00:46:33; первые точки 00:46:43=15, 00:46:58=47, 00:47:13=67
в 01:03:07 значение счётчика 576
increase(…[1h]) 570.708957021963
increase(…[1h] anchored) 561
Первые 15 ошибок случились до первого опроса, и для Prometheus серия началась с 15.
anchored вернул разность 561, а increase дотянул прирост влево на половину
интервала (7,5 с), вправо до границы (9,4 с): 561 × 991,9 / 975,0 = 570,7. Ещё одну потерю показал
раздел про сброс: трафик старого процесса после его последнего опроса.
Что с этим делать
- Создавать серии при старте через
WithLabelValues: серия есть с нулём, и первые события считаются приростом. - Start timestamp. client_golang отдаёт в protobuf время создания счётчика, флаг
--enable-feature=created-timestamp-zero-ingestionвставляет по нему нулевую точку. Новый экземпляр получил 20 запросов между опросами:без флага http_requests_total[5m] 20 20 20 20 20 20 increase 0 с флагом http_requests_total[5m] 0 20 20 20 20 20 20 increase 20.605229719788674
5.3OpenTelemetry в Go-сервисе
Трассировка в Go ломается тихо: SDK не настроен, пропагатор пустой, процесс вышел раньше, чем ушла пачка спанов, а в логе ни одной ошибки. Ниже сервис собран по частям, от API до Collector с tail sampling, и на каждом шаге видно, что доехало до Jaeger.
- Почему библиотеки зависят только от API и что со спанами без SDK.
- Чем OTLP по gRPC отличается от HTTP, зачем
BatchSpanProcessorи что теряется безShutdown. - Какие переменные
OTEL_*Go SDK читает сам. - Как контекст идёт через HTTP, gRPC и Kafka, чем опасен baggage и зачем консьюмеру span link.
- Почему при
ParentBasedрешает корень, а tail sampling требует двух слоёв Collector. - Как привязать логи и метрики к трейсу и во что обходится инструментирование.
Прогоны: Go 1.27.1, go.opentelemetry.io/otel v1.46.0, contrib v0.71.0, otelpgx
v0.12.0, franz-go v1.21.7; в контейнерах Collector contrib 0.161.0, Jaeger 2.21.0, Postgres 18 и Kafka 4.3.1.
API и SDK: что подключает библиотека, а что main
API (go.opentelemetry.io/otel, otel/trace) даёт интерфейсы: tracer, спан,
атрибут. SDK (otel/sdk/…) их реализует: сэмплер, процессоры, экспортеры, ресурс.
Библиотеки вроде otelhttp импортируют только API и берут глобальный провайдер, а SDK
настраивает main, и экспортер с версией SDK выбирает сервис. Пока SDK нет, провайдер раздаёт
пустышки:
tr := otel.Tracer("example.com/orders") // SDK никто не настраивал
ctx, span := tr.Start(context.Background(), "charge")
sc := span.SpanContext()
fmt.Printf("span: %T recording=%v valid=%v trace_id=%s\n", span, span.IsRecording(), sc.IsValid(), sc.TraceID())
span.End()
h := http.Header{}
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(h))
fmt.Printf("propagator fields=%v headers after Inject=%v\n", otel.GetTextMapPropagator().Fields(), h)
otel.SetTracerProvider(sdktrace.NewTracerProvider()) // SDK появился позже, tr тот же
_, span2 := tr.Start(context.Background(), "charge")
fmt.Printf("after SetTracerProvider: %T recording=%v valid=%v\n", span2, span2.IsRecording(), span2.SpanContext().IsValid())
$ go build -o noop ./cmd/noop && ./noop
span: global.nonRecordingSpan recording=false valid=false trace_id=00000000000000000000000000000000
propagator fields=[] headers after Inject=map[]
after SetTracerProvider: *trace.recordingSpan recording=true valid=true
Пустышка ничего не пишет и не падает. Tracer, взятый до настройки SDK, пересоздавать не нужно: глобальный провайдер передаст вызовы настоящему. А пропагатор по умолчанию пустой и заголовков не пишет даже при настоящем SDK.
Resource описывает источник (service.name, версию, окружение) и едет в каждой пачке
OTLP. Без имени SDK подставит имя бинарника, и сервисы с бинарником app сольются в Jaeger в
один:
$ go build -o orders ./cmd/resource && ./orders default | grep service.name
service.name=unknown_service:orders
Ресурс по умолчанию читает OTEL_SERVICE_NAME и OTEL_RESOURCE_ATTRIBUTES. Если имя
есть и в коде, решает порядок слияния: в resource.Merge(resource.Default(), свой) побеждает
код, в resource.New с WithAttributes(…) и WithFromEnv() последний по
списку детектор, то есть переменная.
Имена атрибутов задают семантические конвенции (semconv): в Go это пакеты по версиям (в otel
v1.46.0 последний semconv/v1.43.0) со своим SchemaURL. Ресурсы с разными схемами
не сливаются:
_, err := resource.Merge(resource.Default(), // внутри SDK схема 1.43.0
resource.NewWithAttributes(oldsemconv.SchemaURL, // semconv/v1.26.0
oldsemconv.ServiceName("orders")))
fmt.Println("err:", err)
// err: conflicting Schema URL: https://opentelemetry.io/schemas/1.43.0 and https://opentelemetry.io/schemas/1.26.0
Так бывает после обновления SDK со старым импортом semconv. Бери версию, которую импортирует
sdk/resource, или resource.NewSchemaless.
TracerProvider: экспортер, процессор и Shutdown
func setupTracing(ctx context.Context, name string, ratio float64) (func(context.Context) error, error) {
exp, err := otlptracegrpc.New(ctx) // адрес из OTEL_EXPORTER_OTLP_ENDPOINT, иначе localhost:4317
if err != nil {
return nil, fmt.Errorf("otlp exporter: %w", err)
}
res, err := resource.Merge(resource.Default(), resource.NewWithAttributes(semconv.SchemaURL,
semconv.ServiceName(name), semconv.ServiceVersion(version)))
if err != nil {
return nil, fmt.Errorf("resource: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp), // BatchSpanProcessor
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{}))
return tp.Shutdown, nil
}
Экспортеров OTLP два: otlptracegrpc и otlptracehttp (POST на
/v1/traces, по умолчанию protobuf). Collector слушает gRPC на 4317, HTTP на 4318. Порт
перепутан (export создаёт спаны и зовёт Shutdown с таймаутом 5 секунд):
$ OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 ./export -proto grpc -n 5
grpc/batch: 5 spans, span.End() avg 1µs
shutdown error: context deadline exceeded
Shutdown took 5.002s
gRPC-экспортер считает недоступность временной и повторяет попытки до конца контекста, наружу выходит
только context deadline exceeded. Так же молча ломается WithEndpoint без
WithInsecure(): экспортер идёт по TLS (в переменной это решает схема, http:// или
https://). HTTP-экспортер на 4317 сразу пишет в лог malformed HTTP response.
SimpleSpanProcessor экспортирует спан прямо в End(), BatchSpanProcessor
кладёт в очередь на 2048 спанов и раз в 5 секунд шлёт пачки до 512 штук:
$ export OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317
$ ./export -processor batch -n 20
grpc/batch: 20 spans, span.End() avg 0s
…
$ ./export -processor simple -n 20
grpc/simple: 20 spans, span.End() avg 451µs
…
$ OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4999 ./export -processor simple -n 2 -shutdown=false
2026/09/17 04:50:07 traces export: exporter export timeout: rpc error: code = Unavailable desc = connection error: …
…
grpc/simple: 2 spans, span.End() avg 10.001884s
С недоступным Collector каждый End(), а с ним и ответ пользователю, ждёт таймаут экспорта в 10
секунд. У батчера спаны лежат в памяти до отправки, и процесс без Shutdown их теряет
(jaeger-count.sh считает trace_id сервиса в Jaeger):
$ OTEL_SERVICE_NAME=exp-shutdown ./export -n 20
…
$ OTEL_SERVICE_NAME=exp-noshutdown ./export -n 20 -shutdown=false
…
$ for s in exp-shutdown exp-noshutdown; do printf '%s: ' $s; ./jaeger-count.sh $s; done
exp-shutdown: 20
exp-noshutdown: 0
В поде так пропадают до пяти секунд трейсов перед каждым рестартом. Спан, закрытый после
Shutdown, тоже не уходит, поэтому по SIGTERM зовут srv.Shutdown(sctx), потом
shutdownTracing(sctx) с общим таймаутом меньше terminationGracePeriodSeconds:
иначе недоступный Collector съест срок и остановку оборвёт SIGKILL. Остановка HTTP-сервера разобрана в статье
«Веб-сервисы» раздела Go, глава 10.2.
| Переменные окружения | Кто читает в Go |
|---|---|
OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES | resource.Default() и WithResource |
OTEL_EXPORTER_OTLP_ENDPOINT, …_HEADERS, …_TIMEOUT, …_INSECURE | экспортеры OTLP; опции в коде главнее |
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG | NewTracerProvider; WithSampler главнее |
OTEL_BSP_SCHEDULE_DELAY, OTEL_BSP_MAX_QUEUE_SIZE | NewBatchSpanProcessor |
OTEL_TRACES_EXPORTER, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_PROPAGATORS | contrib/exporters/autoexport и contrib/propagators/autoprop; …_PROTOCOL ещё otlptracehttp (protobuf или JSON, с v1.46.0) |
otelhttp, otelgrpc и pgx
Спаны на входящие и исходящие вызовы дают обёртки из contrib. Цепочка в опытах:
gateway → orders → payments → Postgres. chain.sh поднимает
её и шлёт запросы, jtraces.py печатает трейс из API Jaeger.
handler := otelhttp.NewHandler(mux, *name,
otelhttp.WithFilter(func(r *http.Request) bool { return r.URL.Path != "/healthz" }))
var client = &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport), Timeout: 5 * time.Second}
cfg, err := pgxpool.ParseConfig(*dsn)
cfg.ConnConfig.Tracer = otelpgx.NewTracer()
srv := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
conn, err := grpc.NewClient("127.0.0.1:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
$ ./chain.sh "" "" /orders/42 /healthz && ./jtraces.py gateway d
GET /orders/42 -> 200
GET /healthz -> 200
traces with gateway: 1
trace 88ca0ce28ff70e3b14a174c9c5b97450
gateway GET /orders/{id} span=dccbd86e947047de parent=- SERVER UNSET
gateway HTTP GET span=09eb75a5e6e47d6a parent=dccbd86e947047de CLIENT UNSET
orders GET /orders/{id} span=633372b4e28a40b4 parent=09eb75a5e6e47d6a SERVER UNSET
orders load order span=ce309bbca0a8431b parent=633372b4e28a40b4 INTERNAL UNSET
orders HTTP GET span=646d2b58198650eb parent=ce309bbca0a8431b CLIENT UNSET
payments GET /payments/{id} span=86c796c2286f0f6f parent=646d2b58198650eb SERVER UNSET
payments pool.acquire span=0700d5cad8aa1629 parent=86c796c2286f0f6f CLIENT UNSET
payments connect span=9fca51937ce75e2f parent=0700d5cad8aa1629 CLIENT UNSET
payments SELECT span=ccb3aa7661ac83cd parent=86c796c2286f0f6f CLIENT UNSET
payments SELECT span=d9307bebbd99a666 parent=ccb3aa7661ac83cd CLIENT UNSET
- Имя серверного спана
GET /orders/{id}собрано из метода иr.Pattern(ServeMuxзаполняет его с Go 1.23); второй аргументNewHandlerс otelhttp v0.69.0 на имя не влияет. У роутера безr.Patternимя будетGET, и нуженWithSpanNameFormatter. /healthzтрейса не оставил, его отрезалWithFilter. Успешные спаны осталисьUNSET:OKинструментирование само не ставит.- otelpgx пишет ожидание пула, подключение и запрос, вложенный
SELECTготовит выражение на первом вызове. Вdb.query.textплейсхолдеры без значений. Спан запроса будет, только если в контексте уже есть записываемый спан. - otelgrpc ставится как
stats.Handler,traceparentедет в метаданных. Перехватчики вродеUnaryServerInterceptorустарели в v0.46.0, к v0.63.0 их удалили.
Свои спаны: атрибуты, события и ошибки
func loadOrder(ctx context.Context, id string) error {
ctx, span := tracer.Start(ctx, "load order")
defer span.End()
span.SetAttributes(attribute.String("order.id", id))
span.AddEvent("cache miss", trace.WithAttributes(attribute.String("cache", "redis")))
time.Sleep(15 * time.Millisecond)
code, err := call(ctx, client, *next+"/payments/"+id)
if err == nil && code >= 400 {
err = fmt.Errorf("payments returned %d", code)
}
if err != nil {
span.RecordError(err) // событие exception
span.SetStatus(codes.Error, "payment lookup failed") // статус спана
return err
}
return nil
}
RecordError только добавляет событие exception, статус остаётся
Unset; ошибкой спан делает SetStatus. Заказа 13 в базе нет:
$ ./chain.sh "" "" /orders/13 && ./jtraces.py gateway d | head -7
GET /orders/13 -> 502
traces with gateway: 1
trace 4278e79403dda87da52c7422154f6512
gateway GET /orders/{id} span=ba405b7c43c459fa parent=- SERVER ERROR
gateway HTTP GET span=80b656f31a8d22b4 parent=ba405b7c43c459fa CLIENT ERROR
orders GET /orders/{id} span=8f2a11810b33c155 parent=80b656f31a8d22b4 SERVER ERROR
orders load order span=a3233ba7d84841a2 parent=8f2a11810b33c155 INTERNAL ERROR payment lookup failed
orders HTTP GET span=fc4a180dc471cb9d parent=a3233ba7d84841a2 CLIENT ERROR
payments GET /payments/{id} span=9bf3b45d6bd433f0 parent=fc4a180dc471cb9d SERVER UNSET
Серверный спан payments с ответом 404 остался UNSET: по конвенциям HTTP 4xx
для сервера не ошибка. Клиентский спан orders на тот же 404 получил ERROR и
error.type=404, а 502 помечен ошибкой с обеих сторон. В Jaeger у load order
значок ошибки, теги error=true, otel.status_code=ERROR и
otel.status_description, в Events cache miss и exception.
Контекст на проводе: traceparent, tracestate, baggage
Заголовок traceparent (формат в главе 5.1) пишет и читает пропагатор. Без
SetTextMapPropagator в setupTracing каждый сервис начинает свой трейс:
$ ./chain.sh "-no-propagator" "" /orders/42 >/dev/null
$ for s in gateway orders payments; do ./jtraces.py $s d | sed -n 3p; done
gateway GET /orders/{id} span=4d643f7e33a4288a parent=- SERVER UNSET
orders GET /orders/{id} span=c86fadf495cbbd5d parent=- SERVER UNSET
payments GET /payments/{id} span=e4ed9adaca141a4c parent=- SERVER UNSET
Три корня вместо одного дерева. С пропагатором gateway кладёт в baggage
tenant.id=acme и зовёт партнёра, сервер без OpenTelemetry, печатающий заголовки:
$ ./chain.sh "" "-external http://127.0.0.1:9099/partner -baggage" /orders/42
GET /orders/42 -> 200
GET /partner
…
Baggage: tenant.id=acme
Traceparent: 00-459034cfe4326ff669358d629fe2d406-f4fa710edf7cfc26-01
…
tracestate TraceContext передаёт как есть: на traceparent с флагом
00 и tracestate: congo=t61rcWkgMzE следующий сервис получил тот же trace_id,
новый span_id, флаг 00 и тот же tracestate.
Пары из baggage пропагатор Baggage копирует в каждый исходящий запрос, во внешний API
тоже: выше tenant.id ушёл партнёру, так и email клиента окажется в чужих логах. Входящему
baggage не верят. Транспорту для внешних API пропагатор задают отдельно,
otelhttp.WithPropagators(propagation.TraceContext{}), и партнёр получает только
Traceparent.
Kafka: заголовки сообщения и span link
Пропагатору нужен TextMapCarrier над заголовками сообщения (Get,
Set, Keys), для franz-go он есть в kotel.
// продюсер: контекст спана отправки уходит в заголовки
sendCtx, send := tr.Start(reqCtx, "send "+topic, trace.WithSpanKind(trace.SpanKindProducer))
r := &kgo.Record{Topic: topic, Key: []byte(id), Value: []byte(`{"order_id":"` + id + `"}`)}
prop.Inject(sendCtx, kotel.NewRecordCarrier(r))
if err := cl.ProduceSync(sendCtx, r).FirstErr(); err != nil {
log.Fatal(err)
}
send.End()
// консьюмер, внутри PollFetches: сообщение станет ссылкой
msgCtx := prop.Extract(ctx, kotel.NewRecordCarrier(r))
_, span := tr.Start(ctx, "process "+topic, // родитель: контекст цикла, а не сообщение
trace.WithSpanKind(trace.SpanKindConsumer),
trace.WithLinks(trace.LinkFromContext(msgCtx)),
trace.WithAttributes(semconv.MessagingSystemKafka, semconv.MessagingOperationTypeProcess,
semconv.MessagingOperationName("process"), semconv.MessagingDestinationName(topic),
semconv.MessagingConsumerGroupName("billing")))
span.End()
$ ./kafkademo
header traceparent: 00-b047f7f8ccac4bde5a3e06010951bc10-d4d4bb96d400514f-01
send orders producer trace=b047f7f8ccac4bde5a3e06010951bc10 span=d4d4bb96d400514f parent=e4396c3d03e02370
POST /orders server trace=b047f7f8ccac4bde5a3e06010951bc10 span=e4396c3d03e02370 parent=-
process orders consumer trace=7714de8ff06efe34602b2486ca51de66 span=dd866df4a8053c7b parent=- link=b047f7f8ccac4bde5a3e06010951bc10/d4d4bb96d400514f
Спан обработки начал свой трейс и хранит ссылку (link) на спан отправки, в Jaeger это раздел
Links. Так по умолчанию советуют конвенции messaging (semconv 1.44, статус Development), причина в
главе 5.1. Плагин kotel v1.7.1 по умолчанию делает спан ребёнком, а ссылку только с
kotel.LinkSpans().
Сэмплирование в SDK: решает корень
Description() у ParentBased(TraceIDRatioBased(0.1)) перечисляет пять сэмплеров:
корневой TraceIDRatioBased{0.1} и AlwaysOnSampler или AlwaysOffSampler
для удалённого и локального родителя, сэмплированного или нет. Доля работает только в корне.
sampling гоняет 1000 запросов через три сервиса со своими TracerProvider и
считает записанные серверные спаны:
$ ./sampling | grep -A1 '^[A-D]:'
A: ParentBased(TraceIDRatioBased(0.1)) у gateway, ParentBased у остальных
recorded: gateway=98 orders=98 payments=98; complete traces=98, broken=0
--
B: gateway как в A, orders=TraceIDRatioBased(0.5), payments=TraceIDRatioBased(0.05) без ParentBased
recorded: gateway=99 orders=512 payments=46; complete traces=46, broken=466
--
C: как A, но клиент сам присылает traceparent с флагом 01
recorded: gateway=1000 orders=1000 payments=1000; complete traces=1000, broken=0
--
D: как C, но gateway сам решает и для чужого traceparent
recorded: gateway=119 orders=119 payments=119; complete traces=119, broken=0
В A у orders своя доля 0.5, но родитель есть всегда, и сервисы записали одни и те же трейсы.
В B каждый решает сам, и целых трейсов столько, сколько записал payments: с порогом
сравнивается часть того же trace_id, и 5 % входят в 10 % и в 50 %. В C флаг 01 от клиента
включил запись на всей цепочке; на входе это переопределяют
sdktrace.WithRemoteParentSampled(ratio) и sdktrace.WithRemoteParentNotSampled(ratio)
(опыт D).
Collector: конвейер, агент и шлюз
OpenTelemetry Collector принимает телеметрию, обрабатывает и отправляет дальше по конвейерам из receivers, processors и exporters.
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80 # от memory.max контейнера
spike_limit_percentage: 20
exporters:
otlp_grpc/jaeger:
endpoint: dv-o3-jaeger:4317
tls:
insecure: true
sending_queue:
batch: {} # пачки собирает экспортер
debug:
verbosity: basic
service:
telemetry:
metrics:
readers:
- pull:
exporter:
prometheus:
host: 0.0.0.0
port: 8888
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter]
exporters: [otlp_grpc/jaeger, debug]
memory_limiterпервым: выше мягкого порога он отказывает приёмнику, и отказ доходит до отправителя раньше, чем данные осели в конвейере. Процент считается от лимита контейнера: с--memory 256mв логеlimit_mib: 204, spike_limit_mib: 51.- Пачки. Годами последним ставили процессор
batch, теперь сборку пачек переносят в экспортер (sending_queue::batch, как в примерах документации).queuebatchна заменуbatchесть в ядре с 0.158, в contrib 0.161.0 его нет.batchставят после сэмплирования, чтобы не упаковывать выброшенное. - Имена. С 0.144 экспортеры называются
otlp_grpcиotlp_http, с 0.153loadbalancingсталload_balancing; старые имена остались псевдонимами. debugсverbosity: detailedпечатает в лог каждый спан.
Свои метрики Collector по умолчанию отдаёт на localhost:8888 внутри контейнера, и снаружи порт
молчит, пока в service.telemetry не задан хост:
$ curl -s 127.0.0.1:8888/metrics | grep -E '^otelcol_receiver_(accepted|refused)_spans'
otelcol_receiver_accepted_spans{receiver="otlp",transport="grpc"} 1231
otelcol_receiver_refused_spans{receiver="otlp",transport="grpc"} 0
Агент живёт рядом с сервисом (sidecar или DaemonSet): SDK шлёт на близкий адрес, агент буферизует, добавляет данные пода и пересылает. Шлюз, Deployment за Service, держит в одном месте сэмплирование, фильтры, адреса и секреты бэкендов. Часто работают оба слоя.
Tail sampling в Collector
tail_sampling копит спаны в памяти по trace_id, ждёт decision_wait с первого
спана трейса и проверяет политики. Трейс сохраняется, если хоть одна политика сказала «да» и ни одна
drop не сказала «выбросить»:
# фрагмент tail.yaml, остальное как выше
processors:
tail_sampling:
decision_wait: 10s # по умолчанию 30s
num_traces: 50000 # сколько трейсов держать в памяти до решения
policies:
- name: errors
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow
type: latency
latency: {threshold_ms: 500}
- name: rest
type: probabilistic
probabilistic: {sampling_percentage: 10}
batch: {}
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, tail_sampling, batch]
exporters: [otlp_grpc/jaeger]
tailwatch.sh шлёт 240 трейсов (200 быстрых, 20 с ошибкой, 20 по 600 мс) и считает виды в
Jaeger:
$ ./tailwatch.sh
sent 240 traces, shutdown: <nil>
t+2s: normal=0 error=0 slow=0
t+8s: normal=0 error=0 slow=0
t+18s: normal=15 error=20 slow=20
$ curl -s 127.0.0.1:8888/metrics | grep '^otelcol_processor_tail_sampling_global_count_traces_sampled'
otelcol_processor_tail_sampling_global_count_traces_sampled{decision="not_sampled",sampled="false"} 185
otelcol_processor_tail_sampling_global_count_traces_sampled{decision="sampled",sampled="true"} 55
Пока идёт decision_wait, в Jaeger пусто, потом приходят все ошибки, все медленные и 15
быстрых из 200 (rest, 10 % по хешу trace_id). latency меряет трейс от самого
раннего начала до самого позднего конца среди пришедших спанов, и трейс длиннее
decision_wait оценивается по неполным данным.
По документации процессора все спаны трейса должны прийти в один экземпляр. Обычный Service разложит
соединения по репликам, поэтому реплику выбирает слой с load_balancing (конфиг в третьем
вопросе).
Логи и метрики рядом с трейсом
В главе 5.1 trace_id клали в логгер один раз в middleware. Если брать контекст в момент записи, рядом ляжет и span_id текущего спана. Хендлер-обёртка для slog пишет обычный JSON в stdout:
type traceHandler struct{ slog.Handler }
func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
r.AddAttrs(slog.String("trace_id", sc.TraceID().String()), slog.String("span_id", sc.SpanID().String()))
}
return h.Handler.Handle(ctx, r)
}
func (h traceHandler) WithAttrs(as []slog.Attr) slog.Handler {
return traceHandler{h.Handler.WithAttrs(as)}
}
func (h traceHandler) WithGroup(name string) slog.Handler {
return traceHandler{h.Handler.WithGroup(name)}
}
$ ./logs handler
{"time":"2026-09-17T04:52:15.460124+03:00","level":"INFO","msg":"payment processed","order_id":"42","trace_id":"c8f7e82600c2ca853f9c94caf8c40f0f","span_id":"ed4acf94dc453bb3"}
{"time":"2026-09-17T04:52:15.460597+03:00","level":"INFO","msg":"payment processed","order_id":"43"}
Вторая запись сделана через log.Info без контекста, и trace_id взять неоткуда. Другой путь
даёт мост contrib/bridges/otelslog: slog.Handler, который отдаёт записи в Logs SDK
(дальше по OTLP в Collector) и сам ставит trace_id и span_id:
$ ./logs bridge # экспортер stdoutlog вместо OTLP, вывод сокращён
{"Timestamp":"2026-09-17T04:52:15.474825+03:00",…,"Body":{"Type":"STRING","Value":"payment processed"},…,"TraceID":"afaaa8735d55a3be3731393728bc95a6","SpanID":"19c924d56ee14427","TraceFlags":"01",…}
Если логи собирает агент со stdout подов, хватит своего хендлера; мост нужен для логов по OTLP. Сигнал
логов в Go пока Beta (sdk/log v0.22.0, API менялся в v1.45), место вызова мост кладёт в
code.* только с otelslog.WithSource(true). Писать в оба места позволяет
slog.NewMultiHandler (Go 1.26).
Exemplars (глава 5.1) Metrics SDK прикрепляет сам к измерениям с контекстом сэмплированного спана
(OTEL_METRICS_EXEMPLAR_FILTER=trace_based по умолчанию). Экспортер Prometheus отдаёт их в
OpenMetrics и protobuf, но не в обычном текстовом формате:
$ curl -s -H 'Accept: application/openmetrics-text' 127.0.0.1:9464/otel | grep 'le="0.05"'
http_server_request_duration_seconds_bucket{http_route="/orders/{id}",otel_scope_name="example.com/orders",otel_scope_schema_url="",otel_scope_version="",le="0.05"} 1 # {trace_id="f0466d31eefcf0b97878c20f38ada88c",span_id="1f356d2bfbbaa463"} 0.042 1.7896099358424408e+09
Без заголовка Accept та же строка кончается на } 1, без exemplar.
Сколько стоит инструментирование
Бенчмарк зовёт обработчик напрямую, без сети; экспортер выбрасывает спаны. Медианы
benchstat по шести прогонам на Apple M4 Pro:
| Вариант | на вызов | память | аллокаций |
|---|---|---|---|
| без обёртки | 47,5 нс | 208 Б | 4 |
otelhttp, провайдеры no-op | 1,07 мкс | 2,6 КиБ | 34 |
otelhttp и SDK, спан не сэмплирован | 1,27 мкс | 3,2 КиБ | 36 |
otelhttp и SDK, спан сэмплирован | 2,76 мкс | 6,1 КиБ | 42 |
| то же и metrics SDK | 3,68 мкс | 6,1 КиБ | 42 |
| ручной спан с атрибутом, сэмплирован | 477,8 нс | 1 КиБ | 5 |
| ручной спан с атрибутом, не сэмплирован | 114 нс | 208 Б | 3 |
У запроса с походом в базу микросекунды теряются в миллисекундах сети. Обёртка стоит около микросекунды и без SDK: сэмплирование срезает цену больше чем вдвое, но не до нуля. Полмикросекунды на ручной спан заметны только в цикле по десяти тысячам строк.
Поэтому не трассируют пробы /healthz и /ready (их отрезает WithFilter),
скрейп /metrics, горячие функции, итерации цикла (спан на весь цикл, число элементов в
атрибут) и кэш в памяти. Спан ставят на границе: входящий запрос, исходящий вызов, запрос в базу, заметный
шаг бизнес-логики.
Вопросы
4ctx до
исходящего вызова и сэмплирование.Провод
Разрыв сидит между последним спаном одного куска и корнем следующего. Там и смотрят заголовки:
печатающим сервером, как у партнёра выше, tcpdump или логом на стенде.
traceparentнет. Отправитель не вызвалotel.SetTextMapPropagator(OTEL_PROPAGATORSбезautopropне действует), клиент безotelhttp.NewTransportилиotelgrpc.NewClientHandler, или заголовок срезал прокси.- Заголовок есть, а trace_id у получателя новый. Получатель без пропагатора или ждёт другой
формат (
b3, заголовки Jaeger), тогда нужен составной пропагатор. - trace_id один, а середины нет. Сервисы сэмплируют каждый сам (опыт B) или сервис не
отправляет спаны: не тот адрес, TLS на открытый порт, выход без
Shutdown.
Код
Внутри сервиса контекст теряют, как в главе 5.1: запрос без r.Context(), горутина с
context.Background(). После Kafka два трейса со ссылкой в порядке вещей. Сломано, если в
заголовках сообщения нет traceparent.
BatchSpanProcessor сервис почти ничего не замечает: очередь на
2048 спанов полна, лишние выбрасываются, память не растёт, span.End() не ждёт, теряются
трейсы. С SimpleSpanProcessor каждый спан ждёт таймаут экспорта, и падение Collector
становится падением сервиса.Прогон
Экспортер смотрит в закрытый порт, сервис создаёт 100 000 спанов, метрики SDK включены
экспериментальным флагом (читает их ManualReader):
$ OTEL_GO_X_OBSERVABILITY=true OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4999 ./queuefull
100000 spans in 121ms, heap in use 3 MiB
otel.sdk.exporter.span.inflight{error.type=""} 512
otel.sdk.processor.span.queue.capacity{error.type=""} 2048
otel.sdk.processor.span.queue.size{error.type=""} 2048
otel.sdk.processor.span.processed{error.type="queue_full"} 97440
otel.sdk.processor.span.processed{error.type=""} 512
…
512 спанов в экспорте, 2048 в очереди, остальные выброшены с error.type=queue_full. Экспорт
пачки с повторами ограничен 10 секундами. Без флага выброшенное видно только в отладочном логе SDK.
Где сервис всё-таки страдает
- Остановка:
Shutdownбез таймаута ждёт каждую пачку из очереди до 10 секунд. С полной очередью это 50 секунд, больше срока до SIGKILL по умолчанию. - Огромная очередь:
OTEL_BSP_MAX_QUEUE_SIZEв сотни тысяч превращает недоступный Collector в расход памяти, в очереди спаны целиком.
tail_sampling. За
обычным Service его реплики видят обрывки трейсов, поэтому перед ними ставят слой с
load_balancing, который шлёт спаны трейса в одну реплику.Первый слой
exporters:
load_balancing:
routing_key: traceID # все спаны трейса уходят в один экземпляр
protocol:
otlp:
tls:
insecure: true
resolver:
k8s:
service: otelcol-sampler.observability # Service перед репликами с tail_sampling
ports: [4317]
Конфиг проходит otelcol-contrib validate на 0.161.0, остальное по README экспортера:
резолвер k8s следит за EndpointSlice (сервисному аккаунту нужны get,
list, watch), есть и dns по headless Service. При смене числа
реплик переезжает около R/N маршрутов, и трейс на стыке решают по половинам.
Второй слой и SDK
Политики как в разделе про tail sampling, но sampling_percentage: 5. Память считают от
потока: 2000 новых трейсов в секунду при decision_wait: 10s дают в буферах около 20 000
трейсов, переполнение видно по otelcol_processor_tail_sampling_sampling_trace_dropped_too_early.
В сервисах остаётся умолчание ParentBased(AlwaysSample()): выброшенный в SDK трейс с
ошибкой до Collector не дойдёт.
Среди сохранённых ошибок 100 %, остальных 5 %. RED берут из метрик сервиса
(otelhttp пишет http.server.request.duration сам) или считают до
tail_sampling.
client_golang, проще на нём и остаться. OTel SDK выгоден, когда телеметрия идёт через
Collector: один ресурс на трейсы и метрики, exemplars без ручного кода, готовые метрики
otelhttp и otelgrpc. Имена при этом поменяются.Одна метрика двумя способами
Гистограмма http.server.request.duration (единица s) и счётчик
orders.created ({order}) через экспортер Prometheus для OTel SDK и ниже на
client_golang:
$ curl -s 127.0.0.1:9464/otel | grep -E '^(http_server.*le="0.05"|orders_created|target_info)'
http_server_request_duration_seconds_bucket{http_route="/orders/{id}",otel_scope_name="example.com/orders",otel_scope_schema_url="",otel_scope_version="",le="0.05"} 1
orders_created_total{otel_scope_name="example.com/orders",otel_scope_schema_url="",otel_scope_version=""} 1
target_info{service_name="orders"} 1
$ curl -s 127.0.0.1:9464/prom | grep -E '^(http_server.*le="0.05"|orders_created)'
http_server_request_duration_seconds_bucket{http_route="/orders/{id}",le="0.05"} 1
orders_created_total 1
- Имена. Точки стали подчёркиваниями,
sстала суффиксом_seconds, у счётчика появился_total, единица в фигурных скобках пропала (стратегияUnderscoreEscapingWithSuffixes, умолчание экспортера с v0.61.0). - Метки. У каждой серии
otel_scope_*(убираетWithoutScopeInfo()), ресурс лежит сериейtarget_info,service_nameиз неё подтягивают черезgroup_left. - Рантайм.
contrib/instrumentation/runtimeотдаётgo_goroutine_count, а готовые дашборды ждутgo_goroutinesиgo_memstats_*. - Кардинальность. С v1.44 metrics SDK держит до 2000 наборов атрибутов на инструмент: 2500
разных
user_idдали 2000 серий, последняя сotel.metric.overflow=trueи значением 501. Уclient_golangтакого ограничителя нет.