Модуль 6. Observability и SLO: наблюдаемость и целевые уровни сервиса

Цель модуля: Сделать распределённую систему полностью прозрачной для эксплуатации. Научить студента связывать логи, метрики и трейсы (Three Pillars) через OpenTelemetry. Спроектировать SLO (Service Level Objectives) и настроить алертинг на основе деградации бизнеса. Инфраструктура: Open Source стек (Prometheus, Jaeger, Loki) на Yandex Cloud.

Стадия роста проекта: Продакшен 24/7. Проект пережил интеграции, Outbox, Saga и Resilience. Система работает круглосуточно, метрики прозрачны, секреты в Lockbox. Но пришло время доказать, что система действительно надёжна — и первое, что ломается — видимость. Инциденты расследуются часами вместо минут, потому что логи, метрики и трейсы не связаны.

Перед началом: Модули 1–5 (особенно Resilience — метрики CB, Outbox — trace_id) Инструменты: OpenTelemetry, Prometheus, Jaeger, Loki, Alertmanager Трудоёмкость: ~6 ч (теория 1.5 ч · практика 4 ч · ADR 0.5 ч)

🔥 Инцидент: тишина, которая страшнее ошибок

Circuit Breaker, Bulkhead и кэш работают (Модуль 5). В Grafana всё «зелёное»: RPS стабильный, latency в норме, HTTP-ошибок почти нет. Но в понедельник — письмо от дежурного: «Клиенты жалуются, что переводы зависают. В логах тишина: одни INFO. А количество компенсаций в Saga за выходные превысило сотню, и никто не понимает, на каком шаге и почему».

Ты начинаешь копать. Логи есть, но без связи: в одном сервисе «KYC skipped», в другом «Compensation triggered» — и ни одного общего идентификатора. Метрики: error rate почти нулевой — потому что компенсации это не HTTP 500, а бизнес-решения. На них нет алертов. Трейсы: в Jaeger видны обрывки — трейс есть до Kafka, а дальше пустота. Консьюмер не подхватывает trace_id, и история перевода распадается на несвязанные куски.

И ещё: кто-то в коде логирует email и номер счёта «для удобства отладки» — утечка PII. А в Prometheus — метка user_id для каждого платежа: количество временных рядов взлетело, мониторинг начинает тормозить от взрыва кардинальности.

Три вопроса, на которые нужно ответить:

  1. «Что пошло не так?» — сквозные трейсы с trace_id через Kafka (доработать Outbox: хранить trace_id в колонке, копировать в Kafka headers).
  2. «Насколько всё плохо для бизнеса?» — правильные метрики: не «сколько HTTP 500», а «сколько компенсаций / 10 мин», «CB в Open > 1 мин», «сожжено > 80% Error Budget».
  3. «Где причина в логах?» — структурированные JSON-логи с trace_id, span_id, error_class — и без PII.

Невидимая система неуправляема. Observability — это не «собирать логи», а связать трейсы, метрики и логи в единую картину, где инцидент расследуется за минуты, а не часы.

📖 Теория: Раскрытые термины

Глоссарий модуля:

  • Span / Trace — Span: одна операция в трейсе; Trace: дерево Span-ов сквозь сервисы, склеенное общим trace_id.
  • Time series (временной ряд) — последовательность значений метрики во времени; каждая уникальная комбинация labels = отдельный ряд.
  • Cardinality (кардинальность) — число различных значений метки; user_id даёт «взрыв» рядов и OOM Prometheus.
  • PII (Personally Identifiable Information) — персональные данные (email, телефон, номер карты); логировать нельзя — только маской.
  • Scrape — сбор метрик Prometheus-ом с endpointа /metrics по расписанию.
  • SLI / SLO / SLA — SLI: метрика; SLO: целевое значение; SLA: внешний договор с клиентом.
  • Error Budget — 100% минус SLO = допустимый «запас ошибок»; алерты при сжигании >80% за окно.
  • OpenTelemetry — единый стандарт трейсинга/метрик/логов: SDK + collector, кросс-языковой, пробрасывает trace_id от API до воркера через Kafka headers.

6.1. Три кита Observability

Logs (Логи): Дискретные события. Должны быть структурированными (JSON) и содержать trace_id, span_id, tenant_id и error_class (для быстрого поиска в Loki).

Metrics (Метрики): Агрегированные данные во времени (RPS, latency, error rate). Отлично подходят для алертинга (Prometheus).

Traces (Трейсы): Дерево вызовов (Span) сквозь все микросервисы. Показывают, где именно система тратит время или упала (Jaeger).

6.2. Span, Trace и Jaeger: как читать трейсы

Span — единичная операция: HTTP-запрос, вызов БД, отправка в Kafka. У каждого Span-а есть:

  • trace_id — общий для всего дерева (один перевод = один trace_id).
  • span_id — уникальный для каждого Span-а.
  • parent_span_id — ссылка на родительский Span (образует дерево).
  • duration — сколько времени заняла операция.
  • attributes — метаданные: db.statement, http.url, messaging.destination.

Trace — дерево Span-ов сквозной через все сервисы. Пример перевода:

[API] POST /transfer                     220ms  ← корневой span
  ├── [Saga] HandleTransfer               190ms
  │     ├── [DB] INSERT payment+outbox      15ms
  │     ├── [Kafka] Publish command          5ms
  │     └── [Worker] ProcessPayment        160ms  ← здесь тормозит!
  │           ├── [DB] SELECT account          8ms
  │           ├── [KYC] CheckKyc            120ms  ← виновник
  │           └── [DB] UPDATE balance         12ms
  └── [Response] 200 OK                     2ms

Jaeger — UI для просмотра трейсов. Показывает «водопад» (waterfall): каждый Span — горизонтальная полоса, длина = duration. Вы сразу видите, какой вызов занял больше всего. В Jaeger вы:

  • Ищете по trace_id (из лога) → видите полный путь запроса.
  • Находите самый длинный Span → это «узкое место».
  • Раскрываете attributes Span-а → видите SQL-запрос или URL.

Без Jaeger отладка распределённой системы = SSH на 5 серверов и grep логов. С Jaeger — один клик.

6.3. Распределённая трассировка и Outbox

Трейс рождается на Edge (API Gateway) и передается через HTTP-заголовки (traceparent). Критично: При прохождении через Kafka трейс обрывается. Чтобы этого избежать, trace_id должен сохраняться в отдельной колонке таблицы outbox_events (не внутри JSON payload), а воркер Outbox должен явно копировать его в заголовки Kafka-сообщения. Консьюмер на той стороне восстанавливает контекст из заголовков.

go
// В Outbox-воркере: trace_id из колонки → Kafka headers
func (w *OutboxWorker) publish(ctx context.Context, ev OutboxEvent) error {
    msg := &kafka.Message{
        Topic: "payment.events",
        Key:   []byte(ev.AggregateID),
        Value: ev.Payload,
        Headers: []kafka.Header{
            {Key: "traceparent", Value: []byte(ev.TraceID)}, // сквозной трейс
        },
    }
    return w.producer.WriteMessages(ctx, msg)
}

// В консьюмере: восстанавливаем контекст из headers
func (c *Consumer) handle(ctx context.Context, msg kafka.Message) error {
    traceparent := extractHeader(msg.Headers, "traceparent")
    ctx = otel.GetTextMapPropagator().Extract(ctx, propagation.HeaderCarrier{
        "traceparent": []string{traceparent},
    })
    ctx, span := otel.Tracer("payment-worker").Start(ctx, "ProcessPayment")
    defer span.End()
    // ... бизнес-логика
}

6.4. Взрыв кардинальности (Cardinality Explosion)

Архитектор не должен класть в метки Prometheus user_id, order_id или email. Каждая новая комбинация лейблов создает новую временную серию (time series). 1 миллион пользователей = 1 миллион серий = OOM-kill Prometheus.

Правило: Лейблы — только для статусов (HTTP status, error_type, service).

go
// Только статусы и сервис — низкая кардинальность
var paymentsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
    Name: "payments_total",
}, []string{"status", "service"})

// в обработчике:
paymentsTotal.WithLabelValues("success", "kyc").Inc()

6.5. SLO и Error Budget

SLI (Indicator): Метрика (например, доля успешных HTTP 200 ответов).

SLO (Objective): Цель (например, 99.9% запросов успешны за скользящее окно 1 час).

Error Budget: Допустимый лимит ошибок (0.1%). Алерты должны срабатывать при сжигании > 80% бюджета за 4 часа, а не при каждой ошибке.

6.6. Этот паттерн в архетипах

Observability проявляется по-разному в четырёх архетипах проектов:

АрхетипКлючевой трейсБизнес-метрикаПочему критично
Финтех (P2P)Перевод: API → Saga → KYC → БДsaga_compensation_total, circuit_breaker_stateКомпенсации — бизнес-решения, не HTTP 500
Git-хостингPush: Smart HTTP → Outbox → Webhookoutbox_pending_count, git_semaphore_inuseВебхуки теряются тихо, CI не запускается
МессенджерСообщение: Send → Deliver → Readdelivery_latency_p99, offline_queue_sizeОтправлено, но не доставлено
Лента / чтениеЗапрос: API → Кэш → БД → CDNcache_hit_ratio, stale_serve_totalЛавина на БД при протухшем кэше

Общий принцип: три кита (Logs, Metrics, Traces) + SLO + Error Budget. Различается только бизнес-метрика и критичность инцидента.

6.7. Компромиссы (Trade-offs)

ПодходПлюсМинусКогда выбирать
Auto-instrumentation OTelминимум кода; все HTTP/DB/span автоматическиoverhead на каждый запрос (span creation); меньше контролястарт, быстрый первый шаг
Manual spans (SDK)точный контроль: создавать span только для значимых операцийбольше кода; можно забыть spanоптимизация overhead
Логи + метрики без трейсовпроще стекнет корреляции: «что именно тормозит»антипаттерн для распределённой системы
SLO без Error Budgetдежурные алертят при каждой ошибкеalert-fatigue, инвалидация доверия к алертамантипаттерн

Вывод для курса: auto-instrumentation для старта → manual для hotspot; SLO + Error Budget — обязательно. См. ADR-6.


🛠 Практические задания

Примерный план работы:

  1. Распределённый трейсинг (OpenTelemetry) → trace_id в outbox_events, сквозной трейс от API до Worker через Kafka headers.
  2. Метрики Prometheus → payments_total{status,service}, circuit_breaker_state, outbox_pending_count (низкая кардинальность).
  3. Структурированные логи JSON → level, trace_id, span_id, tenant_id (корреляция с Jaeger).
  4. SLO-алертинг → CB Open > 1 мин, Error Budget > 80% / 4 ч, compensation > 5 / 10 мин.

Задача 1: Распределенная трассировка (OpenTelemetry)

Внедрить сквозной трейсинг от API до БД и Kafka.

Go: В консьюмере Kafka восстановить контекст из заголовков, создать span и закрыть его в defer.

Outbox (Модуль 3): Добавить колонку trace_id в таблицу outbox_events. При поллинге извлекать его и класть в kafka.Message.Headers.

✅ Готово, когда:

  • Колонка trace_id в outbox_events; при поллинге кладётся в Kafka headers
  • Консьюмер Kafka восстанавливает контекст трейса из headers
  • В Jaeger видно единое дерево трейса от API → Worker → БД → Kafka
  • ADR-6 «Auto-instrumentation vs manual» написан

Задача 2: Метрики Prometheus (Связь с Модулями 3-5)

Выставить endpoint /metrics.

Безопасный вариант (низкая кардинальность):

go
// Только статусы и сервис — низкая кардинальность
var paymentsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
    Name: "payments_total",
}, []string{"status", "service"})

// в обработчике:
paymentsTotal.WithLabelValues("success", "kyc").Inc()

Собирать метрики: saga_compensation_total{state="failed"}, circuit_breaker_state{service="kyc", state="open"}, outbox_pending_count.

✅ Готово, когда:

  • Endpoint /metrics в формате Prometheus exposition
  • Низкая кардинальность: payments_total{status,service} — без user_id как label
  • Бизнес-метрики: saga_compensation_total, circuit_breaker_state, outbox_pending_count

Задача 3: Структурированные логи (Loki)

Настроить логирование в JSON формате (slog в Go). Обязательная схема:

json
{
  "level": "error",
  "span_id": "a1b2c3",
  "trace_id": "x1y2z3",
  "tenant_id": "t123",
  "error_class": "compensation_failure",
  "message": "Compensation failed for saga_id=s123"
}

Go (slog):

go
// Структурированный лог с trace_id и error_class
slog.Error("Compensation failed",
    slog.String("trace_id", span.SpanContext().TraceID().String()),
    slog.String("span_id", span.SpanContext().SpanID().String()),
    slog.String("tenant_id", tenantID),
    slog.String("error_class", "compensation_failure"),
    slog.String("saga_id", sagaID),
)

✅ Готово, когда:

  • Логи в JSON-формате (slog)
  • Обязательные поля: level, span_id, trace_id, tenant_id, error_class
  • Лог коррелируется с Jaeger-трейсом через trace_id + span_id

Задача 4: SLO-Алертинг

Настроить алерт в Alertmanager на бизнес-события:

  • "Circuit Breaker находится в Open > 1 минуты".
  • "Количество saga_compensation_total за последние 10 минут превысило 5".
  • "Сожжено > 80% Error Budget за 4 часа".

✅ Готово, когда:

  • Алерт: circuit_breaker_state == open длится > 1 минуты
  • Алерт: saga_compensation_total за 10 минут > 5
  • Алерт: сожжено > 80% Error Budget за 4 часа
  • Все алерты маршрутизированы в Alertmanager

⚠️ Антипаттерн для разбора

Студенту дается код логирования/метрик для анализа.

go
// Go (Антипаттерн)
slog.Info("User made payment",
    slog.String("user_id", user.ID),
    slog.String("email", user.Email), // ❌ Утечка PII
    slog.Float64("amount", amount),
)

// ❌ Метрика с высокой кардинальностью (user_id как label)
paymentsPerUser.WithLabelValues(user.ID).Inc()

Вопросы студенту:

  1. Чем опасен лог email пользователя (помимо GDPR)?
  2. Что произойдет с Prometheus, если эту метрику будут дергать 100 000 разных user_id в минуту?
  3. Чего не хватает в логе для сквозной отладки (корреляции с Jaeger)?

📐 Задание по визуализации (Mermaid)

Студенту необходимо нарисовать схему передачи trace_id через синхронные HTTP-вызовы и асинхронные (Kafka).

Шаблон для ориентира:

Проброс trace_id через HTTP и Kafka (Outbox)

🧩 Проверь себя

1. Как Observability проявляется в Git-хостинге?
2. SLO 99.9% за 30 дней — Error Budget = ?
3. SLO-алерт должен срабатывать при...
4. Error Budget при SLO 99.9% — это…
5. Почему user_id в labels Prometheus — антипаттерн?
6. Почему trace_id «обрывается» при прохождении сообщения через Kafka?

🤖 Инструкция для LLM (Coding Plan Assistant) — Режим Модуля 6

Привет! Я начинаю Модуль 6 курса «Архитектура распределённых систем».

Дай короткий ответ (2–3 предложения): почему trace_id обрывается при прохождении сообщения через Kafka, и как нужно изменить Outbox (из Модуля 3), чтобы трейс восстанавливался на стороне консьюмера?

Объясни риск "взрыва кардинальности" в Prometheus. Почему нельзя использовать user_id или order_id в качестве label? Покажи, как правильно группировать метрики.

Покажи мне код Антипаттерна (логирование с email и метрика с user_id) и попроси меня объяснить все последствия для Observability.

Дай описание задания: внедрить OpenTelemetry (с пробросом через Kafka), настроить метрики Prometheus для Saga и CB, создать SLO-алерт и структурированные логи с error_class. Обязательно добавь требование по хранению trace_id в Outbox (отдельная колонка) и его переносу в Kafka headers.

НЕ ПИШИ за меня реализацию OTel-инструментации. Дождись моего ответа с архитектурным планом. После этого дай фидбек по чек-листу ментора и укажи на слепые зоны (например, «ты теряешь trace_context в Kafka», «метрики протекают», «нет error_class в логах»).


📝 Чек‑лист ментора

  • Логи: Структурированы (JSON). Содержат trace_id, span_id, tenant_id и error_class.

  • Trace Context Propagation: trace_id успешно пробрасывается через HTTP, PostgreSQL и Kafka. В Outbox есть отдельная колонка trace_id.

  • Go Spans: В Go-консьюмере trace_context восстанавливается из Kafka headers, span создаётся и корректно закрывается в defer.

  • Метрики Prometheus: Нет высокой кардинальности (нет user_id в labels). Используются только status, error_type, service.

  • Бизнес-метрики: Собираются метрики для Outbox (pending_count), Saga (compensation_total), CB (state).

  • Jaeger: Видно единое дерево вызовов от API до воркера.

  • SLO/Алертинг: SLO имеет явное окно времени (1 час). Алерты настроены на исчерпание Error Budget (80% за 4 часа) или критические бизнес-события (CB Open > 1 мин).

  • Mermaid-диаграмма: Наглядно показывает точки сериализации/десериализации trace_id.

  • Студент понимает, как Observability проявляется в разных архетипах (финтех, Git, мессенджер, лента).

  • ADR-6: Student сформулировал ADR «Auto-instrumentation vs manual spans» (контекст, альтернативы, решение, overhead-обоснование).

🔗 Мостик к Модулю 7 (Безопасность)

В конце ревью скажи студенту:

  • «Отлично, теперь мы видим всю систему как на ладони: трейсы, метрики, логи. Но кто имеет право дёргать наше API? Как защитить Saga от подделки команд? Если злоумышленник перехватит HTTP-запрос с Idempotency-Key и отправит его повторно (Replay-атака), наша система из Модуля 2 послушно спишет деньги второй раз. В Модуле 7 мы закроем вопросы IAM, Keycloak, HMAC-подписей и ротации секретов, чтобы сделать систему не только наблюдаемой, но и безопасной. Мы также посмотрим, как безопасность влияет на Observability: какие данные можно логировать, а какие — нет.»

Если ты прошёл курс «Git Hosting: Serverless MVP», ты уже видел этот паттерн на упрощённом стеке: Cloud Monitoring вместо Prometheus, Jaeger через OTLP, cost-алерты на free-tier лимиты. Паттерн тот же — инфраструктура разная. Здесь мы разбираем его на полном стеке, с глубиной и trade-offs.

Как это выглядит на живом сервисе — «Мультипоточность в Go», модуль 5 и практика алертов на стенде — «Архитектурный полигон», модуль 6: там правило на симптом включается и проверяется машинно.