Bollywood HungamaEXCLUSIVE: Samarth Jurel to make special appearance in Emraan Hashmi-starrer Gunmaaster G9The Jerusalem PostHackers hit Ukraine, Russia's emergency apps, send fake nuclear, chemical attack alertsPunchAccord asks court to dismiss suit seeking candidates’ disqualificationInquirerOver 9,000 passengers monitored in S. Tagalog ports on Nov. 2 – PCGDaily MaverickEl Niño threatens SA’s crops, but ample maize stocks cushion the blowInquirer EntertainmentAshtine Olviga, David Licauco say they are not competing with other love teamsTechCrunch42AI comes out of stealth with $62.5M and a world model that can ‘predict human behavior’The Hollywood ReporterTokyo: Industry Insiders Skeptical of Bold Japanese Government Plans for Overseas Content Sales PushZDF heuteEntdecken Sie das ZDF-Nachrichtenstudio20 MinutenEndlich gibts gestochen scharfe Bilder des GespensterfischsVanguardHow I met my wife 22 years ago – Actor Odunlade Adekolaعالم التقنيةسامسونج تبدأ إنتاج شاشة الخصوصية لهاتف Galaxy S27 Pro تمهيدًا للإطلاق مطلع 2027
The Daily Newsstand · Free, Always
Monday, November 2, 2026

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

Translate

Всем привет, меня зовут Евгений, я ведущий 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-кластер берет эти проблемы на себя.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.