Перейти к содержанию

Observability

«Сервис тормозит» — это не диагноз. Observability — это способность ответить на вопрос «почему» без редеплоя и без printf. Три кита: метрики, логи, трейсы. Над ними — SLI/SLO/SLA и error budget.

Три столпа

🧩 Простыми словами.

  • Метрики — числа во времени. Сколько RPS, сколько ошибок, какая p99. Дёшевы по объёму, дороги по детализации.
  • Логи — текстовые события. Точные подробности конкретного запроса. Дёшевы по детализации, дороги по объёму.
  • Трейсы — путь одного запроса через несколько сервисов. Видишь, где потерялись 500ms.
graph LR
    Metrics[Метрики<br/>что-то не так,<br/>где-то тормозит] --> Traces
    Traces[Трейсы<br/>в каком сервисе<br/>и какой шаг] --> Logs
    Logs[Логи<br/>детали конкретного<br/>сбоя]

⚙️ Под капотом. Каждый из них даёт свой угол, и они дополняют друг друга. Метрики говорят «есть проблема», трейсы — «вот где», логи — «вот в чём».

RED-метрики (для сервисов)

Минимум, который должен быть на каждом API-сервисе.

🧩 R-E-D.

  • Rate — RPS, разбитые по эндпоинтам.
  • Errors — процент 5xx (и 4xx, если важно).
  • Duration — latency: p50 / p95 / p99.
graph TB
    R[Rate: req/s] --> SLO[Health dashboard]
    E[Errors: %] --> SLO
    D[Duration: p99 latency] --> SLO

🛠 В Go. Стандартный стек — Prometheus + prometheus/client_golang.

import "github.com/prometheus/client_golang/prometheus"

var (
    httpRequests = prometheus.NewCounterVec(
        prometheus.CounterOpts{Name: "http_requests_total"},
        []string{"method", "path", "status"})
    httpDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{Name: "http_request_duration_seconds",
            Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5}},
        []string{"method", "path"})
)

func metricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rw := &statusRecorder{ResponseWriter: w, status: 200}
        next.ServeHTTP(rw, r)
        path := normalizePath(r.URL.Path) // /users/123 → /users/:id
        httpRequests.WithLabelValues(r.Method, path,
            strconv.Itoa(rw.status)).Inc()
        httpDuration.WithLabelValues(r.Method, path).Observe(
            time.Since(start).Seconds())
    })
}

📌 Histogram, не gauge. Latency — это распределение. Хочешь p99 — нужен histogram. Один mean врёт.

📌 Карты лейблов взрывают cardinality. НЕ кладите user_id в labels — миллионы серий убьют Prometheus. Только нормализованные пути, методы, статусы.

USE-метрики (для инфраструктуры)

🧩 U-S-E.

  • Utilization — % используемого ресурса (CPU, memory, disk).
  • Saturation — насколько ресурс перегружен (queue length, run queue).
  • Errors — fail-каунт ресурса (disk errors, IO errors).

Применяется к серверам, дискам, БД-инстансам, очередям. Дополняет RED.

Структурированные логи

🧩 Простыми словами. Не «error: что-то пошло не так», а JSON с полями. Тогда можешь грепать request_id по всему кластеру и увидеть полную картину.

{
  "ts":"2026-04-30T10:23:01Z",
  "level":"error",
  "msg":"payment failed",
  "request_id":"abcd-1234",
  "user_id":42,
  "amount":1500,
  "downstream_err":"timeout"
}

🛠 В Go. Используй slog (стандартная с Go 1.21+) или zap. Никогдаfmt.Println.

import "log/slog"

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

logger.InfoContext(ctx, "payment processed",
    slog.String("user_id", userID),
    slog.Int64("amount", amount),
    slog.Duration("took", time.Since(start)),
)

Request-ID propagation

🧩 Простыми словами. На границе сервиса присваиваешь UUID запросу. Носишь его в context и пишешь в каждый лог. Передаёшь дальше в headers downstream.

func requestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        rid := r.Header.Get("X-Request-ID")
        if rid == "" {
            rid = uuid.NewString()
        }
        ctx := context.WithValue(r.Context(), "rid", rid)
        w.Header().Set("X-Request-ID", rid)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

→ когда в логах увидишь request_id=abcd-1234, ты можешь найти все записи во всех сервисах за этот запрос.

Куда складывать логи

  • Loki (Grafana) — дёшево, индекс по labels, full-text по содержимому.
  • Elasticsearch (ELK) — мощно, дорого.
  • CloudWatch / GCP Logging — managed.
  • Stdout → kubectl logs — для разработки. В проде гонять через агента (Vector, Promtail, Fluent Bit).

Distributed tracing

🧩 Простыми словами. Запрос идёт через 5 сервисов, в каждом по 2–3 шага. Trace — это «дерево» этих шагов с длительностями, нарисованное на одной картинке.

gantt
    title Trace: POST /order
    dateFormat X
    axisFormat %s ms

    section api-gateway
    auth check        :0, 5
    forward to orders :5, 10

    section orders
    validate          :10, 15
    save tx           :15, 80

    section payment
    charge call       :30, 75

    section emails
    queue notify      :80, 85

⚙️ Под капотом. Открытый стандарт — W3C Trace Context (header traceparent). Trace = uuid; spans внутри со своими uuid и parent_id. Реализации: OpenTelemetry (стандарт де-факто), Jaeger / Zipkin / Tempo / Datadog.

🛠 В Go. OpenTelemetry SDK.

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

tracer := otel.Tracer("orders-service")

ctx, span := tracer.Start(ctx, "Place order")
defer span.End()

span.SetAttributes(
    attribute.Int64("user.id", userID),
    attribute.Int("items.count", len(items)),
)

Главное: tracer носит trace-id через context.Context. Все downstream-вызовы автоматически (через otel-instrumentation для http/grpc) пробросят header.

📌 Sampling. Не логируй 100% trace'ов в проде — слишком много данных. Sample 1–10%, плюс 100% для error spans.

SLI / SLO / SLA / Error budget

🧩 Простыми словами.

  • SLI (Service Level Indicator) — что меряем. «99% запросов GET /feed отвечают за < 500ms».
  • SLO (Objective) — какую планку ставим себе. «99.9% за 30 дней».
  • SLA (Agreement) — что обещаем клиенту в контракте. Обычно SLA < SLO, чтобы был запас. «99.5%».
  • Error budget1 - SLO. За месяц 30 × 24 × 60 × (1 - 0.999) = 43 минуты. Это ваш бюджет на сбои.
graph LR
    SLI[SLI: что меряем<br/>p99 latency, error rate] --> SLO[SLO: цель<br/>99.9%]
    SLO --> Budget[Error budget:<br/>сколько можно лежать]
    Budget --> Decision{Бюджет потрачен?}
    Decision -->|Да| Stop[Стоп фичам, работа над reliability]
    Decision -->|Нет| Ship[Релизим дальше, рискуем]
    SLO --> SLA[SLA: контракт<br/>99.5% — обещаем меньше, чем целимся]

⚙️ Зачем нужен error budget. Это инструмент решений: если бюджет потрачен — команда фокусируется на надёжности, не на новых фичах. Если бюджет не выходит — можно пушить релизы быстрее, рисковать с экспериментами.

📌 «100% — не SLO». Он недостижим и убьёт скорость разработки.

Мониторинг и алертинг

graph LR
    Apps -->|metrics| Prom[Prometheus]
    Apps -->|logs| Loki[Loki]
    Apps -->|traces| Tempo[Tempo / Jaeger]
    Prom --> Graf[Grafana<br/>dashboard]
    Loki --> Graf
    Tempo --> Graf
    Prom --> AM[Alertmanager]
    AM --> PD[PagerDuty / Slack / Telegram]

📌 Алёрты на симптомы, не на причины.

❌ Плохой алёрт: «CPU > 80%». Часто — нормальная нагрузка. ✅ Хороший алёрт: «Error rate /api > 1% over 5 min» или «p99 latency > 1s».

📌 SLO-burn rate alert. Если за час сжечь 5% месячного бюджета — это скорость, при которой бюджет кончится за 20 часов. Алёрт.

🛠 Применение в Go-проекте: эталонный стек

# docker-compose / k8s
- prometheus:    /metrics → scrape
- grafana:       dashboards
- loki:          structured JSON logs
- promtail:      собирает логи из контейнеров
- tempo:         трейсы (OTel)
- otel-collector: принимает OTel и роутит в backends
- alertmanager:  alert routing
// main.go
import (
    "go.opentelemetry.io/otel"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

func main() {
    setupTracing(ctx) // OTel exporter → tempo
    setupLogging()    // slog JSON → stdout
    prometheus.MustRegister(httpRequests, httpDuration)

    mux := http.NewServeMux()
    mux.Handle("/metrics", promhttp.Handler())
    mux.Handle("/api/", instrumentHandler(apiHandler))
    http.ListenAndServe(":8080", mux)
}

🔥 Пример: дашборд «здоровья» сервиса

Top row (RED):
- RPS (last 5 min)
- Error rate (5xx %)
- p50 / p95 / p99 latency

Middle (USE):
- CPU per pod
- Memory per pod
- DB connection pool: in_use / idle / wait

Bottom (бизнес):
- Orders/min
- Payment success rate
- Conversion funnel

При инциденте смотришь сверху вниз: RED — нашёл аномалию → USE — нашёл ресурс → trace → log по request_id → готово.

❌ Типичные ошибки

  • Логи в plaintext, без request_id → debug по нескольким сервисам — пытка.
  • High-cardinality labels (user_id, email) → Prometheus падает.
  • Метрика, на которую никто не смотрит — мёртвая метрика. Удали.
  • Алёрт на CPU 80% → алёрт-усталость, реальные проблемы пропускаются.
  • 100% trace sampling в проде → дорого и не нужно.
  • Одно целевое значение SLO для всего сервиса → разные эндпоинты имеют разную природу.

🤖 Что спрашивает AI-ментор

  • Что такое RED-метрики и почему именно эти три?
  • Чем отличаются metrics, logs и traces? Когда какой инструмент использовать?
  • Что такое distributed tracing и зачем нужен trace_id в headers?
  • Объясни SLO и error budget на примере.
  • Зачем нужны structured logs?

📊 Уровни глубины

L1. Знаешь Prometheus. Используешь slog. Видел Grafana dashboard.

L2. Имплементировал RED-метрики, request_id propagation, structured logs. Понимаешь cardinality и почему user_id в labels — плохо.

L3. Настраивал OpenTelemetry в проде. Считал error budget, делал SLO-based alerting. Дебажил инциденты по traces. Знаешь tradeoff sampling rate vs cost. Видел chaos engineering и game day.

📝 Подумай

  1. Сервис тормозит, p99 latency скакнул с 100ms до 2s. Какой следующий шаг диагностики?
  2. У тебя SLO 99.9% доступности. За первые 10 дней месяца сервис лежал 30 минут. Сколько у тебя осталось error budget на оставшиеся 20 дней?
  3. Зачем нужен traceparent header при вызове downstream-сервиса?
Ответ
  1. Дашборд RED → видишь что упало (errors? latency?). Если latency — смотришь по эндпоинтам, потом по downstream. USE → видишь ли saturation (DB connections wait, CPU 100%). Trace последнего медленного запроса → где конкретный шаг затормозил. Логи по этому request_id → детали.
  2. Error budget 99.9% за месяц = 0.1% × 30 × 24 × 60 = 43 минуты. Потратил 30, осталось 13 минут на 20 дней. Бюджет почти кончился — стоп фичам, фокус на reliability.
  3. Это W3C Trace Context: содержит trace_id текущего запроса. Downstream сервис прочитает его и привяжет свои spans к тому же trace, чтобы в Jaeger/Tempo была единая картинка end-to-end.

Дальше: case-studies.md — собираем всё в готовые архитектуры.