Когда приложение состоит из одного app.py, жизнь кажется удивительно простой. Открываешь терминал, запускаешь tail -f, смотришь несколько строк логов и довольно быстро понимаешь, что происходит. Ошибка подключения к базе данных выглядит как ошибка подключения к базе данных. Исключение в коде находится ровно в том месте, где его выбросили. Если что-то сломалось, виновник обычно обнаруживается быстрее, чем успевает остыть кофе. Именно поэтому многие инженеры годами живут с ощущением, что логов достаточно для любых задач наблюдаемости.
А потом приложение начинает расти.
Сначала появляется PostgreSQL. Затем Redis для кэширования. Потом RabbitMQ, потому что "очереди пригодятся". Через некоторое время возникает Kafka, потому что предыдущих очередей внезапно оказывается недостаточно. Появляется второй сервис. Затем третий. Затем кто-то предлагает вынести авторизацию в отдельный компонент. Через полгода инфраструктура уже работает в Kubernetes, а в схеме зависимостей неожиданно обнаруживается "временный" сервис, который должен был просуществовать две недели, но спокойно живёт третий год и обслуживает критически важный бизнес-процесс.
Типичная архитектура начинает выглядеть примерно так:
Internet
↓
API Gateway
↓
Auth Service
↓
Users Service
↓
Redis
↓
PostgreSQL
↓
Notification Service
↓
Kafka
↓
Email Worker
Именно в этот момент заканчивается магия и начинается эксплуатационная реальность. Потому что внезапно выясняется, что разные источники информации рассказывают разные истории. Логи утверждают одно. Метрики показывают другое. Разработчик честно говорит сакральную фразу "у меня локально работает". DevOps-инженер уже третий час смотрит в Grafana примерно так же, как шаман смотрит в костёр в надежде увидеть ответ на фундаментальные вопросы бытия. А руководитель проекта спрашивает, почему страница оформления заказа открывается восемь секунд.
Проблема заключается вовсе не в том, что логи плохие или метрики бесполезны. Они просто отвечают на другие вопросы. Логи прекрасно описывают отдельные события. Метрики позволяют понять состояние системы в целом. Но распределённые приложения породили новую задачу: научиться видеть жизнь одного конкретного запроса, который проходит через десятки компонентов инфраструктуры. Именно для решения этой проблемы и появились трейсы.
Что такое трейсы и почему они изменили подход к диагностике
Если отбросить сложную терминологию и маркетинговые определения, trace - это полная история путешествия одного запроса через всю систему. Представьте, что пользователь открывает страницу своего профиля. Для него это одно действие: нажал кнопку и получил результат. Для инфраструктуры это может оказаться целой цепочкой взаимодействий.
Например:
GET /api/users/42
↓
API Gateway
↓
Auth Service
↓
Redis lookup
↓
Users Service
↓
PostgreSQL
↓
Notification Service
↓
Kafka publish
↓
HTTP Response
Трейс позволяет увидеть всю эту цепочку как единое целое. Не отдельные журналы событий, разбросанные по десяткам Pod'ов и серверов, а именно последовательность действий, относящихся к одному пользовательскому запросу. И это фундаментально меняет процесс диагностики.
Предположим, что пользователь жалуется на медленную работу системы. Без трейсов расследование обычно выглядит следующим образом. Сначала открываются журналы API Gateway. Затем начинается поиск подходящих записей в логах Users Service. После этого приходится сопоставлять временные метки из PostgreSQL, искать следы публикации сообщений в Kafka и надеяться, что часы на всех узлах синхронизированы достаточно хорошо. Очень быстро расследование превращается в археологическую экспедицию, где приходится восстанавливать древнюю цивилизацию по отдельным осколкам керамики.
С трейсами картина выглядит иначе:
GET /api/users/42
Total duration: 8423 ms
├─ Auth Service 42 ms
├─ Redis GET 3 ms
├─ Users Service 18 ms
├─ PostgreSQL 7962 ms
├─ Kafka Produce 54 ms
└─ Response Serialization 12 ms
И внезапно оказывается, что проблема вовсе не в Kubernetes, не в сети и не в загадочных "тормозах Grafana". Просто кто-то написал SQL-запрос, который выполняется почти восемь секунд.
Именно поэтому инженеры, впервые увидевшие нормально настроенный distributed tracing, обычно испытывают одно и то же чувство.
Господи. Я наконец-то вижу, что происходит.
Из чего состоит trace
Чтобы понимать дальнейшую работу OpenTelemetry, необходимо разобраться в трёх основных сущностях распределённого трассирования. На первый взгляд терминология кажется сложной, однако на практике всё довольно логично.
Первая сущность - это сам Trace. Он представляет собой полную историю обработки пользовательского запроса.
Например:
GET /api/users/42
Вторая сущность называется Span. Если trace - это книга целиком, то span - отдельная глава внутри неё. Каждый span описывает конкретную операцию.
Например:
Auth validation
Redis GET user:42
SELECT * FROM users WHERE id = 42
POST /notifications/send
Kafka publish
Каждый из этих шагов имеет собственную длительность, статус выполнения и набор дополнительных атрибутов.
Третья сущность - Context Propagation, или передача контекста между сервисами. Именно она превращает отдельные фрагменты в единую историю.
Представим ситуацию:
Service A
↓ HTTP
Service B
↓ gRPC
Service C
Если контекст передаётся корректно, все три компонента понимают:
Мы участвуем в обработке одного и того же пользовательского запроса.
Если propagation отсутствует, распределённая система снова превращается в набор несвязанных журналов:
Service A:
"Calling user service"
Service B:
"Executing SQL"
Service C:
"Publishing event"
Наверное, это связано...
А может быть и нет.
Именно поэтому исправление сломанного propagation является одной из самых частых задач при внедрении distributed tracing.
Почему логи уже не спасают
Логи остаются одним из важнейших инструментов эксплуатации. Они позволяют увидеть исключения, понять последовательность событий и собрать детальную техническую информацию. Но распределённые системы выявили их фундаментальное ограничение.
Представьте себе три сервиса.
Первый пишет:
Calling user service...
Второй сообщает:
Fetching user information...
Третий фиксирует:
SQL query started...
Теперь попробуйте ответить на три простых вопроса.
Какие именно записи относятся к запросу конкретного пользователя?
Почему этот запрос выполнялся восемь секунд?
Какой сервис оказался узким местом?
Если система обрабатывает сотни запросов в секунду, подобная задача превращается в интеллектуальную пытку. Именно поэтому распределённое трассирование перестало быть модной игрушкой крупных компаний и превратилось в необходимый инструмент эксплуатации современных систем. И именно вокруг этой идеи сегодня строится весь OpenTelemetry - стандарт, который наконец позволил индустрии договориться о том, как должна выглядеть современная наблюдаемость.
Появление OpenTelemetry можно считать одним из самых важных событий в истории наблюдаемости за последние годы. До его появления индустрия напоминала своеобразный зоопарк несовместимых решений. Одни библиотеки умели работать только с трейсами, другие специализировались исключительно на метриках, третьи использовали собственные форматы передачи данных и требовали отдельной интеграции. Каждый новый инструмент тащил за собой собственный SDK, собственные агенты и собственные правила игры. В результате переход между системами наблюдаемости часто превращался в отдельный инфраструктурный проект с переписыванием кода и мучительными миграциями. Особенно весело становилось в больших компаниях, где одна команда использовала Zipkin, другая Jaeger, третья вообще писала собственные решения, а четвёртая продолжала жить исключительно в мире grep ERROR.
OpenTelemetry фактически попытался навести порядок в этом хаосе. Его главная идея удивительно проста: давайте договоримся об общих стандартах. Не о конкретном продукте, не о конкретном поставщике услуг и даже не о конкретном хранилище данных. Именно о стандартах. В результате появился набор API, SDK и протоколов передачи данных, которые позволяют инструментировать приложения один раз, а затем отправлять телеметрию практически куда угодно.
Именно поэтому OpenTelemetry сегодня воспринимается как новый стандарт индустрии.
Он объединяет три основных типа данных наблюдаемости:
Traces
↓
Metrics
↓
Logs
Или, как любят говорить в мире observability:
Unified Telemetry
И, если честно, после десятилетий попыток договориться хотя бы о чём-нибудь, сам факт того, что индустрия смогла выработать общий подход, уже заслуживает уважения.
Типичная архитектура современной системы распределённого трассирования сегодня выглядит следующим образом:
Application
↓
OpenTelemetry SDK
↓
OpenTelemetry Collector
↓
Jaeger / Tempo
↓
Grafana
На первый взгляд эта схема может показаться избыточной. Особенно если сравнивать её с наивным вариантом вида:
Application
↓
Jaeger
Именно так начинают многие инженеры. Настроили экспортёр, увидели первые трейсы и решили, что задача решена. Однако инфраструктура имеет неприятную привычку усложняться.
Сегодня вы используете Jaeger:
App
↓
Jaeger
Через полгода появляется желание перейти на Tempo:
App
↓
Tempo
Потом возникает необходимость одновременно отправлять данные в несколько систем:
App
↓
Jaeger
App
↓
Tempo
Затем появляется sampling, фильтрация, нормализация атрибутов и пакетная отправка. И внезапно оказывается, что во всех сервисах компании придётся переписывать конфигурацию SDK.
Именно поэтому OpenTelemetry Collector сегодня считается обязательным компонентом взрослой инфраструктуры наблюдаемости.
Почему OpenTelemetry Collector стал обязательным элементом
Collector выполняет роль своеобразного маршрутизатора телеметрии. Он принимает данные от приложений, обрабатывает их и уже затем отправляет в конечные системы хранения.
С точки зрения архитектуры это выглядит примерно так:
FastAPI
↓
OTLP
Go Service
↓
OTLP
Java Service
↓
OTLP
Node.js Service
↓
OTLP
OpenTelemetry Collector
↓
Tempo
↓
Jaeger
↓
Prometheus
У такого подхода появляется сразу несколько преимуществ.
Во-первых, приложения перестают зависеть от конкретного backend. Для них существует только OTLP - стандартный протокол OpenTelemetry.
Во-вторых, появляется возможность централизованно управлять обработкой телеметрии.
Например:
-
добавлять общие атрибуты;
-
фильтровать данные;
-
выполнять sampling;
-
дублировать экспорт;
-
ограничивать нагрузку;
-
применять пакетную обработку.
В-третьих, миграции становятся практически незаметными для разработчиков.
Не нужно менять код десятков сервисов. Достаточно изменить конфигурацию Collector.
Именно поэтому рекомендация "всегда используйте Collector" не является проявлением инженерного снобизма. Это результат большого количества болезненного опыта.
Jaeger - дед, который до сих пор тащит
Если попросить инженеров назвать первую систему распределённого трассирования, которую они использовали в своей жизни, очень многие вспомнят именно Jaeger.
Его любят по вполне понятным причинам. Он появился достаточно давно, обладает отличной документацией и позволяет получить работающую систему буквально за несколько минут. Более того, интерфейс Jaeger остаётся одним из самых удобных способов изучения трейсов.
Поднять лабораторную среду можно при помощи обычного Docker Compose:
services:
jaeger:
image: jaegertracing/all-in-one:latest
environment:
COLLECTOR_OTLP_ENABLED: "true"
ports:
- "16686:16686"
- "4317:4317"
- "4318:4318"
Запуск выглядит привычно:
docker compose up -d
После этого пользовательский интерфейс становится доступен по адресу:
http://localhost:16686
И именно здесь происходит маленькое профессиональное откровение.
Первый раз открывая Jaeger UI, многие инженеры испытывают примерно одинаковые эмоции. Вчера они пытались понять работу распределённой системы по разрозненным строкам журналов. Сегодня они видят дерево вызовов, длительность каждого этапа, ошибки и зависимости между сервисами.
И первая мысль обычно звучит примерно так:
Так вот что всё это время происходило внутри системы.
Tempo - tracing глазами Grafana
Если Jaeger напоминает надёжный старый пикап, который можно починить практически в любом гараже, то Tempo больше похож на современный грузовик, спроектированный для длительных перевозок.
Tempo появился значительно позже и строился уже с учётом новых реалий эксплуатации.
Его основные идеи заключаются в следующем:
-
максимально дешёвое хранение трейсов;
-
минимизация объёма индексов;
-
тесная интеграция с Grafana;
-
хорошая работа в Kubernetes;
-
масштабирование под огромные объёмы телеметрии.
Лабораторная установка может выглядеть следующим образом:
services:
tempo:
image: grafana/tempo:latest
command:
- "-config.file=/etc/tempo.yaml"
volumes:
- ./tempo.yaml:/etc/tempo.yaml
ports:
- "3200:3200"
- "4317:4317"
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
Минимальная конфигурация Tempo:
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
storage:
trace:
backend: local
local:
path: /tmp/tempo/traces
compactor:
compaction:
block_retention: 24h
После запуска:
docker compose up -d
можно подключить Tempo как источник данных в Grafana и начать работать с трейсами.
Именно здесь Tempo начинает показывать своё главное преимущество. Вместо отдельного мира для метрик, отдельного мира для логов и ещё одного отдельного мира для трейсов появляется единое пространство наблюдаемости.
- Можно открыть график задержек в Grafana.
- Перейти из метрики в конкретный trace.
- Из trace перейти к соответствующим логам Loki.
- Открыть span.
- Увидеть SQL-запрос.
- Посмотреть текст ошибки.
И в этот момент возникает очень странное ощущение. Кажется, будто ты впервые действительно управляешь системой, а не занимаешься гаданием на кофейной гуще по отдельным фрагментам информации.
До этого момента вся история выглядела достаточно красиво. Мы разобрались, зачем вообще нужны трейсы, увидели преимущества OpenTelemetry, познакомились с Jaeger и Tempo и даже построили архитектуру уровня "взрослой" наблюдаемости. Именно здесь большинство статей обычно заканчивается фразой: "Добавьте несколько библиотек, и всё заработает". После чего читатель идёт внедрять tracing в реальный проект и через несколько часов начинает задавать вопросы, которые редко встречаются в рекламных презентациях.
Почему трейсы не появляются?
Почему часть запросов видна, а часть исчезает?
Почему один сервис показывает красивые цепочки вызовов, а второй выглядит так, будто существует в отдельной вселенной?
Почему в Kubernetes всё работало вчера, а сегодня половина span'ов внезапно потерялась?
Именно на этом этапе начинается настоящая эксплуатация распределённого трассирования.
Первое знакомство OpenTelemetry и Python
Python в последние годы стал одним из самых популярных языков для внутренних сервисов, API и машинного обучения. Особенно часто OpenTelemetry встречается в проектах на FastAPI. И это неудивительно. FastAPI хорошо интегрируется с асинхронным кодом, активно используется в микросервисной архитектуре и практически автоматически становится кандидатом на внедрение distributed tracing.
Установка необходимых библиотек выглядит следующим образом:
pip install \
opentelemetry-api \
opentelemetry-sdk \
opentelemetry-exporter-otlp \
opentelemetry-instrumentation-fastapi \
opentelemetry-instrumentation-requests \
fastapi \
uvicorn
После этого возникает естественное желание написать что-нибудь подобное:
from fastapi import FastAPI
from opentelemetry import trace
app = FastAPI()
tracer = trace.get_tracer(__name__)
@app.get("/")
async def root():
with tracer.start_as_current_span(
"custom-operation"
):
return {
"status": "ok"
}
Код выглядит красиво. Используется span. Запросы успешно обрабатываются.
Но трейсов не будет.
Вообще.
Именно здесь многие впервые сталкиваются с одной из главных особенностей OpenTelemetry. Создание span'ов ещё не означает экспорт данных. Без экспортёра вся телеметрия остаётся внутри процесса приложения.
Иными словами, вы честно ведёте журнал наблюдений, а затем забываете его кому-либо показать.
Настраиваем OTLP Exporter
Полноценная конфигурация уже выглядит немного серьёзнее.
from fastapi import FastAPI
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
)
app = FastAPI()
provider = TracerProvider()
processor = BatchSpanProcessor(
OTLPSpanExporter(
endpoint="localhost:4317",
insecure=True
)
)
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
FastAPIInstrumentor.instrument_app(app)
@app.get("/")
async def root():
with tracer.start_as_current_span(
"generate-response"
):
return {
"message": "hello"
}
Теперь происходит уже полноценная магия.
Разберём компоненты подробнее.
TracerProvider является центральной сущностью SDK. Именно он управляет созданием span'ов и общей конфигурацией трассирования.
OTLPSpanExporter отвечает за отправку данных наружу. Причём ему совершенно безразлично, кто находится на другой стороне соединения:
-
OpenTelemetry Collector;
-
Jaeger;
-
Tempo;
-
облачный сервис наблюдаемости.
Если принимающая сторона понимает OTLP, всё будет работать.
BatchSpanProcessor заслуживает отдельного разговора. Новички очень любят заменять его на немедленную отправку каждого span.
Технически это возможно.
Практически это ужасная идея.
Представьте приложение, которое обрабатывает тысячу запросов в секунду. Если каждый span будет немедленно уходить по сети, приложение превратится в своеобразный обогреватель серверной комнаты.
BatchSpanProcessor работает значительно разумнее:
Span
↓
Span
↓
Span
↓
Batch
↓
Export
В результате уменьшается количество сетевых операций, снижается нагрузка на CPU и повышается общая производительность системы.
Автоматическое инструментирование HTTP
Одним из самых приятных открытий OpenTelemetry становится автоматическое инструментирование популярных библиотек.
Например, сервис вызывает внешний HTTP API:
import requests
response = requests.get(
"https://httpbin.org/get"
)
Без инструментирования это обычный вызов.
С инструментированием:
pip install \
opentelemetry-instrumentation-requests
достаточно нескольких строк:
from opentelemetry.instrumentation.requests import (
RequestsInstrumentor
)
RequestsInstrumentor().instrument()
И всё.
Теперь каждый HTTP-запрос автоматически появляется внутри trace.
Когда впервые видишь это в интерфейсе Jaeger или Grafana, возникает ощущение, будто пересел с велосипеда на космический корабль.
Вместо:
Calling external API...
Done.
можно увидеть:
HTTP GET https://httpbin.org/get
Duration: 127 ms
Status: 200
Именно в этот момент начинаешь понимать, сколько времени на самом деле тратится на внешние интеграции.
Очень часто оказывается, что приложение тормозит вовсе не из-за PostgreSQL или Kubernetes.
Оно просто ждёт ответа от "маленького" внешнего API, который почему-то отвечает две секунды.
Трассирование PostgreSQL и болезненные открытия
Следующий этап внедрения обычно вызывает у разработчиков смешанные чувства.
С одной стороны, появляется невероятная прозрачность.
С другой стороны, некоторые архитектурные решения внезапно становятся видны всем.
Например, работа с asyncpg.
Устанавливаем инструментирование:
pip install \
opentelemetry-instrumentation-asyncpg
Подключаем его:
import asyncpg
from opentelemetry.instrumentation.asyncpg import (
AsyncPGInstrumentor
)
AsyncPGInstrumentor().instrument()
Используем базу данных привычным образом:
conn = await asyncpg.connect(
user="postgres",
password="postgres",
database="app",
host="localhost"
)
rows = await conn.fetch(
"""
SELECT *
FROM users
WHERE id = $1
""",
42
)
Теперь SQL-запросы становятся частью общего trace.
И именно здесь происходят удивительные открытия.
Например:
GET /users/42
Redis GET 2 ms
SELECT users 15 ms
SELECT roles 17 ms
SELECT permissions 18 ms
SELECT settings 19 ms
SELECT profile 8 ms
...
ещё 327 SQL-запросов
Именно так многие команды впервые обнаруживают классическую проблему N+1.
До появления трейсов подобные вещи могли жить в продакшене месяцами. После появления трейсов они становятся видны буквально за несколько минут. И иногда это самый болезненный, но одновременно самый полезный разговор между эксплуатацией и разработкой.
После PostgreSQL большинство команд довольно быстро добирается до следующего кандидата на инструментирование - Redis. И именно здесь distributed tracing окончательно перестаёт быть красивой демонстрацией возможностей и превращается в инструмент, способный менять архитектурные решения. Потому что кэширование, которое в презентациях выглядит как волшебная кнопка "ускорить всё", в реальной жизни часто оказывается источником весьма неожиданных открытий.
Практически в каждой компании существует момент, когда кто-то произносит фразу:
"Давайте положим это в Redis. Будет быстрее."
Через некоторое время Redis начинает использоваться буквально везде. Там оказываются сессии пользователей, временные данные, результаты вычислений, блокировки, очереди и ещё десяток сущностей, которые "пока просто удобно хранить". И только после появления трейсов выясняется, насколько часто приложение на самом деле обращается к кэшу.
Инструментирование Redis выглядит удивительно просто.
Устанавливаем библиотеку:
pip install \
opentelemetry-instrumentation-redis
Подключаем её:
import redis
from opentelemetry.instrumentation.redis import (
RedisInstrumentor
)
RedisInstrumentor().instrument()
Используем Redis как обычно:
client = redis.Redis(
host="localhost",
port=6379,
decode_responses=True
)
user = client.get("user:42")
И всё.
Каждый вызов автоматически попадает в trace.
Например:
GET /api/users/42
├─ Redis GET user:42 2 ms
├─ Redis GET roles:42 1 ms
├─ Redis GET profile:42 2 ms
└─ PostgreSQL 18 ms
Казалось бы, прекрасная картина.
Но затем появляются другие трейсы:
GET /dashboard
├─ Redis GET widget:1 2 ms
├─ Redis GET widget:2 3 ms
├─ Redis GET widget:3 2 ms
├─ Redis GET widget:4 2 ms
├─ Redis GET widget:5 3 ms
...
└─ Redis GET widget:187 4 ms
Именно здесь многие впервые обнаруживают, что можно устроить проблему N+1 не только в PostgreSQL, но и в Redis. Более того, кэш способен стать таким же узким местом, как и база данных, если обращаться к нему неэффективно.
Ручные span'ы - настоящий суперсила инженера
Автоматическое инструментирование великолепно. Оно покрывает HTTP, базы данных, кэширование и огромное количество популярных библиотек. Но именно ручные span'ы превращают distributed tracing из красивой картинки в инструмент понимания бизнес-процессов.
Представим генерацию сложного отчёта.
Для инфраструктуры это просто HTTP-запрос.
Для бизнеса - критически важная операция.
Без ручных span'ов trace может выглядеть так:
POST /reports/generate
├─ PostgreSQL
├─ Redis
└─ HTTP Request
Формально всё правильно.
Но совершенно непонятно, чем именно занималось приложение.
Ручное создание span'ов позволяет добавить контекст:
with tracer.start_as_current_span(
"generate-report"
) as span:
span.set_attribute(
"report.id",
123
)
span.set_attribute(
"user.id",
999
)
process_report()
Теперь trace выглядит иначе:
POST /reports/generate
└─ generate-report
├─ report.id=123
├─ user.id=999
├─ PostgreSQL
├─ Redis
└─ Kafka Publish
Именно здесь происходит удивительная трансформация. Инженеры перестают наблюдать только технические операции и начинают видеть реальные бизнес-сценарии.
Можно ответить на вопросы:
-
какие отчёты генерируются дольше всего;
-
какие пользователи создают максимальную нагрузку;
-
какие операции чаще завершаются ошибками;
-
какие процессы требуют оптимизации.
Главное правило при этом предельно простое: атрибуты должны помогать расследованиям.
Именно поэтому категорически запрещается добавлять чувствительные данные.
Никогда не помещайте в span'ы:
password
jwt
api_key
credit_card
access_token
refresh_token
Иначе отдел информационной безопасности познакомится с вашей командой значительно ближе, чем хотелось бы.
Trace ID в логах - то, без чего нельзя жить
Существует одна практика, которая способна многократно повысить ценность всей системы наблюдаемости.
Trace ID должен присутствовать в логах.
Без исключений.
Именно он становится мостом между журналами событий и распределённым трассированием.
Получить текущий идентификатор можно следующим образом:
from opentelemetry.trace import (
get_current_span
)
span = get_current_span()
trace_id = format(
span.get_span_context().trace_id,
"032x"
)
Предположим, приложение пишет структурированные журналы:
logger.info(
"User profile generated",
extra={
"trace_id": trace_id,
"user_id": 42
}
)
Тогда запись может выглядеть так:
{
"message": "User profile generated",
"trace_id": "f64eaf2fbc3c0d09f17d6e1b2c7d8a15",
"user_id": 42
}
А дальше происходит магия.
Пользователь сообщает:
У меня возникла ошибка в 14:32.
- Инженер открывает Loki.
- Находит запись журнала.
- Копирует trace_id.
- Переходит в Grafana или Jaeger.
- И видит полную историю запроса.
Больше не нужно писать в Slack:
"Скиньте, пожалуйста, кусок логов за 14:32."
- Не нужно сопоставлять временные метки вручную.
- Не нужно угадывать, относится ли ошибка к конкретному пользователю.
Именно этот момент многие считают настоящим рождением observability.
Потому что только тогда три источника данных начинают работать как единое целое:
Metrics
↓
Traces
↓
Logs
или наоборот:
Logs
↓
Trace ID
↓
Trace
↓
Metrics
И внезапно наблюдаемость перестаёт быть набором разрозненных инструментов.
Она становится системой навигации внутри собственной инфраструктуры.
OpenTelemetry Collector: взрослая эксплуатация
До этого момента можно было отправлять данные напрямую в Jaeger или Tempo. И для локальной лаборатории такой подход действительно работает.
Но продакшен очень быстро заставляет пересмотреть взгляды на архитектуру.
- Сначала появляется необходимость переключиться с Jaeger на Tempo.
- Потом возникает желание дублировать трейсы.
- Затем выясняется, что необходимо выполнять sampling.
- Потом приходит безопасность и просит удалять некоторые атрибуты.
- А затем появляется требование отправлять телеметрию сразу в несколько систем.
Если приложения взаимодействуют напрямую с backend, инфраструктура превращается в клубок жёстких зависимостей.
Именно поэтому OpenTelemetry Collector постепенно становится обязательным компонентом практически любой серьёзной платформы наблюдаемости. Он позволяет отделить приложения от конечных систем хранения и превратить телеметрию в полноценный поток данных, которым можно централизованно управлять. И, как показывает практика, чем больше становится инфраструктура, тем сильнее начинаешь ценить это решение.
До этого момента может сложиться впечатление, что после внедрения OpenTelemetry жизнь инженера становится удивительно простой. Трейсы появляются автоматически, Grafana начинает показывать внутренности системы, Jaeger рисует красивые диаграммы, а разработчики внезапно начинают оптимизировать SQL-запросы ещё до того, как их найдут пользователи. К сожалению, как и любая зрелая технология, distributed tracing приносит не только новые возможности, но и совершенно новые проблемы. И если первые недели после внедрения обычно проходят под лозунгом "почему мы не сделали этого раньше?", то через несколько месяцев появляется другой вопрос: "почему у нас генерируется семьсот тысяч span'ов в секунду и кто теперь будет оплачивать это счастье?"
Именно здесь разговор неизбежно переходит к одной из самых болезненных тем современной наблюдаемости - стоимости хранения телеметрии. Метрики относительно компактны. Логи могут занимать значительные объёмы, но большинство команд давно привыкло к необходимости их хранения. Трейсы же обладают крайне неприятной особенностью. Они растут вместе с усложнением архитектуры. Один пользовательский запрос может породить десятки span'ов. В более сложных системах речь уже идёт о сотнях. Если приложение обрабатывает несколько тысяч запросов в секунду, объёмы телеметрии начинают измеряться миллионами событий.
Представим относительно обычную систему электронной коммерции. Пользователь открывает страницу товара. С точки зрения бизнеса это одно действие. С точки зрения инфраструктуры картина выглядит иначе:
GET /products/42
↓
API Gateway
↓
Auth Middleware
↓
Redis Session Lookup
↓
Product Service
↓
PostgreSQL
↓
Inventory Service
↓
Redis
↓
Recommendation Service
↓
Machine Learning API
↓
Kafka Publish
↓
Audit Logging
Даже если каждый этап создаёт всего один span, получается десять-двенадцать событий на один запрос. При нагрузке в тысячу запросов в секунду речь уже идёт о десятках тысяч span'ов каждую секунду. За сутки это превращается в сотни миллионов записей. Именно поэтому практически все зрелые системы observability используют sampling.
Sampling - это механизм выборочного сохранения трейсов. И здесь очень важно избавиться от распространённого заблуждения. Многие инженеры воспринимают его как вынужденное зло или компромисс, вызванный нехваткой ресурсов. На самом деле sampling является нормальной инженерной практикой. В подавляющем большинстве случаев совершенно не требуется хранить сто процентов телеметрии.
Настройка базового sampling в OpenTelemetry выглядит следующим образом:
from opentelemetry.sdk.trace import (
TracerProvider
)
from opentelemetry.sdk.trace.sampling import (
TraceIdRatioBased
)
provider = TracerProvider(
sampler=TraceIdRatioBased(0.1)
)
В данном случае значение 0.1 означает сохранение примерно десяти процентов трейсов.
1.0 → 100%
0.5 → 50%
0.1 → 10%
0.01 → 1%
На первый взгляд идея может показаться пугающей. Кажется, будто вместе с частью трейсов неизбежно потеряется важная информация. Однако реальная эксплуатация показывает обратное. Если система генерирует миллионы успешных однотипных запросов, хранить их все не имеет большого смысла. Намного ценнее сохранить репрезентативную выборку и уделить особое внимание проблемным случаям.
Именно поэтому постепенно появились более интеллектуальные подходы к выборке данных. Например, можно сохранять абсолютно все ошибки, но применять sampling только к успешным операциям. Такой подход позволяет одновременно контролировать объёмы хранения и не терять информацию о действительно важных инцидентах.
Например, в Collector можно реализовать подобную стратегию централизованно, не изменяя код приложений. И именно здесь снова проявляется преимущество архитектуры с OpenTelemetry Collector. Он позволяет управлять потоками телеметрии как полноценным инженерным ресурсом.
Ещё одной проблемой, с которой сталкиваются практически все команды, становится broken tracing - "сломанные" трейсы. Это тот самый момент, когда красивое дерево вызовов внезапно превращается в набор отдельных фрагментов, между которыми отсутствуют связи. Обычно подобные ситуации вызывают искреннее недоумение. Ведь приложение продолжает работать. Запросы успешно обрабатываются. Но часть span'ов внезапно исчезает в небытие.
Причины оказываются удивительно приземлёнными. В большинстве случаев проблема связана вовсе не с OpenTelemetry.
Очень часто виновниками становятся:
-
HTTP-клиенты без инструментирования;
-
самописные библиотеки;
-
потерянные заголовки при проксировании;
-
асинхронные задачи;
-
очереди сообщений;
-
сервисные сети;
-
неправильно настроенные ingress-контроллеры.
Представим простой сценарий:
Frontend
↓
API Service
↓
Billing Service
↓
Kafka
↓
Worker
Если propagation настроен корректно, вся цепочка выглядит как единый trace. Однако достаточно потерять контекст на этапе публикации сообщения в Kafka, и история запроса разделяется на две независимые части.
Получается примерно следующее:
Trace A
Frontend
↓
API Service
↓
Billing Service
И отдельно:
Trace B
Worker
↓
Email Sender
Формально всё работает. Письма отправляются. Пользователи получают уведомления. Но возможность восстановить полную историю запроса оказывается утраченной. Именно поэтому propagation превращается в одну из важнейших тем при эксплуатации distributed tracing.
Ситуация становится ещё интереснее в Kubernetes. На конференциях любят показывать красивые схемы облачных платформ, где сервисы обмениваются данными с безупречной точностью. Реальность выглядит значительно веселее. Здесь появляются ingress-контроллеры, sidecar-контейнеры, сервисные сети, политики безопасности, повторные попытки доставки запросов и автоматическое масштабирование.
Типичная схема может выглядеть так:
Internet
↓
Ingress NGINX
↓
Service
↓
Pod
↓
Service Mesh
↓
Another Pod
↓
Kafka
↓
Background Worker
Именно в подобных системах начинаются самые загадочные расследования. Один trace внезапно заканчивается внутри ingress-контроллера. Другой теряет часть span'ов после повторной попытки доставки запроса. Третий распадается после масштабирования Deployment. Четвёртый почему-то перестаёт работать после обновления Istio.
Поэтому существует несколько рекомендаций, которые многие команды вырабатывают исключительно на собственном болезненном опыте.
Во-первых, OpenTelemetry Collector стоит воспринимать как обязательный элемент платформы, а не дополнительную опцию для энтузиастов. Во-вторых, propagation необходимо проверять так же внимательно, как проверяются резервные копии или правила межсетевого экрана. В-третьих, трассирование должно становиться частью инженерной культуры, а не оставаться игрушкой нескольких заинтересованных специалистов.
Особенно важно понимать, что observability - это не продукт и не набор инструментов. Это способность задавать инфраструктуре вопросы и получать на них ответы. Сколько времени выполняется запрос? Где возникла задержка? Какая зависимость стала узким местом? Почему пользователь столкнулся с ошибкой? Если система не позволяет отвечать на подобные вопросы, значит наблюдаемость существует только на уровне красивых дашбордов.
И именно здесь многие инженеры замечают любопытную закономерность. Их собственный путь развития практически всегда выглядит одинаково. Сначала есть только журналы событий и знаменитый tail -f. Затем появляется grep ERROR. Потом приходят Prometheus и Grafana. Через некоторое время возникают трейсы, Loki и OpenTelemetry. А спустя ещё несколько месяцев команда уже обсуждает стратегии sampling, стоимость хранения телеметрии и производительность Collector.
Самое удивительное заключается в том, что обратной дороги практически не существует. После того как однажды удаётся открыть trace конкретного пользовательского запроса, увидеть все SQL-запросы, HTTP-вызовы, обращения к Redis, публикации в Kafka и точно определить источник проблемы, возвращаться к диагностике исключительно по логам становится физически больно. Это примерно как пытаться ориентироваться по бумажной карте после нескольких лет использования современной навигации. Теоретически возможно. Практически - мучительно неудобно.
Именно поэтому distributed tracing давно перестал быть модной технологией крупнейших компаний. Сегодня это инструмент, который экономит часы жизни инженеров, ускоряет расследование инцидентов, помогает находить узкие места ещё до появления массовых жалоб пользователей и делает микросервисную архитектуру значительно менее хаотичной. А OpenTelemetry, Jaeger и Tempo оказались не очередным витком технологической моды, а закономерным ответом индустрии на усложнение систем, которые мы сами же и построили.
Потому что однажды увидев матрицу собственной инфраструктуры целиком, развидеть её уже невозможно.