Distributed tracing на практике: как связать запрос от фронта до базы и найти узкое место за минуты

Distributed tracing на практике: как связать запрос от фронта до базы и найти узкое место за минуты

Коротко:

  • Трейс - это сквозная запись пути запроса через все сервисы; span - отдельный шаг внутри этого пути.
  • OpenTelemetry дает единый стандарт инструментирования: один SDK для любого бэкенда хранения.
  • Jaeger и Grafana Tempo - два популярных хранилища трейсов; выбор зависит от того, есть ли у вас уже Grafana-стек.
  • Узкое место видно по самому длинному span-у на waterfall-диаграмме - это первое, на что смотрят при разборе медленного запроса.
  • Без передачи контекста между сервисами (propagation) трейс разорвется и не покажет полную картину.
  • Начинать стоит с одного критичного пути, а не с инструментирования всего сразу.

Почему логи и метрики не дают полной картины

Представьте: пользователь жалуется, что оформление заказа занимает 8 секунд. Prometheus показывает нормальный CPU и память. В Kibana есть логи, но они разбросаны по пяти сервисам, и непонятно, в каком именно порядке выполнялись вызовы. Вы знаете, что что-то медленно, но не знаете - что именно и почему.

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

Именно этот пробел закрывает трейсинг - третий столп observability. Он дает сквозную картину: от HTTP-запроса на фронте до SQL-запроса в PostgreSQL, включая все промежуточные вызовы между сервисами.

Как устроены trace и span

Каждый входящий запрос получает уникальный trace ID - идентификатор, который путешествует вместе с запросом через все сервисы. Внутри одного трейса каждая операция фиксируется как span: отдельный временной отрезок с именем, временем начала, длительностью и набором атрибутов.

Спаны выстраиваются в дерево. Корневой span - это обычно входящий HTTP-запрос на API Gateway или BFF. Дочерние спаны - вызовы к другим сервисам, запросы к базе, обращения к кешу, отправка сообщений в очередь. Каждый дочерний span знает ID своего родителя - это и позволяет восстановить полную иерархию.

На практике span содержит:

  • trace_id - общий для всего запроса
  • span_id - уникальный для этой операции
  • parent_span_id - ссылка на родителя
  • name - что делали (например, POST /checkout или db.query users)
  • start_time и duration
  • attributes - произвольные теги: http.status_code, db.statement, user.id
  • status - OK, ERROR или UNSET

Когда вы открываете трейс в Jaeger или Tempo, вы видите waterfall-диаграмму: горизонтальные полосы, каждая из которых соответствует одному span-у. Длина полосы - это время выполнения. Вложенность - это иерархия вызовов. Самая длинная полоса и есть ваше узкое место.

OpenTelemetry: стандарт, который стоит выбрать сразу

До появления OpenTelemetry каждый инструмент - Jaeger, Zipkin, Datadog - требовал своего SDK. Переход с одного бэкенда на другой означал переписывание инструментирования во всех сервисах.

OpenTelemetry (OTel) решил эту проблему: один SDK, один протокол (OTLP), любой бэкенд. Вы инструментируете сервис один раз, а куда отправлять данные - решаете через конфигурацию коллектора.

Архитектура выглядит так:

  1. Сервис использует OTel SDK для генерации спанов.
  2. SDK отправляет данные в OTel Collector - легковесный агент или сайдкар.
  3. Коллектор обрабатывает, фильтрует и пересылает данные в хранилище - Jaeger, Tempo, Zipkin или любой другой OTLP-совместимый бэкенд.

Официальная документация OpenTelemetry доступна на opentelemetry.io. Там же есть готовые инструкции по инструментированию для Go, Java, Python, Node.js и других языков.

Пример конфигурации OTel Collector (фрагмент otel-collector-config.yaml):

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

exporters:
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [jaeger]

Jaeger или Grafana Tempo: что выбрать

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

КритерийJaegerGrafana Tempo
ХранилищеCassandra, Elasticsearch, BadgerObject storage (S3, GCS, локальный диск)
ВизуализацияВстроенный UIЧерез Grafana (нет своего UI)
Интеграция с метрикамиОтдельноНативно через Grafana + Prometheus
Стоимость храненияВыше при больших объемахНиже за счет object storage
Когда выбиратьНет Grafana-стека, нужен standaloneУже используете Grafana + Loki + Prometheus

Если у вас уже настроен Grafana-стек с Prometheus и Loki, Tempo - очевидный выбор: трейсы, метрики и логи будут в одном интерфейсе, и вы сможете переходить от лога к трейсу одним кликом. Если трейсинг нужен изолированно или команда не хочет тащить Grafana, Jaeger проще поднять и использовать самостоятельно.

Инструментирование сервисов: с чего начать

Самая частая ошибка - пытаться охватить всё сразу. Начните с одного критичного пути: например, оформление заказа или авторизация. Это даст быстрый результат и покажет, как работает инструментирование на практике.

Шаг 1. Добавьте SDK в сервис

Для Node.js это выглядит так:

npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-otlp-grpc

Затем инициализируйте SDK в точке входа приложения:

const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-otlp-grpc');

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: 'http://otel-collector:4317',
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

Авто-инструментирование автоматически создает спаны для HTTP-запросов, работы с базой данных (через популярные драйверы), gRPC и других стандартных операций. Для кастомных операций добавляйте спаны вручную.

Шаг 2. Убедитесь, что контекст передается между сервисами

Это самый критичный момент. Если сервис A вызывает сервис B, trace ID должен передаться в заголовке HTTP-запроса. Стандарт называется W3C TraceContext: заголовок traceparent содержит trace ID и parent span ID.

Авто-инструментирование OTel делает это автоматически для HTTP и gRPC. Но если вы используете очереди сообщений (Kafka, RabbitMQ), контекст нужно передавать вручную через атрибуты сообщения - иначе трейс разорвется на границе очереди.

Частая ошибка: сервис создает новый trace ID при каждом входящем запросе, не проверяя, есть ли уже traceparent в заголовках. В результате трейс начинается не с фронта, а с этого сервиса, и вы теряете всю предысторию вызова.

Шаг 3. Добавьте атрибуты, которые помогут при разборе

Стандартные атрибуты от авто-инструментирования дают базу, но для реального расследования нужны бизнес-контекст и идентификаторы:

  • user.id - чтобы найти трейс конкретного пользователя
  • order.id, request.id - для корреляции с логами
  • db.statement - какой именно SQL выполнялся (осторожно с чувствительными данными)
  • http.route - маршрут, а не URL с параметрами

Добавить атрибут к текущему спану в Node.js:

const { trace } = require('@opentelemetry/api');
const span = trace.getActiveSpan();
if (span) {
  span.setAttribute('order.id', orderId);
  span.setAttribute('user.id', userId);
}

Как читать трейс и находить проблему

Допустим, запрос на оформление заказа занимает 4 секунды вместо ожидаемых 400 миллисекунд. Вы открываете Jaeger, вводите trace ID из лога (или ищете по тегу http.route=/checkout с фильтром по длительности > 3s) и видите waterfall.

На что смотреть в первую очередь:

  1. Самый длинный span. Если это db.query в сервисе инвентаря - проблема в запросе к базе. Если это ожидание ответа от внешнего API - проблема во внешней зависимости.
  2. Последовательные вызовы там, где можно параллельно. Если сервис заказов последовательно вызывает сервис пользователей, сервис инвентаря и сервис цен, а каждый занимает 300 мс - итого 900 мс. Если сделать вызовы параллельно, будет 300 мс.
  3. Пустые промежутки между спанами. Время между окончанием одного span-а и началом следующего - это накладные расходы: сериализация, сетевая задержка, ожидание в пуле соединений.
  4. Спаны со статусом ERROR. Даже если запрос в итоге завершился успешно, ошибка внутри (например, retry после первой неудачи) видна по красному статусу.

Реальный сценарий разбора: Представьте маркетплейс, где страница товара грузится 3 секунды. Waterfall показывает: корневой span занимает 3000 мс, из них 2800 мс - это span redis.get в сервисе рекомендаций. Атрибут redis.key указывает на ключ с TTL 0 - то есть кеш никогда не истекает, но при этом каждый раз делается полный пересчет из-за бага в логике инвалидации. Без трейса это заняло бы часы поиска по логам. С трейсом - 5 минут.

Sampling: как не утонуть в данных

В высоконагруженной системе записывать каждый запрос нецелесообразно - это дорого по хранилищу и создает нагрузку. Здесь нужен sampling - стратегия отбора трейсов для сохранения.

Два основных подхода:

  • Head-based sampling. Решение принимается на входе запроса - например, сохранять 10% всех трейсов. Просто, но вы можете пропустить редкие ошибки.
  • Tail-based sampling. Решение принимается после завершения трейса. Можно настроить: сохранять 100% трейсов с ошибками или длительностью > 1s, и только 1% остальных. Требует буферизации в коллекторе.

Для большинства команд хорошим стартом будет: 100% трейсов с ошибками + 5-10% успешных. OTel Collector поддерживает tail-based sampling через процессор tailsampling.

Связь трейсов с логами и метриками

Трейсинг становится значительно мощнее, когда он связан с остальными сигналами. Ключевой механизм - trace ID в логах.

Если каждый лог-запись содержит trace_id текущего запроса, вы можете из Grafana Tempo кликнуть на span и сразу перейти к соответствующим логам в Loki - без ручного копирования идентификаторов. Это называется exemplars и correlation, и в Grafana это работает нативно.

Для добавления trace ID в логи в Node.js с использованием winston:

const { trace } = require('@opentelemetry/api');

const addTraceContext = winston.format((info) => {
  const span = trace.getActiveSpan();
  if (span) {
    const ctx = span.spanContext();
    info.trace_id = ctx.traceId;
    info.span_id = ctx.spanId;
  }
  return info;
});

С метриками связь работает через exemplars в Prometheus: конкретная метрика (например, всплеск latency) может содержать ссылку на trace ID, который был в момент всплеска.

Когда трейсинг не нужен или не поможет

Инструментирование требует времени и поддержки. Есть ситуации, где затраты не оправданы:

  • Монолит с одной базой данных. Если у вас один сервис, профилировщик и медленные запросы в pg_stat_statements дадут ответ быстрее.
  • Проблема на уровне инфраструктуры. Если диск перегружен или сеть между нодами деградировала, трейс покажет симптом (все спаны медленные), но не причину - здесь нужны метрики инфраструктуры.
  • Очень ранняя стадия продукта. Если у вас два сервиса и 100 пользователей, стоимость настройки трейсинга может быть выше пользы. Начните с нормального логирования.
  • Команда не готова читать трейсы. Инструмент без культуры использования не работает. Если никто не открывает Jaeger при инцидентах, инвестиция бессмысленна.

Типичные ошибки при внедрении

Большинство проблем при первом внедрении предсказуемы:

  • Не настроена передача контекста через очереди. Kafka-консьюмер начинает новый трейс, и вы никогда не увидите, что запрос пришел из конкретного HTTP-вызова.
  • Слишком широкие имена спанов. Имя db.query без уточнения таблицы или операции бесполезно при разборе. Лучше: db.select users или db.insert orders.
  • Запись чувствительных данных в атрибуты. Пароли, токены, номера карт не должны попадать в трейсы. Настройте фильтрацию в коллекторе или на уровне SDK.
  • Head-based sampling 100% в продакшене. При нагрузке 1000 RPS это тысяча трейсов в секунду - хранилище быстро переполнится.
  • Инструментирование только одного сервиса. Трейс без дочерних спанов от других сервисов показывает только время ожидания, но не что происходило внутри вызываемого сервиса.

Чеклист внедрения

  1. Выбрать бэкенд: Jaeger (standalone) или Tempo (если есть Grafana-стек).
  2. Развернуть OTel Collector как DaemonSet или sidecar в Kubernetes.
  3. Добавить OTel SDK в первый критичный сервис, включить авто-инструментирование.
  4. Проверить, что trace ID появляется в заголовке traceparent исходящих запросов.
  5. Добавить SDK во все сервисы на критичном пути (не обязательно сразу во все).
  6. Настроить передачу контекста через очереди сообщений, если они есть.
  7. Добавить trace_id в логи для корреляции.
  8. Настроить sampling: 100% ошибок + 5-10% успешных запросов.
  9. Добавить бизнес-атрибуты: user.id, order.id, request.id.
  10. Провести первый разбор медленного запроса по waterfall-диаграмме.
  11. Договориться с командой: при инцидентах смотрим трейсы, а не только логи.

FAQ

Чем трейсинг отличается от логирования?

Логи фиксируют события внутри одного сервиса. Трейс показывает полный путь запроса через несколько сервисов с временными метками каждого шага. Они дополняют друг друга: trace ID в логах связывает оба инструмента.

Что такое span простыми словами?

Span - это один шаг в жизни запроса: например, HTTP-вызов к другому сервису или SQL-запрос к базе. У каждого шага есть имя, время начала и длительность. Из таких шагов складывается полная картина - трейс.

Нужен ли OpenTelemetry, если уже используется Jaeger?

Jaeger поддерживает прием данных в формате OTLP, поэтому OTel и Jaeger отлично работают вместе. OTel SDK - это инструментирование на стороне приложения, Jaeger - хранилище и UI. Использовать OTel стоит в любом случае: это защита от vendor lock-in.

Как найти узкое место, если трейс очень большой?

Сортируйте спаны по длительности - самый медленный будет первым. Также обращайте внимание на последовательные вызовы, которые можно было бы распараллелить, и на пустые промежутки между спанами (время ожидания в очередях или пулах соединений).

Что делать, если трейс обрывается на границе сервиса?

Скорее всего, один из сервисов не читает заголовок traceparent из входящего запроса или не передает его в исходящих. Проверьте, что OTel SDK инициализирован до обработки первого запроса, и что HTTP-клиент использует OTel-инструментированную версию.

Сколько это стоит в плане производительности?

Накладные расходы от OTel SDK при разумном sampling (5-10%) обычно составляют менее 1-2% CPU и памяти. Основная нагрузка - это сеть и хранилище. Tail-based sampling и object storage (для Tempo) существенно снижают стоимость.

Можно ли использовать трейсинг без Kubernetes?

Да. OTel Collector можно запустить как обычный процесс или Docker-контейнер. Jaeger тоже доступен как standalone-бинарник. Kubernetes упрощает деплой, но не является обязательным условием.

Итог

Трейсинг закрывает слепое пятно, которое остается после логов и метрик: он показывает, что именно происходит с запросом на каждом шаге его пути. Waterfall-диаграмма с временными метками спанов превращает расследование медленного запроса из многочасового перебора логов в пятиминутный анализ.

Начните с одного критичного пути, добавьте OTel SDK, настройте передачу контекста между сервисами и выберите хранилище под свой стек. Первые результаты будут видны уже после инструментирования двух-трех сервисов - и это быстро меняет то, как команда разбирает инциденты.