Distributed tracing: как отследить запрос через 10 сервисов и найти узкое место за минуты

Distributed tracing: как отследить запрос через 10 сервисов и найти узкое место за минуты

Запрос пришёл, но ответ не вернулся. Или вернулся, но через 8 секунд вместо 200 миллисекунд. Вы смотрите в логи первого сервиса - там всё чисто. Второй тоже молчит. Третий пишет что-то подозрительное, но непонятно, связано ли это с вашим запросом или с чем-то другим. Через полчаса вы всё ещё не знаете, где именно сломалось.

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

В этой статье разберём, как устроена распределённая трассировка изнутри, что такое span и trace на практике, как подключить OpenTelemetry за разумное время и как читать результаты в Jaeger, чтобы находить узкое место за минуты, а не часы.

Коротко:

  • Распределённая трассировка - это механизм, который прикрепляет к каждому запросу уникальный идентификатор и передаёт его через все сервисы, фиксируя время и контекст каждого шага.
  • Основные единицы: trace (весь путь запроса) и span (один шаг внутри этого пути - вызов сервиса, запрос к БД, HTTP-запрос).
  • OpenTelemetry - стандарт инструментирования, Jaeger и Zipkin - бэкенды для хранения и визуализации данных.
  • Внедрение начинается с автоматической инструментации: для большинства фреймворков достаточно подключить библиотеку и указать адрес коллектора.
  • Главные ошибки - не пробрасывать контекст через очереди и не добавлять атрибуты к спанам, из-за чего трейс теряет смысл.
  • Трейсинг не заменяет логи и метрики, а дополняет их - это три разных слоя observability.

Что такое trace и span и как они связаны

Представьте, что пользователь нажимает кнопку «Оформить заказ». Запрос идёт в API Gateway, оттуда - в Order Service, который дёргает Inventory Service, Payment Service и Notification Service. Каждый из них может обращаться к своей базе данных или внешнему API.

Trace - это весь этот путь целиком, от первого входящего запроса до последнего ответа. У него есть уникальный идентификатор - trace ID, который путешествует через все сервисы в заголовках HTTP-запросов или метаданных сообщений.

Span - это один конкретный шаг внутри трейса. Вызов Order Service - это спан. Запрос к базе данных внутри Order Service - это дочерний спан. Каждый спан знает своего родителя, поэтому из них складывается дерево вызовов.

У каждого спана есть:

  • Имя (например, POST /orders или db.query)
  • Время начала и окончания
  • Статус (OK, ERROR)
  • Атрибуты - произвольные пары ключ-значение (user_id, order_id, http.status_code)
  • События - точечные отметки внутри спана (например, момент валидации или момент записи в кэш)

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

Чем это отличается от логов и метрик

Логи, метрики и трейсы - три столпа observability, и каждый отвечает на свой вопрос.

ИнструментВопросОграничение
МетрикиЧто происходит в системе прямо сейчас?Не показывают конкретный запрос
ЛогиЧто именно произошло в сервисе?Сложно связать события между сервисами
ТрейсыКак конкретный запрос прошёл через систему?Не дают агрегированную картину по всем запросам

Метрики скажут, что p99 latency вырос до 5 секунд. Логи покажут ошибку в конкретном сервисе. Но только трейс покажет, что именно этот запрос застрял на 4.8 секунды в Inventory Service, потому что там случился N+1 запрос к базе данных.

Как работает проброс контекста

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

При HTTP-вызовах контекст передаётся в заголовках. Стандарт W3C TraceContext (поддерживается OpenTelemetry по умолчанию) использует два заголовка:

  • traceparent - содержит trace ID, span ID родителя и флаги сэмплирования
  • tracestate - дополнительные данные для конкретных реализаций

Когда сервис получает входящий запрос с этими заголовками, он извлекает trace ID и создаёт свой спан как дочерний. Если заголовков нет - создаёт новый корневой трейс.

С очередями сообщений сложнее: Kafka, RabbitMQ и другие брокеры не передают HTTP-заголовки автоматически. Контекст нужно явно упаковывать в метаданные сообщения при отправке и распаковывать при получении. Это одно из самых частых мест, где трейс «рвётся».

OpenTelemetry: стандарт, который объединил экосистему

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

Архитектура OpenTelemetry состоит из трёх частей:

  1. SDK и API - библиотеки для вашего языка (Python, Java, Go, Node.js, .NET и другие). Они создают спаны, добавляют атрибуты и управляют контекстом.
  2. OpenTelemetry Collector - отдельный процесс, который принимает телеметрию от сервисов, обрабатывает её (фильтрует, обогащает, сэмплирует) и отправляет в бэкенд.
  3. Экспортёры - плагины для отправки данных в конкретный бэкенд: Jaeger, Zipkin, Prometheus, Datadog и другие.

Официальная документация OpenTelemetry доступна на opentelemetry.io. Там же есть getting started для каждого языка.

Быстрый старт: инструментируем Python-сервис

Покажем минимальный рабочий пример для FastAPI. Автоматическая инструментация подхватывает входящие HTTP-запросы и исходящие вызовы без ручного создания спанов.

Установка зависимостей:

pip install opentelemetry-sdk \
            opentelemetry-instrumentation-fastapi \
            opentelemetry-instrumentation-httpx \
            opentelemetry-exporter-otlp

Инициализация в точке входа приложения:

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor

# Настройка провайдера
provider = TracerProvider()
exporter = OTLPSpanExporter(endpoint="http://otel-collector:4317")
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)

# Автоматическая инструментация
app = FastAPI()
FastAPIInstrumentor.instrument_app(app)
HTTPXClientInstrumentor().instrument()

Добавление собственных атрибутов к спану:

from opentelemetry import trace

tracer = trace.get_tracer(__name__)

@app.post("/orders")
async def create_order(order: OrderRequest):
    with tracer.start_as_current_span("validate-inventory") as span:
        span.set_attribute("order.user_id", order.user_id)
        span.set_attribute("order.items_count", len(order.items))
        # ваша логика
        result = await inventory_service.check(order.items)
        if not result.available:
            span.set_status(trace.StatusCode.ERROR, "items not available")
    return {"order_id": ...}

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

Jaeger: как читать результаты

Jaeger - open-source бэкенд для хранения и визуализации трейсов, разработанный Uber и переданный в CNCF. Запустить его локально для разработки можно одной командой:

docker run -d --name jaeger \
  -p 16686:16686 \
  -p 4317:4317 \
  jaegertracing/all-in-one:latest

UI будет доступен на http://localhost:16686.

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

Что искать в первую очередь:

  • Самый длинный спан - очевидное узкое место. Если он в базе данных, смотрите на атрибут db.statement.
  • Последовательные вызовы там, где можно параллельные - если три сервиса вызываются один за другим и не зависят друг от друга, это потенциальная оптимизация.
  • Спаны с ошибками - в Jaeger они подсвечены красным. Атрибут error и события спана покажут детали.
  • Большие gaps между спанами - время, которое не занято ни одним дочерним спаном, часто означает сетевую задержку или ожидание в очереди.

Сэмплирование: почему нельзя писать всё подряд

В нагруженной системе может быть тысячи запросов в секунду. Хранить трейс для каждого из них - дорого. Сэмплирование решает, какой процент запросов записывать.

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

Head-based sampling - решение принимается в начале запроса, до того как известен его исход. Просто и дёшево, но вы можете пропустить редкие ошибки, если они попали в «несэмплированные» запросы.

Tail-based sampling - решение принимается после завершения запроса. Можно записывать все медленные или ошибочные запросы независимо от общего процента. OpenTelemetry Collector поддерживает tail sampling через одноимённый процессор. Это правильный выбор для production, но требует буферизации трейсов в коллекторе.

Разумный старт для большинства систем: 100% сэмплирование в dev/staging, 5-10% в production плюс 100% для запросов с ошибками.

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

Большинство проблем с трейсингом не в коде, а в том, как команда думает о нём.

Не пробрасывать контекст через очереди. Сервис A отправляет сообщение в Kafka, сервис B его обрабатывает - но trace ID не передан в заголовках сообщения. В итоге трейс обрывается на границе брокера, и вы видите два несвязанных трейса вместо одного.

Создавать спаны без атрибутов. Спан с именем process и без атрибутов бесполезен. Добавляйте хотя бы идентификаторы сущностей: user_id, order_id, product_id. Тогда по трейсу можно будет найти конкретную проблему конкретного пользователя.

Инструментировать только входящие запросы, игнорируя исходящие. Если сервис делает запросы к внешнему API или к базе данных без создания дочерних спанов, вы не увидите, сколько времени там уходит.

Использовать синхронный экспортёр в production. SimpleSpanProcessor отправляет каждый спан сразу, блокируя поток. Используйте BatchSpanProcessor - он буферизует спаны и отправляет пачками.

Считать, что автоматической инструментации достаточно. Она покрывает HTTP-вызовы и популярные библиотеки, но не знает о вашей бизнес-логике. Добавляйте собственные спаны для значимых операций: валидации, расчёта цены, обращения к кэшу.

Zipkin vs Jaeger: что выбрать

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

КритерийJaegerZipkin
ПроисхождениеUber, CNCF graduatedTwitter, open-source
UIБогаче, есть сравнение трейсовПроще, быстрее разобраться
ХранилищеCassandra, Elasticsearch, BadgerElasticsearch, Cassandra, MySQL
МасштабированиеЛучше для больших объёмовПроще в небольших установках
Интеграция с OTelНативная поддержка OTLPЧерез экспортёр Zipkin

Для новых проектов с OpenTelemetry логичнее выбрать Jaeger: он принимает данные по протоколу OTLP напрямую, без дополнительных конвертаций. Zipkin хорош, если в команде уже есть опыт с ним или нужна простая установка для небольшой системы.

Если нужна коммерческая платформа с алертингом, дашбордами и долгосрочным хранением - смотрите на Grafana Tempo (бесплатный, хорошо интегрируется с Grafana и Loki), Datadog APM или Honeycomb.

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

Не каждой системе нужна распределённая трассировка. Монолит с одной базой данных прекрасно отлаживается через обычные логи и профилировщик. Добавлять OTel ради одного сервиса - это overhead без пользы.

Трейсинг начинает окупаться, когда:

  • В системе больше трёх-четырёх сервисов, которые вызывают друг друга
  • Команда тратит больше 30 минут на поиск причины медленного запроса
  • Есть асинхронные цепочки через очереди, где логи не дают сквозной картины

Если система маленькая, а команда тратит время на настройку инфраструктуры вместо продукта - это неправильный приоритет. Начните с хороших структурированных логов с correlation ID, и этого может хватить на долгое время.

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

  1. Выбрать бэкенд: Jaeger для старта, Grafana Tempo или коммерческое решение для production
  2. Развернуть OpenTelemetry Collector как промежуточный слой между сервисами и бэкендом
  3. Подключить автоматическую инструментацию для каждого сервиса (HTTP-клиент, фреймворк, ORM)
  4. Убедиться, что trace ID пробрасывается через все транспорты: HTTP, gRPC, Kafka, RabbitMQ
  5. Добавить атрибуты к спанам: идентификаторы сущностей, статусы, параметры запросов
  6. Настроить сэмплирование: 100% в dev, tail-based в production
  7. Добавить собственные спаны для критичных бизнес-операций
  8. Проверить, что ошибки корректно помечаются через span.set_status(ERROR)
  9. Настроить алерт на p99 latency по трейсам или на рост доли ошибочных трейсов

FAQ

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

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

Чем OpenTelemetry отличается от Jaeger?

OpenTelemetry - это стандарт и набор библиотек для инструментирования кода. Jaeger - это бэкенд для хранения и отображения данных. OTel собирает трейсы, Jaeger их показывает. Можно использовать OTel с любым совместимым бэкендом.

Нужен ли трейсинг, если у нас только три сервиса?

Зависит от сложности взаимодействий. Если три сервиса вызывают друг друга синхронно и команда уже теряет время на поиск медленных запросов - да, стоит добавить. Если система простая и логи справляются - можно обойтись correlation ID в логах.

Как связать трейс с конкретным пользователем или заказом?

Добавьте атрибуты к корневому спану: span.set_attribute("user.id", user_id). Jaeger и другие бэкенды позволяют искать трейсы по атрибутам. Так можно найти все запросы конкретного пользователя за последний час.

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

Нужно явно передавать контекст в заголовках сообщения. OpenTelemetry предоставляет propagate.inject(headers) при отправке и propagate.extract(headers) при получении. Для Kafka есть готовые инструментации в пакете opentelemetry-instrumentation-kafka-python.

Сколько места занимают трейсы в хранилище?

Зависит от нагрузки и количества атрибутов. При 1000 RPS и 10% сэмплировании типичная установка Jaeger с Elasticsearch потребляет 10-50 ГБ в день. Tail-based sampling с фокусом на ошибках и медленных запросах существенно снижает объём без потери полезности данных.

Можно ли использовать трейсинг вместе с существующими логами?

Да, и это правильный подход. Добавьте trace ID в каждую строку лога - тогда по трейсу можно будет найти все связанные логи, и наоборот. В Python это делается через logging.Filter, который достаёт текущий trace ID из контекста OTel.

Итог

Распределённая трассировка - это не магия и не сложная инфраструктура. Это структурированный способ ответить на вопрос «где именно потерялось время», когда запрос проходит через несколько сервисов. OpenTelemetry снял главную боль - теперь не нужно выбирать между вендорами на этапе инструментирования кода.

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