39. Observability в архитектуре. Metrics, Logs, Traces

Три столпа наблюдаемости

«Чтобы понять систему, вы должны быть способны судить о ее внутреннем состоянии по внешним проявлениям, не разбирая ее на части».
— Рудольф Калман, создатель теории линейных систем

Защитные механизмы распределённой архитектуры — Rate Limiting, Circuit Breaker, Timeout и Bulkhead — делают сервис удивительно стойким к ударам стихии. Они предотвращают каскадные обрушения и гасят сетевые штормы. Однако устойчивость вовсе не означает вечное отсутствие сбоев. В реальной жизни распределённая система непрерывно находится в состоянии частичной деградации: на одном из узлов подскочила задержка дискового ввода-вывода, сеть между дата-центрами начала терять 1.5% пакетов, сторонний платёжный шлюз стал отвечать кодом 504 Gateway Timeout, а конкретный VIP-пользователь упирается в необъяснимую ошибку.

Если система представляет собой «чёрный ящик», инженеры дежурной смены обречены на судорожное гадание на кофейной гуще, лихорадочный перезапуск случайных подов и бесплодные споры в инцидент-чатах.

Чтобы управлять сложной системой, её необходимо сделать наблюдаемой (Observable). В этой лекции мы разберём фундаментальную триаду наблюдаемости — метрики, логи и распределённые трейсы, раскроем их архитектурное взаимодействие, реализуем идиоматичную инструментацию в рантайме Go (с учётом log/slog и OpenTelemetry) и измерим скрытую цену, которую наше «механическое сочувствие» платит за каждый снятый показатель.


Observability vs Monitoring: два уровня понимания

В инженерном обиходе термины «мониторинг» и «наблюдаемость» часто путают, однако между ними пролегает глубокая концептуальная граница:

flowchart TD
    subgraph Monitoring["Классический мониторинг: 'Известные неизвестные'"]
        M1["Предопределённые дашборды"] --> M2["Статические пороги (Thresholds)"]
        M2 --> M3["Алерты: 'Что именно сломалось?'<br>(CPU > 90%, HTTP 500 > 5%)"]
    end

    subgraph Observability["Современная наблюдаемость: 'Неизвестные неизвестные'"]
        O1["Метрики (Агрегированные тренды)"] --> CORR["Единая корреляция<br>(Trace ID / Exemplars)"]
        O2["Логи (Контекст дискретных событий)"] --> CORR
        O3["Трейсы (Сквозной граф пути запроса)"] --> CORR
        CORR --> O4["Исследование поведения:<br>'Почему это произошло именно сейчас?'"]
    end

    classDef mon fill:#1e1b4b,stroke:#818cf8,stroke-width:2px,color:#f8fafc;
    classDef obs fill:#131d33,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    class M1,M2,M3 mon;
    class O1,O2,O3,CORR,O4 obs;
  
  1. Мониторинг (Monitoring) отвечает на вопрос: «Работает ли система в соответствии с нашими ожиданиями и что конкретно сломалось?»
    Он следит за предопределёнными симптомами («занято 92% памяти на узле DB-03», «число 500-х ответов превысило 2%»). Это пассивное наблюдение за заранее известными точками отказа.
  2. Наблюдаемость (Observability) отвечает на вопрос: «Почему система ведёт себя именно так в нештатной ситуации?»
    Это свойство внутренней архитектуры системы, позволяющее инженеру сформулировать любой произвольный вопрос о внутреннем состоянии сервисов на основе анализа выходных потоков телеметрии без необходимости деплоить новый код или расставлять отладочные принты.

В современной высоконагруженной архитектуре наблюдаемость опирается на три столпа:

  • Метрики (Metrics): числовые временные ряды. Отвечают на вопросы: «Сколько? Как быстро? Каков тренд?». Идеальны для обнаружения аномалий и детекции инцидентов.
  • Логи (Logs): структурированные текстовые записи о дискретных событиях. Отвечают на вопрос: «Что именно произошло в конкретной точке исполнения?».
  • Трейсы (Traces): направленные ациклические графы пути одного запроса сквозь десятки микросервисов. Отвечают на вопрос: «Где именно возникла задержка и на каком звене оборвалась цепочка?».
flowchart LR
    Service["Go-микросервис"]
    Metrics["Метрики<br>Prometheus / VictoriaMetrics"]
    Logs["Логи<br>Loki / ELK"]
    Traces["Трейсы<br>Jaeger / Grafana Tempo"]

    Service -->|"HTTP middleware<br>(client_golang)"| Metrics
    Service -->|"slog / zap<br>(JSON stdout)"| Logs
    Service -->|"OpenTelemetry SDK<br>(OTLP gRPC/HTTP)"| Traces

    classDef srv fill:#131d33,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    classDef dst fill:#1e1b4b,stroke:#818cf8,stroke-width:2px,color:#f8fafc;
    class Service srv;
    class Metrics,Logs,Traces dst;
  

Метрики: числа, раскрывающие тренды

Метрики представляют собой числовые временные ряды: пары (timestamp, float64), снабжённые именами и набором строковых меток (labels/tags). Главное достоинство метрик — предельная компактность и вычислительная дешевизна: агрегация сотен тысяч запросов в секунду сводится к элементарным сложениям чисел в оперативной памяти процесса.

В экосистеме Go непререкаемым стандартом де-факто стал стек Prometheus и официальная библиотека github.com/prometheus/client_golang/prometheus.

В Prometheus существует 4 базовых типа метрик:

  1. Counter (Счётчик): монотонно возрастающее число (сбрасывается только при перезапуске сервиса). Примеры: общее число запросов http_requests_total, число отправленных байт.
  2. Gauge (Датчик): число, которое может произвольно расти и падать. Примеры: текущее количество активных горутин, занятая оперативная память, размер пула коннектов к БД.
  3. Histogram (Гистограмма): квантование значений по настраиваемым корзинам (buckets). Позволяет вычислять процентили ($p50, p95, p99$) на стороне хранилища. Примеры: время обработки запросов, размер полезной нагрузки.
  4. Summary (Сводка): рассчитывает скользящие квантили прямо в рантайме сервиса. Из-за высокой цены расчёта в Go-коде и невозможности агрегации между несколькими подами применяется редко.

RED-паттерн: Rate, Errors, Duration

Для сетевых сервисов и HTTP/gRPC API золотым стандартом является RED-паттерн (сформулированный Томом Уилки):

  • Rate (Частота): количество входящих запросов в секунду (RPS).
  • Errors (Ошибки): количество запросов, завершившихся неудачей (HTTP 5xx, gRPC коды ошибок).
  • Duration (Длительность): распределение времени ответа сервиса (гистограмма латентности).

Ниже приведена промышленная реализация RED-инструментации в виде HTTP-middleware на чистом Go:

Go
package telemetry

import (
    "net/http"
    "strconv"
    "time"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    // Rate & Errors: счётчик входящих запросов с разбиением по кодам ответа
    httpRequestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Namespace: "app",
            Subsystem: "http",
            Name:      "requests_total",
            Help:      "Total number of processed HTTP requests.",
        },
        []string{"method", "path", "status"},
    )

    // Duration: гистограмма времени ответа с логарифмическими корзинами (в секундах)
    httpRequestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Namespace: "app",
            Subsystem: "http",
            Name:      "request_duration_seconds",
            Help:      "HTTP request latency distributions in seconds.",
            Buckets:   prometheus.DefBuckets, // от 5мс до 10с
        },
        []string{"method", "path"},
    )
)

func init() {
    prometheus.MustRegister(httpRequestsTotal)
    prometheus.MustRegister(httpRequestDuration)
}

// responseWriter перехватывает HTTP-код ответа для фиксации в метриках
type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

// MetricsMiddleware оборачивает любой стандартный http.Handler
func MetricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}

        next.ServeHTTP(rw, r)

        duration := time.Since(start).Seconds()

        // Записываем RED-показатели
        httpRequestsTotal.WithLabelValues(r.Method, r.URL.Path, strconv.Itoa(rw.statusCode)).Inc()
        httpRequestDuration.WithLabelValues(r.Method, r.URL.Path).Observe(duration)
    })
}

USE-паттерн: Utilisation, Saturation, Errors

Если RED-паттерн описывает состояние сервиса с точки зрения внешнего пользователя, то USE-паттерн (сформулированный Бренданом Греггом) описывает состояние внутренних аппаратных и системных ресурсов (процессор, память, сокеты, пулы горутин):

  • Utilisation (Утилизация): процент времени или объёма, в течение которого ресурс был занят полезной работой (например, 70% CPU).
  • Saturation (Насыщение): объём отложенной работы, скопившейся в очереди из-за перегрузки ресурса (например, длина runqueue планировщика Go, очередь задач в канале).
  • Errors (Ошибки): количество аппаратных или системных сбоев (например, TCP retransmits, сброшенные сетевые пакеты).

Для непрерывного сбора метрик рантайма Go библиотека Prometheus предоставляет встроенный коллектор:

Go
// Регистрация детальных метрик рантайма Go (память, GC, горутины, аллокации)
prometheus.MustRegister(prometheus.NewGoCollector())

Экспорт метрик

В модели Prometheus принята pull-архитектура: агент сбора периодически (раз в 5–15 секунд) выполняет HTTP GET-запрос к выделенному техническому порту сервиса:

Go
// Отдельный внутренний мультиплексор для метрик и healthcheck-проверок
func RegisterMetricsEndpoint(mux *http.ServeMux) {
    mux.Handle("/metrics", promhttp.Handler())
}

Логи: контекст и детали

Если метрики сигнализируют о том, что пожар начался, то логи позволяют локализовать брошенную спичку. Лог — это подробная текстовая запись о дискретном событии в жизни приложения.

Начиная с версии Go 1.21, в стандартную библиотеку языка вошёл пакет log/slog, закрепивший фундаментальный стандарт: логирование в промышленном бэкенде обязано быть структурированным и выводиться в формате JSON. До появления slog индустриальным стандартом служили сторонние библиотеки uber-go/zap (go.uber.org/zap) и github.com/rs/zerolog.

Каждая запись лога обязана представлять собой валидный JSON-объект, содержащий обязательные контекстные поля: временную метку с наносекундами, уровень логирования (DEBUG, INFO, WARN, ERROR), имя сервиса, идентификаторы сквозной трассировки (trace_id, span_id) и произвольные атрибуты операции:

Go
package main

import (
    "context"
    "log/slog"
    "os"
    "time"
)

type Order struct {
    ID     string
    UserID string
    Amount int64
}

func initLogger() *slog.Logger {
    // JSON-хендлер для машиночитаемого централизованного сбора (Loki, Elasticsearch)
    opts := &slog.HandlerOptions{
        Level:     slog.LevelInfo,
        AddSource: true, // добавляет файл и номер строки
    }
    handler := slog.NewJSONHandler(os.Stdout, opts)
    return slog.New(handler)
}

func ProcessOrder(ctx context.Context, logger *slog.Logger, order Order) {
    start := time.Now()

    // Бизнес-логика выполнения заказа...
    duration := time.Since(start)

    // Структурированная запись: строгая типизация ключей и значений
    logger.InfoContext(ctx, "order created successfully",
        slog.String("order_id", order.ID),
        slog.String("user_id", order.UserID),
        slog.Int64("amount_cents", order.Amount),
        slog.Int64("duration_ms", duration.Milliseconds()),
    )
}

Распределённая трассировка: путь запроса

В микросервисной среде единичный клик пользователя в мобильном приложении запускает разветвлённую цепную реакцию: вызов API Gateway каскадно расходится по сервисам аутентификации, каталога, корзины, платёжного шлюза, кэша Redis и баз данных.

Когда сервис возвращает ошибку 504 Gateway Timeout или латентность подскакивает до 4 секунд, ни метрики, ни разрозненные логи отдельных сервисов не способны показать, на каком именно плече сетевого пути возник затор. Эту задачу решает распределённая трассировка (Distributed Tracing).

Индустриальным стандартом трассировки и телеметрии является проект CNCF OpenTelemetry (OTel).

Трассировка оперирует тремя фундаментальными концепциями:

  1. Trace (Трейс): полное дерево операций, порождённых одним внешним запросом. Трейс идентифицируется глобально уникальным 128-битным TraceID.
  2. Span (Спан): неделимый отрезок работы внутри одного сервиса (например, выполнение SQL-запроса, вызов gRPC-метода или обработка HTTP-хендлера). Спан имеет имя, время старта и завершения, статус, атрибуты (теги) и идентификатор родителя ParentSpanID.
  3. Context Propagation (Проброс контекста): протокольный механизм передачи идентификаторов трассировки сквозь сетевые границы. Стандартом W3C Trace Context определены HTTP-заголовки traceparent и tracestate.
sequenceDiagram
    autonumber
    participant Client as Клиент / Браузер
    participant GW as API Gateway
    participant OrderSvc as Order Service
    participant PaySvc as Payment Service
    participant DB as PostgreSQL

    Note over Client,DB: TraceID = 4bf92f3577b34da6a3ce929d0e0e4736
    Client->>GW: POST /api/v1/orders (без заголовка)
    Note over GW: Старт Root Span: "HTTP POST /orders"<br>Генерация TraceID и SpanID_1
    GW->>OrderSvc: POST /orders (W3C traceparent: SpanID_1)
    Note over OrderSvc: Старт Child Span: "CreateOrder" (SpanID_2)
    OrderSvc->>PaySvc: POST /charge (W3C traceparent: SpanID_2)
    Note over PaySvc: Старт Child Span: "Authorize" (SpanID_3)
    PaySvc-->>OrderSvc: 200 OK
    OrderSvc->>DB: INSERT INTO orders... (SpanID_4)
    DB-->>OrderSvc: Query completed (3ms)
    OrderSvc-->>GW: 201 Created
    GW-->>Client: 200 OK
  

В Go передача контекста трассировки естественным и идиоматичным образом ложится на стандартный интерфейс context.Context. Библиотека go.opentelemetry.io предоставляет готовые middleware и транспорты для сетевых вызовов:

Go
package main

import (
    "context"
    "net/http"

    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/trace"
)

// Инструментация входящего HTTP-сервера
func SetupHTTPServer(mux *http.ServeMux) http.Handler {
    // otelhttp автоматически извлекает W3C traceparent из входящих заголовков
    return otelhttp.NewHandler(mux, "order-service")
}

// Инструментация исходящего HTTP-клиента
func NewInstrumentedClient() *http.Client {
    // otelhttp.NewTransport автоматически инжектирует traceparent в исходящие HTTP-заголовки
    return &http.Client{
        Transport: otelhttp.NewTransport(http.DefaultTransport),
    }
}

// Создание ручных спанов для критических участков бизнес-логики
func ProcessPayment(ctx context.Context, orderID string, amount int64) error {
    tracer := otel.Tracer("payment-processor")

    // Начинаем спан операции
    ctx, span := tracer.Start(ctx, "ProcessPayment",
        trace.WithAttributes(
            attribute.String("order.id", orderID),
            attribute.Int64("payment.amount", amount),
        ),
    )
    defer span.End()

    // Выполнение логики...
    return nil
}

Корреляция через единый идентификатор

Настоящая сила Observability раскрывается не тогда, когда метрики, логи и трейсы существуют по отдельности в разных вкладках браузера, а тогда, когда они бесшовно связаны между собой единой нитью корреляции:

flowchart LR
    subgraph Metric["1. Алерт в Prometheus"]
        A["Histogram p99 > 2s<br>Exemplar: TraceID=4bf92..."]
    end

    subgraph Trace["2. Трассировка в Jaeger / Tempo"]
        B["Root Span: 2.1s<br>Postgres Query Span: 1.95s (Bottleneck!)"]
    end

    subgraph Log["3. Логи в Loki"]
        C["Query error / Slow lock wait<br>trace_id=4bf92... order_id=8812"]
    end

    Metric -->|"Клик по Exemplar"| Trace
    Trace -->|"Фильтр по Trace ID"| Log

    classDef m fill:#1e1b4b,stroke:#818cf8,stroke-width:2px,color:#f8fafc;
    classDef t fill:#131d33,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    classDef l fill:#0f172a,stroke:#10b981,stroke-width:2px,color:#f8fafc;
    class Metric m;
    class Trace t;
    class Log l;
  

Для достижения такой бесшовности HTTP-хендлер извлекает актуальный TraceID из контекста OpenTelemetry и автоматически обогащает им логгер slog:

Go
package middleware

import (
    "context"
    "log/slog"
    "net/http"

    "go.opentelemetry.io/otel/trace"
)

type contextKey string

const LoggerContextKey contextKey = "ctx_logger"

// LoggingMiddleware связывает логи с OpenTelemetry TraceID
func LoggingMiddleware(baseLogger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx := r.Context()
            spanContext := trace.SpanContextFromContext(ctx)

            logger := baseLogger
            if spanContext.IsValid() {
                // Внедряем trace_id и span_id в контекстный логгер
                logger = logger.With(
                    slog.String("trace_id", spanContext.TraceID().String()),
                    slog.String("span_id", spanContext.SpanID().String()),
                )
            }

            // Пробрасываем обогащённый логгер в контекст запроса
            reqWithLogger := r.WithContext(context.WithValue(ctx, LoggerContextKey, logger))
            next.ServeHTTP(w, reqWithLogger)
        })
    }
}

// FromContext извлекает контекстный логгер
func FromContext(ctx context.Context) *slog.Logger {
    if logger, ok := ctx.Value(LoggerContextKey).(*slog.Logger); ok {
        return logger
    }
    return slog.Default()
}

А благодаря механизму Prometheus Exemplars инспектор метрик прямо на графике латентности видит точку конкретного медленного запроса, кликнув на которую переходит непосредственно в интерфейс Jaeger к трейсу этого запроса!


Mechanical Sympathy: цена observability

Сбор данных никогда не бывает бесплатным. Профессиональный архитектор обязан трезво оценивать влияние каждого инструмента на наносекунды процессора, аллокации памяти и сетевой ввод-вывод.

1. Метрики: Опасность взрыва кардинальности (Cardinality Explosion)

  • Вызов Counter.Inc() или Histogram.Observe() под капотом использует атомарные инструкции процессора (LOCK XADD на x86-64) и хэш-таблицы лейблов с RWMutex. Это занимает доли наносекунд.
  • Однако добавление в метрику динамических лейблов с высокой кардинальностью (например, user_id, order_id или сырого URL с уникальными query-параметрами) приводит к созданию новой структуры time.Series в куче Go на каждое уникальное значение! Это вызывает взрывной рост памяти (Out of Memory) и перегрузку сборщика мусора GC.
  • Правило: Лейблы метрик должны быть строго ограничены конечным множеством значений (HTTP-методы, статусы, имена ручек).

2. Логи: Блокировка системных вызовов и аллокации

  • Каждый вызов slog.InfoContext или форматирование лога — это аллокация строк, сериализация JSON и системный вызов ядра Linux write(2) в файловый дескриптор stdout (fd 1).
  • Если сервис генерирует 10 000 логов в секунду, горутины встают в очередь на блокировку pipe-буфера stdout ядра Linux.
  • Решение: Использование буферизированных неблокирующих писателей с фиксированными кольцевыми буферами, сэмплирование логов информационного уровня (zapcore.NewSampler) и использование структурированных атрибутов slog.Attr без лишних аллокаций интерфейсов.

3. Трейсы: Нагрузка на сеть и сэмплирование

  • Создание спана требует аллокации структур в куче и сериализации метаданных. При 100 000 RPS попытка записать 100% трейсов создаст сетевой трафик телеметрии, превышающий полезную нагрузку приложения.
  • Решение: Head-based сэмплирование. Трассируется только репрезентативная часть трафика (например, 1–5% случайных запросов, но 100% запросов с ошибками):
Go
import (
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

// Сэмплирование 10% запросов: дочерние спаны наследуют решение родителя
sampler := sdktrace.ParentBased(
    sdktrace.TraceIDRatioBased(0.10),
)

Observability в архитектуре

Наблюдаемость — это не библиотека, которую подключают перед релизом в файле main.go. Это фундаментальный сквозной аспект чистой архитектуры (14. Clean Architecture и Dependency Rule):

flowchart TD
    subgraph Core["Domain & Application Core (Чистая логика)"]
        UC["Use Case / Interactor"]
    end

    subgraph Ports["Интерфейсы (Ports)"]
        P_Log["Logger Interface"]
        P_Trace["Tracer Interface / context.Context"]
        P_Metric["Domain Metrics"]
    end

    subgraph Adapters["Инфраструктурные адаптеры (Adapters)"]
        A_HTTP["HTTP / gRPC Handler (Middleware RED)"]
        A_Repo["PostgreSQL Repo (DB Spans / Latency)"]
        A_Kafka["Kafka Consumer (Header Propagation)"]
    end

    Adapters --> Ports
    Core --> Ports

    classDef core fill:#131d33,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    classDef port fill:#1e1b4b,stroke:#818cf8,stroke-width:2px,color:#f8fafc;
    classDef adp fill:#0f172a,stroke:#10b981,stroke-width:2px,color:#f8fafc;
    class Core core;
    class Ports port;
    class Adapters adp;
  
  1. Контекст как главная артерия: Каждый метод в интерфейсах бизнес-логики и репозиториев первым аргументом принимает ctx context.Context. Прерывание цепочки контекста убивает распределённую трассировку.
  2. Адаптеры на границах: Входные адаптеры (HTTP/gRPC) извлекают контекст трассировки из сетевых заголовков и фиксируют RED-метрики. Выходные адаптеры (клиенты баз данных, брокеров сообщений) инжектируют контекст в исходящие заголовки и логируют ошибки с детализацией SQL-запросов.
  3. Бизнес-метрики: Доменные сервисы инкрементируют продуктовые метрики (например, orders_completed_total, payment_processing_amount_rubles), не привязываясь к конкретному формату экспорта.

Связь с другими темами


Антипаттерны и типичные ошибки

  1. High Cardinality Hell:
    Передача идентификаторов пользователей (user_id), хэшей или временных меток в качестве label в Prometheus. Приводит к мгновенному падению сервера метрик по OOM.
  2. Логирование чувствительных данных (PII/Secrets):
    Запись паролей, номеров кредитных карт или токенов авторизации в логи. Нарушает GDPR/PCI-DSS и создает критическую уязвимость безопасности.
  3. Отсутствие корреляции:
    Метрики собираются отдельно, логи пишутся без trace_id, а трейсы не настроены. При возникновении аварии инженеры тратят часы на ручное сопоставление времени логов с разных машин.
  4. Синхронный экспорт телеметрии:
    Попытка отправлять спаны трейсов или логи синхронно в сторонний HTTP API прямо в обработчике клиентского запроса. Любое сетевое замедление системы сбора моментально парализует все бизнес-сервисы.


Итог

Observability — это не роскошь и не факультативная надстройка, а единственный способ сохранить контроль над сложной распределённой системой. Метрики выступают в роли радара, предупреждающего об опасных тенденциях; логи предоставляют подробный отчёт бортового самописца о каждом конкретном инциденте; а распределённые трейсы освещают непрерывный сквозной путь транзакции сквозь лабиринт сотен сетевых узлов.

Связав эту триаду воедино через сквозные TraceID, мы получаем систему, способную отвечать на любые непредвиденные вопросы в моменты кризиса.

В следующей лекции мы детально погрузимся в механику самого сложного и мощного из этих компонентов: 40. Distributed Tracing и корреляция запросов.