Как потерять трейсинг в микросервисах (и не заметить этого)


Всем привет, меня зовут Евгений, я ведущий Go-разработчик в Cloud.ru. За время работы над разными проектами в разных компаниях я замечал одну похожую историю: все собирают трейсы, но пользы от них мало. Их просто никто не смотрит, потому что они неполные. В них буквально не видно весь путь запроса от клиента до последнего микросервиса в цепочке интеграций разных продуктов и команд в компании. В итоге на них все забивают, ищут проблемы по логам, это входит в привычку, так дальше и живут.
В этой статье я хочу рассказать, где же теряются трейсы в микросервисах и как перестать жрать кактус.
Трассировка через синхронные запросы
Настроить трейсинг в обычном синхронном API сейчас уже ни для кого не проблема. В сети огромное количество статей и мануалов по этой теме, готовые библиотеки с middleware/interceptors для любого языка и протокола, да даже любая нейронка вам напишет готовый код специально под ваш проект.
Допустим, ваша команда пишет на Go, использует gRPC, у вас подключены интерсепторы, которые запускают процесс трассировки для каждого входящего запроса, context.Context плавно скользит по коду, бережно перенося информацию о спанах. И вот вам нужно интегрироваться с другим продуктом в компании, а там команда пишет на Python, и их API на обычном HTTP. На этом стыке разнородных технологий вы наверняка забудете, что клиент для HTTP-запросов не работает с контекстом, и ваш трейсинг оборвется.
У кого так было? У нас вот было. На стыке разнородных технологий, особенно в переходе с более привычной на менее привычную, нужно быть очень внимательным.
client := &http.Client{
Timeout: 10 * time.Second,
}
ctx, span := tracer.Start(ctx, "new-span") // <-- Span есть
defer span.End()
req, err := http.NewRequest(http.MethodGet, "https://api.example.com", nil) // <-- А в запросе его нет
if err != nil {
return err
}
// И в заголовки не добавили
resp, err := client.Do(req) // <-- И при вызове тоже нет
Исправляется проблема довольно просто:
client := &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport), // <-- Готовая обёртка над транспортом
Timeout: 10 * time.Second,
}
ctx, span := tracer.Start(ctx, "new-span")
defer span.End()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com", nil) // <-- Контекст с трейсом связывается с запросом
if err != nil {
return err
}
resp, err := client.Do(req) // <-- otelhttp сам допишет заголовки
Трассировка через брокеры сообщений
Этот пункт похож на предыдущий — тоже переход через технологию. Все популярные современные брокеры поддерживают передачу заголовков или метаданных вместе с сообщением: NATS, Kafka, RabbitMQ и другие. Но в отличие от синхронных API, здесь нет стандартного механизма middleware, чтобы работать с метаданными сообщений. Приходится обрабатывать их самостоятельно, причем как на стороне отправителя, так и на стороне получателя. Об этом очень легко забыть, упустить из виду, и на этом очень часто теряются трейсы.
Алгоритм решения таков.
Записать данные трейса в заголовки сообщения
На примере Go + Kafka:
type CustomProducerCarrier struct {
Headers *[]sarama.RecordHeader
}
// todo: В CustomProducerCarrier необходимо реализовать интерфейс
// propagation.TextMapCarrier (https://pkg.go.dev/go.opentelemetry.io/otel/propagation#TextMapCarrier)
ctx, span := tracer.Start(ctx, "kafka.produce", trace.WithSpanKind(trace.SpanKindProducer))
defer span.End()
msg := &sarama.ProducerMessage{
Topic: topic,
Value: sarama.ByteEncoder(value),
Headers: []sarama.RecordHeader{},
}
carrier := CustomProducerCarrier{Headers: &msg.Headers}
otel.GetTextMapPropagator().Inject(ctx, carrier)
_, _, err := producer.SendMessage(msg)
return err
Так данные отправятся получателю, но на его стороне тоже нужно кое-что сделать.
Восстановить спан отправителя из полученного сообщения
carrier := CustomProducerCarrier{Headers: (*[]sarama.RecordHeader)(&msg.Headers)}
remoteCtx := otel.GetTextMapPropagator().Extract(context.Background(), carrier) // <-- Тут уже будет информация о спане, созданном на стороне продюсера
ctx, span := tracer.Start(ctx, "kafka.consume",
trace.WithSpanKind(trace.SpanKindConsumer),
trace.WithLinks(trace.LinkFromContext(remoteCtx)),
) // <-- Тут уже новый спан консюмера, локальный
Здесь мы реконструировали родительский спан в продюсере и создали новый корневой спан в консюмере, связав его с родительским через SpanLink. Альтернативно можем создать прямой дочерний спан от внешнего:
remoteSpanContext := trace.NewSpanContext(trace.SpanContextConfig{
TraceID: traceID,
SpanID: spanID,
TraceFlags: trace.FlagsSampled, // Если внешний спан действительно был сэмплирован
Remote: true, // Обязательно при восстановлении внешнего спана
})
remoteCtx := trace.ContextWithSpanContext(context.Background(), remoteSpanContext) // <-- Тут уже будет информация о спане, созданном на стороне продюсера
ctx, span := tracer.Start(remoteCtx, "kafka.consume",
trace.WithSpanKind(trace.SpanKindConsumer),
) // <-- Тут уже новый спан консюмера, локальный
Подробнее о том, какой подход выбрать, поговорим в разделе Использовать SpanLink или нет. В любом случае вся цепочка взаимодействий будет сохранена, мы ничего не потеряем.
Трассировка через очередь задач
Брокеры сообщений в целом могут использоваться как очереди, но нередко в качестве очереди используется база данных, структура в памяти или что-то еще. Также в микросервисной архитектуре популярен паттерн Transactional inbox/outbox, когда данные сохраняются в базу, а обрабатываются или отправляются отдельным потоком после чтения из базы. Для таких вещей в принципе нет готовой библиотеки для внедрения трейсинга, а вся работа с ними находится в коде вашего приложения, и очень легко понадеяться на «магию контекста». Тут-то трейсы и теряются.
Решение простое: сохранить данные трейса вместе с полезными данными задачи, а при извлечении восстановить трейс.
type Task struct {
ID int64
Payload []byte
TraceCarrier map[string]string
}
/* Сохранение */
carrier := propagation.MapCarrier{}
otel.GetTextMapPropagator().Inject(ctx, carrier)
task := Task{
Payload: data,
TraceCarrier: carrier
}
err = saveTask(ctx, task)
/* Извлечение */
task, err := getTask(ctx, id)
if err != nil {
return err
}
parentCtx = otel.GetTextMapPropagator().Extract(ctx, propagation.MapCarrier(task.TraceCarrier))
taskCtx, span := tracer.Start(parentCtx, "task.process")
Так как у нас уже есть контекст с трейсом, мы можем использовать стандартный MapCarrier из библиотеки go.opentelemetry.io/otel/propagation в отличие от кастомного для Kafka. Новый спан создали напрямую для примера, но можно и через SpanLink, об этом будет далее.
Трассировка через оркестраторы
В современных микросервисных архитектурах находят применение различные оркестраторы задач, такие как Temporal, Restate, Conductor. Они позволяют описывать процессы в виде кода, отслеживать их выполнение, вмешиваться руками в процесс в случае необходимости, автоматически перезапускать в случае ошибок — в общем, разработчики могут сосредоточиться на бизнес-логике и не тратить время на инфраструктурный код. И конечно, потерять трейсы. Иначе мы бы сейчас об этом не говорили.
Далее разберем на примере Temporal, как же можно потерять трейсинг.
В Temporal критически важна детерминированность кода, поэтому там используется свой собственный workflow.Context вместо обычного, а также свои инструменты управления конкурентностью, синхронизацией и т.д. Использовать привычные инструменты для трейсинга здесь не получается, так что многие обычно забивают на это и просто стартуют новый трейс в Activity, где уже есть классический гошный контекст и можно писать код как обычно привыкли. Однако Temporal поддерживает механизм интерсепторов, подобно gRPC, и в его библиотеках есть реализация интерсептора для трейсинга.
package main
import (
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/contrib/opentelemetry"
"go.temporal.io/sdk/interceptor"
)
func main() {
/* ... */
// Создаём интерсептор для трейсинга
tracingInterceptor, err := opentelemetry.NewTracingInterceptor(
opentelemetry.TracerOptions{
Tracer: otel.Tracer("temporal-workflow"),
},
)
if err != nil {
log.Fatal(err)
}
// Подключаемся к серверу Temporal
c, err := client.Dial(client.Options{
HostPort: "localhost:7233",
Interceptors: []interceptor.ClientInterceptor{tracingInterceptor},
})
if err != nil {
log.Fatal(err)
}
defer c.Close()
// Создаём воркер
w := worker.New(c, "temporal-worker", worker.Options{
Interceptors: []interceptor.WorkerInterceptor{tracingInterceptor},
})
}
Теперь, когда мы будем запускать Workflow с помощью созданного клиента, данные трейсинга из контекста будут обрабатываться интерсептором, и он будет создавать новые спаны для каждого запускаемого Workflow и Activity. Внутри Activity можно создавать свои спаны для операций, если это необходимо.
Далее рассмотрим еще один кейс потери трейсинга. Он не связан с забытым инструментарием в коде, но часто встречается в крупных компаниях и достоен упоминания в статье.
Трассировка в разные места
Самый очевидный и самый непростой случай, особо актуальный для больших корпораций. Продукты и команды формируются в разное время, технологии меняются, а правило «работает — не трогай» еще никто не отменял. Одни пишут на Go, используют OpenTelemetry и собирают данные в Tempo. Другие пишут на Java и в Zipkin через B3-Propagation. И в какой-то момент развития продукта первой команды им требуется интегрироваться со второй. А может быть и с третьей, у которой бэкенд на Perl, а трассировка полностью самописная. При таком раскладе отследить цепочку запросов становится весьма непросто. Но это можно немного облегчить.
Trace correlation
Grafana с 12 версии поддерживает механизм Trace Correlation. Прямо в интерфейсе можно настроить переход от трейса к логам по trace_id, но также можно и сделать переход к другому источнику данных, например, если у команд разные инстансы Tempo, но общая Grafana. А еще есть возможность построить URL с подстановкой параметров, так можно добавить ссылку на чужую систему, на страницу поиска и сразу передать параметры. В Jaeger есть подобный механизм, в конфиг-файле можно определить linkPatterns. Не так гибко, как в Grafana, но хоть что-то. Возможно в вашем инструменте тоже есть что-то подобное.

Подстановка при сборе трейсов
Если нет возможности использовать корреляции прямо в вашем инструменте для просмотра трейсов, но вы используете OtelCollector для сбора, можно добавить трансформер, который сформирует ссылку и добавит в атрибуты.
processors:
transform:
trace_statements:
- context: span
statements:
- set(
attributes["trace.url.jaeger"],
Concat([
"https://jaeger.local/trace/",
span.trace_id.string
], "")
)
В крайнем случае можно сформировать ссылку прямо в коде приложения и добавить как атрибут в спан. Звучит как костыль, но при интеграции с legacy-системами без них все равно не обойтись.
Атрибуты для поиска
Если уже совсем никак не выходит добавить прямые ссылки в интерфейс, остается только добавлять атрибуты, которые помогут искать трейсы между системами. Например, вот так:
ctx, span := tracer.Start(ctx, "legacy.svc/Create", trace.WithAttributes(
attribute.String("destination", "leagcy-service-1"),
attribute.Int("task_id", task.ID),
))
Здесь добавлены два атрибута:
destinationпозволяет понять, в какую систему ушел запрос. Вы, как разработчик, и так наверняка помните, куда в этом месте идут запросы, но L2-инженер поддержки скорее всего не знает. Этот атрибут поможет понять, где смотреть продолжение.task_idкак уникальное бизнес-значимое значение позволит найти трейс в другой системе. Хорошо, если она поддерживает OpenTelemetry стандарт, и тогда бы вы могли продолжить поиск по уже знакомомуtrace_id, но если там другой стандарт или самописное решение, то нужно что-то общее для обеих систем.
Важно, чтобы поисковый атрибут имел уникальное значение для каждого запроса. Идентификатор задачи, операции, реквеста — главные кандидаты на выбор. Останется только договориться с командой на той стороне, чтобы они его записывали в свои трейсы.
Использовать SpanLink или нет
Прямая связь спанов удобна, когда они в коротком интервале. Когда разрывы между ними измеряются десятками минут или часами, тут уже сложнее. Ломается поиск, индексация, сэмплирование, даже интерфейс плывет. С другой стороны, линки не отображаются в одном экране, в итоге поиск проблемы по трейсам усложняется. В общем, как говорится...

В документации OpenTelemetry SpanLink предлагается как рекомендуемое решение для связи спанов в асинхронном взаимодействии. Но если у вас в нормальном режиме работы паузы между отправкой и получением (или постановкой в очередь и извлечением) невелики и консюмер не обрабатывает пачками события, например не заливает их в Clickhouse оптом, то вы можете попробовать связывать спаны напрямую без использования SpanLink.
Так вам не придется прыгать по ссылкам, держать множество вкладок, только чтобы найти проблемное место — все будет у вас на виду в одной странице. С другой стороны, когда цепочка взаимодействий слишком глубока и ваши спаны не влезают в экран, разделение даже синхронных запросов на разные трейсы с линком может улучшить опыт ваших коллег из техподдержки в поиске проблем. При этом вы также можете использовать Trace Correlation в Grafana или аналогичный механизм в других платформах для упрощения перехода между сегментами.

Вместо заключения
Хорошо выстроенный трейсинг — это отличный инструмент для поиска и решения проблем в современных приложениях. Чтобы разработчики и девопсы могли сосредоточиться на таких задачах, как построение трейсинга, а не тратить время на дебаг сетевых проблем в кластере, мы в Cloud.ru разрабатываем Managed Kubernetes. Готовый облачный managed-кластер берет эти проблемы на себя.
Расскажите, сталкивались ли вы в работе с разорванными или потерянными трейсами? Или может у вас есть опыт построения трейсинга для сложного взаимодействия сервисов?
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.