Backend-разработка

Distributed Tracing в микросервисах: сквозная трассировка запросов с OpenTelemetry, Jaeger и Go

Ruslan Ismailov Опубликовано 14 мин чтения
D

Введение: зачем нужен 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. Подробнее обо мне →