Distributed Tracing в микросервисах: сквозная трассировка запросов с OpenTelemetry, Jaeger и Go
Введение: зачем нужен distributed tracing в 2026 году
Когда монолит распадается на десятки микросервисов, отладка становится нетривиальной задачей. Запрос пользователя проходит через API Gateway, несколько бизнес-сервисов, очередь сообщений, Redis-кэш и PostgreSQL — и где-то в этой цепочке появляется латентность в 800 мс, о которой сообщают клиенты. Найти узкое место без инструментов observability практически невозможно.
Distributed tracing — это механизм сквозной трассировки запросов через все компоненты распределённой системы. Каждый запрос получает уникальный trace_id, каждая операция внутри сервиса — span с временными метками, атрибутами и связями. В итоге вы видите полную картину: где потрачено время, какие сервисы вызывались, где возникли ошибки.
В 2026 году стандартом де-факто стал OpenTelemetry — vendor-neutral проект CNCF, объединивший OpenTracing и OpenCensus. Вместе с Jaeger как backend для хранения и визуализации трейсов и Go как языком реализации микросервисов это даёт production-ready стек observability.
Обзор OpenTelemetry: стандарт, SDK и экспортёры
OpenTelemetry — это набор API, SDK и инструментов для сбора телеметрии: трейсов, метрик и логов. Архитектура состоит из нескольких ключевых компонентов:
- API — интерфейсы, не зависящие от конкретной реализации. Библиотеки инструментируются через API, не привязываясь к SDK.
- SDK — реализация API с батчингом, сэмплингом и обработкой данных.
- Exporters — компоненты, отправляющие данные в backend: Jaeger, Zipkin, OTLP, Prometheus.
- Collector — опциональный агент/прокси, принимающий данные от приложений и маршрутизирующий их в один или несколько backend.
- Instrumentation Libraries — готовые интеграции для net/http, gRPC, database/sql, Redis и других.
Для Go основной пакет — go.opentelemetry.io/otel. SDK трейсинга — go.opentelemetry.io/otel/sdk/trace. Экспортёр в Jaeger через OTLP gRPC — go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc.
Инструментирование Go-сервисов с otel-go
Инициализация провайдера трейсов
Первым делом нужно настроить TracerProvider — центральный объект, управляющий жизненным циклом трейсов. Обычно это делается при старте приложения:
package telemetry
import (
"context"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func InitTracer(ctx context.Context, serviceName, collectorAddr string) (func(), error) {
conn, err := grpc.DialContext(ctx, collectorAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
if err != nil {
return nil, err
}
exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
if err != nil {
return nil, err
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(serviceName),
semconv.ServiceVersion("1.0.0"),
semconv.DeploymentEnvironment("production"),
),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter,
sdktrace.WithBatchTimeout(5*time.Second),
sdktrace.WithMaxExportBatchSize(512),
),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.ParentBased(
sdktrace.TraceIDRatioBased(0.1), // 10% сэмплинг в prod
)),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
shutdown := func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = tp.Shutdown(ctx)
}
return shutdown, nil
}
Создание спанов и передача контекста
Ключевой принцип — контекст Go (context.Context) является носителем информации о текущем трейсе. Передавайте его через все слои приложения:
package service
import (
"context"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
var tracer = otel.Tracer("order-service")
type OrderService struct {
repo OrderRepository
cache CacheClient
}
func (s *OrderService) GetOrder(ctx context.Context, orderID string) (*Order, error) {
ctx, span := tracer.Start(ctx, "OrderService.GetOrder")
defer span.End()
span.SetAttributes(
attribute.String("order.id", orderID),
attribute.String("component", "order-service"),
)
// Проверяем кэш
order, err := s.cache.Get(ctx, "order:"+orderID)
if err == nil {
span.SetAttributes(attribute.Bool("cache.hit", true))
return order, nil
}
span.SetAttributes(attribute.Bool("cache.hit", false))
// Идём в БД
order, err = s.repo.FindByID(ctx, orderID)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, fmt.Errorf("repo.FindByID: %w", err)
}
return order, nil
}
Настройка Jaeger как backend для сбора трейсов
Jaeger — open-source система трассировки от Uber, принятая CNCF. В development-режиме удобно использовать all-in-one образ. Вот конфигурация docker-compose.yml:
version: "3.9"
services:
jaeger:
image: jaegertracing/all-in-one:1.55
environment:
- COLLECTOR_OTLP_ENABLED=true
- SPAN_STORAGE_TYPE=badger
- BADGER_EPHEMERAL=false
- BADGER_DIRECTORY_VALUE=/badger/data
- BADGER_DIRECTORY_KEY=/badger/key
volumes:
- jaeger-data:/badger
ports:
- "16686:16686" # UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "14268:14268" # Jaeger HTTP collector
restart: unless-stopped
otel-collector:
image: otel/opentelemetry-collector-contrib:0.95.0
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "4319:4317" # OTLP gRPC от приложений
depends_on:
- jaeger
volumes:
jaeger-data:
Конфигурация OpenTelemetry Collector (otel-collector-config.yaml):
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 1s
send_batch_size: 1024
memory_limiter:
limit_mib: 512
spike_limit_mib: 128
check_interval: 5s
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
logging:
loglevel: warn
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/jaeger, logging]
Для production рекомендуется использовать Jaeger с Elasticsearch или Cassandra как backend хранилища вместо badger. Настройте SPAN_STORAGE_TYPE=elasticsearch и укажите адрес кластера.
Интеграция трассировки с REST API и gRPC эндпоинтами
Middleware для REST API (net/http)
Для HTTP-серверов используется библиотека go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp:
package middleware
import (
"net/http"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func NewTracingMiddleware(serviceName string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return otelhttp.NewHandler(next, serviceName,
otelhttp.WithMessageEvents(
otelhttp.ReadEvents,
otelhttp.WriteEvents,
),
)
}
}
// HTTP-клиент с трассировкой
func NewTracedHTTPClient() *http.Client {
return &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
}
}
Интерсепторы для gRPC
Для gRPC-сервисов применяем interceptors из пакета go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc:
package server
import (
"google.golang.org/grpc"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
)
func NewGRPCServer() *grpc.Server {
return grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler(
otelgrpc.WithMessageEvents(
otelgrpc.ReceivedEvents,
otelgrpc.SentEvents,
),
)),
)
}
func NewGRPCClientConn(target string) (*grpc.ClientConn, error) {
return grpc.Dial(target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
}
Корреляция трейсов с логами и метриками
Максимальная ценность observability достигается, когда трейсы, логи и метрики связаны между собой. Ключ — вставка trace_id и span_id в каждую лог-запись:
package logger
import (
"context"
"log/slog"
"os"
"go.opentelemetry.io/otel/trace"
)
type TraceHandler struct {
handler slog.Handler
}
func (h *TraceHandler) Handle(ctx context.Context, r slog.Record) error {
span := trace.SpanFromContext(ctx)
if span.IsRecording() {
sc := span.SpanContext()
r.AddAttrs(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
slog.String("trace_flags", sc.TraceFlags().String()),
)
}
return h.handler.Handle(ctx, r)
}
func NewLogger() *slog.Logger {
base := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
return slog.New(&TraceHandler{handler: base})
}
При таком подходе вы можете в Grafana или Kibana по trace_id из Jaeger мгновенно найти все логи, относящиеся к конкретному запросу. Для метрик используйте go.opentelemetry.io/otel/metric — добавляйте атрибуты service.name и environment к каждой метрике, чтобы корреляция работала через exemplars в Prometheus.
Практический пример: трассировка цепочки из трёх Go-сервисов
Рассмотрим систему из трёх сервисов: API Gateway, Order Service и Inventory Service. Запрос создания заказа проходит через всю цепочку, затрагивая Redis (кэш) и PostgreSQL (хранилище).
Инструментирование PostgreSQL через database/sql
package db
import (
"context"
"database/sql"
"github.com/XSAM/otelsql"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
_ "github.com/lib/pq"
)
func NewPostgresDB(dsn string) (*sql.DB, error) {
db, err := otelsql.Open("postgres", dsn,
otelsql.WithAttributes(
semconv.DBSystemPostgreSQL,
),
otelsql.WithSpanOptions(otelsql.SpanOptions{
Ping: true,
RowsAffected: true,
DisableErrSkip: true,
}),
)
if err != nil {
return nil, err
}
if err := otelsql.RegisterDBStatsMetrics(db,
otelsql.WithAttributes(semconv.DBSystemPostgreSQL),
); err != nil {
return nil, err
}
return db, nil
}
Инструментирование Redis
package cache
import (
"context"
"github.com/redis/go-redis/extra/redisotel/v9"
"github.com/redis/go-redis/v9"
)
func NewRedisClient(addr string) (*redis.Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
DB: 0,
})
// Включаем трассировку и метрики Redis
if err := redisotel.InstrumentTracing(rdb,
redisotel.WithDBStatement(true),
); err != nil {
return nil, err
}
return rdb, nil
}
Полный обработчик заказа с дочерними спанами
package handler
import (
"context"
"encoding/json"
"net/http"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
var tracer = otel.Tracer("api-gateway")
type CreateOrderRequest struct {
UserID string `json:"user_id"`
ProductID string `json:"product_id"`
Quantity int `json:"quantity"`
}
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx, span := tracer.Start(ctx, "CreateOrder",
trace.WithSpanKind(trace.SpanKindServer),
)
defer span.End()
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "invalid request body")
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
span.SetAttributes(
attribute.String("user.id", req.UserID),
attribute.String("product.id", req.ProductID),
attribute.Int("order.quantity", req.Quantity),
)
// Проверяем наличие товара в Inventory Service
available, err := h.checkInventory(ctx, req.ProductID, req.Quantity)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "inventory check failed")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
span.SetAttributes(attribute.Bool("inventory.available", available))
if !available {
span.SetStatus(codes.Error, "out of stock")
http.Error(w, "Product out of stock", http.StatusConflict)
return
}
// Создаём заказ в Order Service
orderID, err := h.createOrder(ctx, req)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "order creation failed")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
span.SetAttributes(attribute.String("order.id", orderID))
span.SetStatus(codes.Ok, "order created")
json.NewEncoder(w).Encode(map[string]string{"order_id": orderID})
}
func (h *Handler) checkInventory(ctx context.Context, productID string, qty int) (bool, error) {
ctx, span := tracer.Start(ctx, "checkInventory",
trace.WithSpanKind(trace.SpanKindClient),
)
defer span.End()
span.SetAttributes(
attribute.String("rpc.service", "inventory-service"),
attribute.String("product.id", productID),
)
// HTTP-вызов с пропагацией контекста
resp, err := h.inventoryClient.CheckStock(ctx, productID, qty)
if err != nil {
span.RecordError(err)
return false, err
}
return resp.Available, nil
}
Типичные ошибки и как их избежать
- Потеря контекста. Самая частая ошибка — передача
context.Background()вместоctxиз вызывающей функции. Всегда пробрасывайте контекст через все слои. - Отсутствие propagation в HTTP-клиентах. При ручном создании HTTP-запросов вызывайте
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header)), иначе дочерние сервисы не получат trace context. - Избыточный сэмплинг в production. Трассировка 100% запросов на высоконагруженном сервисе создаёт значительный overhead. Используйте
TraceIDRatioBased(0.01–0.1)и увеличивайте ставку при расследовании инцидентов. - Спаны без завершения. Всегда вызывайте
defer span.End()сразу послеtracer.Start(). Незавершённые спаны не экспортируются и создают утечку памяти. - Слишком детальные атрибуты с PII. Не записывайте в атрибуты спанов персональные данные пользователей (email, телефон, номера карт). Это нарушает GDPR и создаёт риски безопасности.
- Игнорирование ошибок экспортёра. В production настройте алертинг на метрику
otelcol_exporter_send_failed_spansв Collector, чтобы вовремя обнаружить проблемы доставки трейсов. - Единый
tracerна всё приложение. Создавайте именованные трейсеры черезotel.Tracer("package-name")для каждого пакета — это упрощает фильтрацию в Jaeger.
Production-советы
Несколько рекомендаций для работы в промышленной среде:
- Используйте OpenTelemetry Collector как промежуточный слой между приложениями и Jaeger — это позволяет менять backend без перекомпиляции сервисов, добавлять обогащение данных и tail-based sampling.
- Настройте Jaeger с Elasticsearch для хранения трейсов дольше 48 часов. Индексируйте по
service.name,span.kindиhttp.status_code. - Добавьте Span Events для важных бизнес-событий внутри спана:
span.AddEvent("cache.miss", trace.WithAttributes(attribute.String("key", cacheKey))). - Интегрируйте Jaeger с Grafana через datasource plugin для единого dashboard с метриками, логами и трейсами (Grafana Tempo как альтернатива Jaeger имеет нативную интеграцию).
Заключение
Distributed tracing с OpenTelemetry, Jaeger и Go — это не просто инструмент отладки, а фундамент современной observability-стратегии. Внедрение требует разовых усилий: настройки TracerProvider, добавления middleware для HTTP и gRPC, инструментирования PostgreSQL и Redis. Но отдача многократно превышает затраты: время на диагностику инцидентов сокращается с часов до минут.
Ключевые принципы успешного внедрения: строгая дисциплина передачи контекста, умный сэмплинг в production, корреляция трейсов с логами и метриками, и использование OpenTelemetry Collector как буфера между приложениями и backend. Начните с инструментирования критического пути вашего приложения, и вы быстро увидите реальную ценность сквозной трассировки запросов.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →