Наблюдаемость: логи, метрики, трейсинг
В монолите отладка — это стектрейс. В микросервисах запрос проходит через восемь процессов на разных машинах, и без специальных усилий вы просто ослепли.
Наблюдаемость (observability) — свойство системы, позволяющее по её внешним сигналам (логи, метрики, трейсы) понять, что происходит внутри, не выкатывая новый код.
Разница с привычным мониторингом принципиальна. Мониторинг отвечает на вопросы, которые вы придумали заранее: «жив ли сервис», «не кончилось ли место». Наблюдаемость нужна для вопросов, которых вы не предвидели: «почему у пользователей из мобильного приложения с промокодом оплата тормозит, и только по вторникам?» Заранее такой дашборд никто не нарисует — данные должны позволять задать вопрос уже после того, как всё сломалось.
Три столпа
| Столп | На какой вопрос отвечает | Что это физически | Чем ограничен |
| Метрики | Сломалось? Насколько плохо? Когда началось? | Числовые ряды во времени: rps, доля ошибок, время ответа | Дёшевы, но в них нельзя класть уникальные значения (см. кардинальность ниже) |
| Логи | Что именно случилось с этим запросом? | События с контекстом, обычно JSON-строки | Дороги при больших объёмах; нужны уровни и сэмплирование |
| Трейсы | Где именно потерялись 800 мс из 900? | Дерево спанов одного запроса через все сервисы | Почти всегда сэмплируются: хранить 100% трейсов дорого |
В настоящем инциденте вы пользуетесь ими строго по очереди. Метрика кричит: «доля 5xx выросла до 4%». Трейс показывает: «время уходит в сервисе инвентаря, а внутри него — в запросе к Postgres». Лог договаривает: «lock wait timeout на таблице reservations». Метрика — что, трейс — где, лог — почему. Пропустите любой столп, и один из этих вопросов останется без ответа.
Trace-id: нить, которая связывает всё
Это самая важная вещь во всём уроке. Один запрос пользователя порождает десятки записей в логах разных сервисов на разных машинах. Без общего идентификатора связать их невозможно — вы будете искать «что-то в логах платежей около 10:31:02», где в эту секунду 4000 записей от других пользователей.
Trace-id — идентификатор, который рождается на входе в систему (обычно в API Gateway), кладётся в каждую запись лога и передаётся дальше по всем вызовам. Отраслевой стандарт — W3C Trace Context, заголовок traceparent:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
^^ ^ ^ ^
| | | флаги: 01 = трейс сэмплируется
| | span-id (8 байт): ЭТОТ конкретный шаг
| trace-id (16 байт): один на весь путь запроса
версия формата
Здесь два разных id, и путать их нельзя. Trace-id один на весь запрос, от кнопки до базы. Span-id — идентификатор одного шага (один HTTP-вызов, один запрос к базе). У каждого спана есть родитель, и из этих связей собирается дерево. Визуализируют его «водопадом»:
api-gateway [============================ 820 мс ============================]
orders [====================== 760 мс ======================]
payments [== 90 мс ==]
inventory [============ 410 мс ============]
postgres [========== 380 мс =========] <- вот он, виновник
notify [= 12 мс =]
Одна картинка мгновенно отвечает на вопрос, ради которого раньше поднимали пять человек в чат: 380 мс из 820 съел один запрос к Postgres в сервисе инвентаря. Без трейсинга этот вывод добывается часами.
Где trace-id теряется
Обычно в трёх местах, и все три надо проверять руками:
- Асинхронная граница. Сервис положил сообщение в RabbitMQ и не проложил
traceparentв headers сообщения. Трейс обрывается ровно там, где интереснее всего. Правило: любой отправитель в очередь обязан класть контекст в заголовки сообщения, любой потребитель — доставать его оттуда и продолжать трейс. - Фоновые задачи и пулы потоков. Контекст живёт в контексте выполнения (
contextvars,AsyncLocal,ThreadLocal) и не переезжает в новый поток сам. - Самописный HTTP-клиент. Автоинструментация патчит известные ей библиотеки; ваш велосипед она не знает и заголовок не вставит.
Логи: что писать и чего не писать никогда
Первое: логи должны быть структурными. Не строка "order 8831 failed: timeout", которую можно только грепать, а объект с полями — по нему можно фильтровать, группировать и строить графики:
{
"ts": "2026-07-14T10:31:02.481Z",
"level": "error",
"service": "orders",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"event": "payment_failed",
"order_id": "ord-8831",
"user_id": 10427,
"reason": "upstream_timeout",
"upstream": "payments",
"duration_ms": 3002
}
Такой лог даёт мгновенный ответ на вопрос «сколько заказов упало по таймауту платежей за час» — обычной агрегацией, без регулярных выражений. И, главное, в нём есть trace_id: из лога вы одним кликом проваливаетесь в трейс.
| Пишем всегда | trace_id, span_id, имя сервиса, уровень, событие, длительность, коды ошибок внешних сервисов, ключевые бизнес-идентификаторы (order_id, user_id) |
| Пишем осторожно | Тела запросов (обрезать до N килобайт), стектрейсы (только на error), SQL-запросы (текст — да, значения параметров — нет) |
| Не пишем НИКОГДА | Пароли, токены и заголовок Authorization, номера карт и CVV, персональные данные (паспорт, адрес, телефон), содержимое личных сообщений, ключи API |
Последнюю строку стоит воспринимать всерьёз. Логи уезжают в поисковый индекс, доступ к которому есть у половины инженеров, у подрядчиков и у мониторинга. Токен, попавший в лог, считается скомпрометированным — его придётся отзывать. А номер карты в логе — это уже не техническая проблема, а разговор с юристами.
Метрики: RED и почему среднее врёт
Для любого сервиса минимальный набор метрик называется RED:
| R — Rate | Сколько запросов в секунду приходит |
| E — Errors | Какая доля из них завершается ошибкой |
| D — Duration | Распределение времени ответа — именно распределение, а не одно число |
На слове «распределение» ломаются почти все. Смотреть на среднее время ответа бессмысленно, и вот почему:
import statistics
# длительности 100 запросов в миллисекундах: 95 быстрых и 5 «залипших»
latencies = [40 + (i % 20) for i in range(95)] + [2100, 2400, 2700, 3000, 5200]
latencies.sort()
def percentile(data, p):
index = min(int(len(data) * p / 100), len(data) - 1)
return data[index]
print("запросов: %d" % len(latencies))
print("среднее: %.0f мс -- выглядит вполне прилично" % statistics.mean(latencies))
print("медиана (p50): %d мс" % percentile(latencies, 50))
print("p95: %d мс" % percentile(latencies, 95))
print("p99: %d мс -- вот что чувствует самый несчастный клиент" % percentile(latencies, 99))
Результат:
запросов: 100
среднее: 201 мс -- выглядит вполне прилично
медиана (p50): 50 мс
p95: 2100 мс
p99: 5200 мс -- вот что чувствует самый несчастный клиент
Среднее — 201 мс, дашборд зелёный, все довольны. При этом каждый двадцатый пользователь ждёт больше двух секунд, а каждый сотый — больше пяти. Именно эти люди пишут в поддержку и уходят к конкурентам. Поэтому в целях (SLO) фигурируют перцентили: «p99 времени ответа меньше 500 мс», а не «среднее время ответа».
Кардинальность — мина под мониторингом
У метрики есть лейблы (метки): http_requests_total{service="orders", status="500"}. Каждая уникальная комбинация лейблов — это отдельный временной ряд в хранилище. Пара десятков сервисов × пять статусов = сотня рядов, отлично.
А теперь кто-то добавляет лейбл user_id. Миллион пользователей = миллион рядов. Prometheus съедает всю память и умирает — и вместе с ним умирает ваш мониторинг, ровно в тот момент, когда он нужнее всего. Правило железное: уникальные идентификаторы (user_id, order_id, trace_id) идут в логи и трейсы, но НИКОГДА в лейблы метрик. В метриках место только тому, у чего мало значений: сервис, эндпоинт, код ответа, регион.
Как это работает
Писать это руками не нужно — есть OpenTelemetry (OTel), вендоронезависимый стандарт и набор SDK. Его автоинструментация подменяет популярные HTTP-клиенты, серверные фреймворки и драйверы БД, так что traceparent сам вставляется в исходящие запросы и сам вычитывается из входящих. Руками код нужно писать только на границах, о которых OTel не знает: свой протокол, экзотическая очередь, ручной пул потоков.
Дальше данные едут через OTel Collector — отдельный процесс, который принимает сигналы от приложений, фильтрует, сэмплирует и раскладывает по хранилищам. Типичный стек: Prometheus или VictoriaMetrics (метрики), Loki или Elasticsearch (логи), Jaeger или Tempo (трейсы), Grafana как единое окно поверх всего.
Важная мелочь: приложение пишет логи в stdout и не знает, куда они поедут. Собирает их агент (Promtail, Fluent Bit, Vector), запущенный рядом. Приложение, которое само ходит в Elasticsearch, — это приложение, которое ляжет вместе с Elasticsearch.
Наконец, сэмплирование. Хранить трейс каждого запроса нереально дорого. Есть два подхода. Head-based: решение принимается на входе (оставляем 1% трейсов) — дёшево, но именно тот трейс, который сломался, вы с вероятностью 99% выбросите. Tail-based: коллектор дожидается всего трейса и оставляет те, где были ошибки или превышена длительность — дороже, зато в хранилище попадает ровно то, что интересно.
Частые ошибки
- Нет сквозного trace-id. Отладка превращается в археологию по таймстампам. Это ошибка номер один — всё остальное лечится, эта нет.
- Trace-id есть, но обрывается на очереди. Контекст не проложен в headers сообщения, и трейс кончается ровно там, где начинается интересное.
- Смотрим на среднее время ответа. Оно прячет хвост распределения. Смотрите p95 и p99.
- Взрыв кардинальности.
order_idв лейбле Prometheus кладёт мониторинг во время инцидента. - Логи-строки вместо структурных. Грепать можно, агрегировать нельзя. Одна строка — один
eventи поля, а не форматированный текст. - Логирование в цикле. Строка на каждый из 10 000 элементов: сервис тратит на логи больше, чем на работу, а счёт за хранение обгоняет счёт за вычисления.
- Алерты на всё подряд. Дежурный за неделю привыкает их игнорировать (это называется alert fatigue). Алертить надо на симптомы, которые чувствует пользователь — доля 5xx, p99, — а не на «CPU 80%».
- Health check всегда возвращает 200. Kubernetes уверен, что под здоров, а тот давно потерял базу и молча отвечает ошибками.
- Секреты в логах. Токен в логе = отозванный токен. Данные карты в логе = инцидент с регулятором.
Итоги
- Наблюдаемость — про вопросы, которых вы не предвидели. Мониторинг — про те, что предвидели. В микросервисах нужны оба.
- Метрики отвечают «что сломалось», трейсы — «где», логи — «почему». В инциденте вы идёте именно в таком порядке.
- Сквозной
trace-id— обязательное условие. Рождается на входе, летит во всех HTTP-заголовках и в headers сообщений, попадает в каждую строку лога. - Стандарт — W3C
traceparent: версия, trace-id (один на запрос), span-id (один на шаг), флаги. - Логи — структурные, с
trace_id. Никогда — пароли, токены, карты, персональные данные. - Метрики — RED (Rate, Errors, Duration) и перцентили вместо среднего. Уникальные id в лейблы не кладём никогда.
- Инструментирует всё это OpenTelemetry; логи пишем в stdout, а собирает их агент рядом.