Запрос пришёл, но ответ не вернулся. Или вернулся, но через 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 состоит из трёх частей:
- SDK и API - библиотеки для вашего языка (Python, Java, Go, Node.js, .NET и другие). Они создают спаны, добавляют атрибуты и управляют контекстом.
- OpenTelemetry Collector - отдельный процесс, который принимает телеметрию от сервисов, обрабатывает её (фильтрует, обогащает, сэмплирует) и отправляет в бэкенд.
- Экспортёры - плагины для отправки данных в конкретный бэкенд: 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:latestUI будет доступен на 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: что выбрать
Оба инструмента решают одну задачу, но есть различия в деталях.
| Критерий | Jaeger | Zipkin |
|---|---|---|
| Происхождение | Uber, CNCF graduated | Twitter, open-source |
| UI | Богаче, есть сравнение трейсов | Проще, быстрее разобраться |
| Хранилище | Cassandra, Elasticsearch, Badger | Elasticsearch, Cassandra, MySQL |
| Масштабирование | Лучше для больших объёмов | Проще в небольших установках |
| Интеграция с OTel | Нативная поддержка OTLP | Через экспортёр Zipkin |
Для новых проектов с OpenTelemetry логичнее выбрать Jaeger: он принимает данные по протоколу OTLP напрямую, без дополнительных конвертаций. Zipkin хорош, если в команде уже есть опыт с ним или нужна простая установка для небольшой системы.
Если нужна коммерческая платформа с алертингом, дашбордами и долгосрочным хранением - смотрите на Grafana Tempo (бесплатный, хорошо интегрируется с Grafana и Loki), Datadog APM или Honeycomb.
Когда трейсинг не нужен
Не каждой системе нужна распределённая трассировка. Монолит с одной базой данных прекрасно отлаживается через обычные логи и профилировщик. Добавлять OTel ради одного сервиса - это overhead без пользы.
Трейсинг начинает окупаться, когда:
- В системе больше трёх-четырёх сервисов, которые вызывают друг друга
- Команда тратит больше 30 минут на поиск причины медленного запроса
- Есть асинхронные цепочки через очереди, где логи не дают сквозной картины
Если система маленькая, а команда тратит время на настройку инфраструктуры вместо продукта - это неправильный приоритет. Начните с хороших структурированных логов с correlation ID, и этого может хватить на долгое время.
Чеклист внедрения
- Выбрать бэкенд: Jaeger для старта, Grafana Tempo или коммерческое решение для production
- Развернуть OpenTelemetry Collector как промежуточный слой между сервисами и бэкендом
- Подключить автоматическую инструментацию для каждого сервиса (HTTP-клиент, фреймворк, ORM)
- Убедиться, что trace ID пробрасывается через все транспорты: HTTP, gRPC, Kafka, RabbitMQ
- Добавить атрибуты к спанам: идентификаторы сущностей, статусы, параметры запросов
- Настроить сэмплирование: 100% в dev, tail-based в production
- Добавить собственные спаны для критичных бизнес-операций
- Проверить, что ошибки корректно помечаются через
span.set_status(ERROR) - Настроить алерт на p99 latency по трейсам или на рост доли ошибочных трейсов