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;
- Мониторинг (Monitoring) отвечает на вопрос: «Работает ли система в соответствии с нашими ожиданиями и что конкретно сломалось?»
Он следит за предопределёнными симптомами («занято 92% памяти на узле DB-03», «число 500-х ответов превысило 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 базовых типа метрик:
- Counter (Счётчик): монотонно возрастающее число (сбрасывается только при перезапуске сервиса). Примеры: общее число запросов
http_requests_total, число отправленных байт. - Gauge (Датчик): число, которое может произвольно расти и падать. Примеры: текущее количество активных горутин, занятая оперативная память, размер пула коннектов к БД.
- Histogram (Гистограмма): квантование значений по настраиваемым корзинам (
buckets). Позволяет вычислять процентили ($p50, p95, p99$) на стороне хранилища. Примеры: время обработки запросов, размер полезной нагрузки. - Summary (Сводка): рассчитывает скользящие квантили прямо в рантайме сервиса. Из-за высокой цены расчёта в Go-коде и невозможности агрегации между несколькими подами применяется редко.
RED-паттерн: Rate, Errors, Duration
Для сетевых сервисов и HTTP/gRPC API золотым стандартом является RED-паттерн (сформулированный Томом Уилки):
- Rate (Частота): количество входящих запросов в секунду (RPS).
- Errors (Ошибки): количество запросов, завершившихся неудачей (HTTP 5xx, gRPC коды ошибок).
- Duration (Длительность): распределение времени ответа сервиса (гистограмма латентности).
Ниже приведена промышленная реализация RED-инструментации в виде HTTP-middleware на чистом 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 (память, GC, горутины, аллокации)
prometheus.MustRegister(prometheus.NewGoCollector())
Экспорт метрик
В модели Prometheus принята pull-архитектура: агент сбора периодически (раз в 5–15 секунд) выполняет HTTP GET-запрос к выделенному техническому порту сервиса:
// Отдельный внутренний мультиплексор для метрик и 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) и произвольные атрибуты операции:
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).
Трассировка оперирует тремя фундаментальными концепциями:
- Trace (Трейс): полное дерево операций, порождённых одним внешним запросом. Трейс идентифицируется глобально уникальным 128-битным
TraceID. - Span (Спан): неделимый отрезок работы внутри одного сервиса (например, выполнение SQL-запроса, вызов gRPC-метода или обработка HTTP-хендлера). Спан имеет имя, время старта и завершения, статус, атрибуты (теги) и идентификатор родителя
ParentSpanID. - 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 и транспорты для сетевых вызовов:
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:
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 и системный вызов ядра Linuxwrite(2)в файловый дескрипторstdout(fd 1). - Если сервис генерирует 10 000 логов в секунду, горутины встают в очередь на блокировку pipe-буфера stdout ядра Linux.
- Решение: Использование буферизированных неблокирующих писателей с фиксированными кольцевыми буферами, сэмплирование логов информационного уровня (
zapcore.NewSampler) и использование структурированных атрибутовslog.Attrбез лишних аллокаций интерфейсов.
3. Трейсы: Нагрузка на сеть и сэмплирование
- Создание спана требует аллокации структур в куче и сериализации метаданных. При 100 000 RPS попытка записать 100% трейсов создаст сетевой трафик телеметрии, превышающий полезную нагрузку приложения.
- Решение: Head-based сэмплирование. Трассируется только репрезентативная часть трафика (например, 1–5% случайных запросов, но 100% запросов с ошибками):
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;
- Контекст как главная артерия: Каждый метод в интерфейсах бизнес-логики и репозиториев первым аргументом принимает
ctx context.Context. Прерывание цепочки контекста убивает распределённую трассировку. - Адаптеры на границах: Входные адаптеры (HTTP/gRPC) извлекают контекст трассировки из сетевых заголовков и фиксируют RED-метрики. Выходные адаптеры (клиенты баз данных, брокеров сообщений) инжектируют контекст в исходящие заголовки и логируют ошибки с детализацией SQL-запросов.
- Бизнес-метрики: Доменные сервисы инкрементируют продуктовые метрики (например,
orders_completed_total,payment_processing_amount_rubles), не привязываясь к конкретному формату экспорта.
Связь с другими темами
- Распределённая трассировка: Глубокий разбор контекстной пропагации, спанов и корреляции запросов представлен в лекции 40. Distributed Tracing и корреляция запросов.
- Метрики и соглашения об уровне сервиса: Сбор SLI и расчёт Error Budget детально рассмотрены в лекции 4. SLA, SLO, SLI и как они влияют на дизайн.
- Защитные барьеры: Данные метрик насыщения (USE) служат основой для динамического управления лимитами в 38. Rate Limiting и защита системы и 43. Backpressure и контроль нагрузки.
Антипаттерны и типичные ошибки
- High Cardinality Hell:
Передача идентификаторов пользователей (user_id), хэшей или временных меток в качестве label в Prometheus. Приводит к мгновенному падению сервера метрик по OOM. - Логирование чувствительных данных (PII/Secrets):
Запись паролей, номеров кредитных карт или токенов авторизации в логи. Нарушает GDPR/PCI-DSS и создает критическую уязвимость безопасности. - Отсутствие корреляции:
Метрики собираются отдельно, логи пишутся безtrace_id, а трейсы не настроены. При возникновении аварии инженеры тратят часы на ручное сопоставление времени логов с разных машин. - Синхронный экспорт телеметрии:
Попытка отправлять спаны трейсов или логи синхронно в сторонний HTTP API прямо в обработчике клиентского запроса. Любое сетевое замедление системы сбора моментально парализует все бизнес-сервисы.
Итог
Observability — это не роскошь и не факультативная надстройка, а единственный способ сохранить контроль над сложной распределённой системой. Метрики выступают в роли радара, предупреждающего об опасных тенденциях; логи предоставляют подробный отчёт бортового самописца о каждом конкретном инциденте; а распределённые трейсы освещают непрерывный сквозной путь транзакции сквозь лабиринт сотен сетевых узлов.
Связав эту триаду воедино через сквозные TraceID, мы получаем систему, способную отвечать на любые непредвиденные вопросы в моменты кризиса.
В следующей лекции мы детально погрузимся в механику самого сложного и мощного из этих компонентов: 40. Distributed Tracing и корреляция запросов.