golang

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

  • вторник, 3 ноября 2026 г. в 00:00:10
https://habr.com/ru/companies/cloud_ru/articles/1102572/

Всем привет, меня зовут Евгений, я ведущий 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, но хоть что-то. Возможно в вашем инструменте тоже есть что-то подобное.

Настройка trace correlation в Grafana с типом external
Настройка trace correlation в Grafana с типом external

Подстановка при сборе трейсов

Если нет возможности использовать корреляции прямо в вашем инструменте для просмотра трейсов, но вы используете 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 или аналогичный механизм в других платформах для упрощения перехода между сегментами.

Child Spans это настроенный Trace Correlation для поиска спанов, родителем которых является отображаемый спан. В свою очередь в дочерних спанах в References отображается ссылка на родителей
Child Spans это настроенный Trace Correlation для поиска спанов, родителем которых является отображаемый спан. В свою очередь в дочерних спанах в References отображается ссылка на родителей

Вместо заключения

Хорошо выстроенный трейсинг — это отличный инструмент для поиска и решения проблем в современных приложениях. Чтобы разработчики и девопсы могли сосредоточиться на таких задачах, как построение трейсинга, а не тратить время на дебаг сетевых проблем в кластере, мы в Cloud.ru разрабатываем Managed Kubernetes. Готовый облачный managed-кластер берет эти проблемы на себя.

Расскажите, сталкивались ли вы в работе с разорванными или потерянными трейсами? Или может у вас есть опыт построения трейсинга для сложного взаимодействия сервисов?