← назад к разделу

Когда сервис работает в продакшене и что-то идёт не так — нужно понять: что случилось, с каким запросом, в какой момент. Без инструментов наблюдаемости это почти невозможно — остаётся только гадать по косвенным признакам.

Наблюдаемость держится на трёх опорах: логи (что происходило), трейсы (путь конкретного запроса), метрики (числа во времени). К ним добавляются health-checks — чтобы оркестратор знал, жив ли процесс и готов ли принимать трафик. В Go всё это собирается из стандартной библиотеки и нескольких зрелых пакетов — без отдельного фреймворка.

Структурные логи: slog

Обычный fmt.Println("user created: " + name) — это строка. В продакшене строки бесполезны: их нельзя искать по полю, нельзя агрегировать, нельзя автоматически разобрать.

Структурные логи — это записи вида «ключ: значение». В JSON-виде их легко принять в любой агрегатор (Loki, ELK, Datadog) и фильтровать: «покажи все строки с user_id=42 за последние 10 минут».

С Go 1.21 структурное логирование есть в стандартной библиотеке — log/slog:

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

slog.Info("product_created", "product_id", product.ID, "price", product.Price)
// → {"time":"...","level":"INFO","msg":"product_created","product_id":"abc","price":990}

Ключевое поле в каждой строке — идентификатор запроса (request_id). Middleware проставляет его в context при входе запроса, обработчики достают логгер с этим полем из контекста. По request_id строки одного запроса сшиваются в цепочку — без него логи выглядят как хаотичный поток.

Правило: лог — это событие с полями, не форматированная строка.

Трейсинг: OpenTelemetry

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

Трейс (trace) — это граф операций: пришёл HTTP-запрос, он обратился к базе, та вернула ответ, мы сделали запрос во внешний API. Каждая операция — это span с временными метками начала и конца. Вместе они показывают, где запрос провёл больше всего времени и что пошло не так.

Стандарт де-факто — OpenTelemetry. Для net/http есть готовая обёртка otelhttp, которая автоматически создаёт span на каждый входящий запрос:

import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

handler := otelhttp.NewHandler(router, "http")
http.ListenAndServe(":8080", handler)

Дальше инструментируют соединение с базой данных и исходящие HTTP-клиенты — чтобы трейс был сквозным. Трейсы отправляются в коллектор (Jaeger, Tempo) через экспортёр, который настраивается один раз отдельно от кода обработчиков.

request_id из логов сшивается с trace_id из трейса — вместе они отвечают на вопрос «что случилось с этим конкретным запросом».

Метрики: Prometheus

Если трейс показывает один конкретный запрос, то метрики показывают картину в целом: сколько запросов в секунду обрабатывает сервис, какой процент занимает время ответа больше 500 мс, сколько ошибок за последний час.

Стандарт для Go — Prometheus с библиотекой prometheus/client_golang. Prometheus сам забирает метрики, делая запрос на эндпоинт /metrics с заданным интервалом.

Подключить эндпоинт просто:

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

r.Handle("/metrics", promhttp.Handler())

Базовые метрики — количество и длительность HTTP-запросов — навешивают как middleware на роутер. Свои бизнес-счётчики объявляют через client_golang и инкрементируют в нужных местах:

var ordersCreated = prometheus.NewCounter(prometheus.CounterOpts{
    Name: "orders_created_total",
    Help: "Total number of created orders",
})

func init() {
    prometheus.MustRegister(ordersCreated)
}

// в коде обработчика:
ordersCreated.Inc()

Health-checks

Kubernetes периодически проверяет каждый под через два типа зондирований (probe), и путать их опасно.

Liveness probe — жив ли процесс? Если нет, Kubernetes перезапустит контейнер. Этот эндпоинт должен быть максимально простым и не зависеть ни от чего: база недоступна — это не причина говорить, что процесс мёртв. Перезапуск процесса чужую базу не починит.

Readiness probe — готов ли процесс принимать трафик? Если нет, Kubernetes временно исключит под из ротации, но не перезапустит. Вот здесь уместно проверить подключение к базе.

r.Get("/health/live", func(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
})

r.Get("/health/ready", func(w http.ResponseWriter, r *http.Request) {
    if err := pool.Ping(r.Context()); err != nil {
        http.Error(w, "not ready", http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
})

Типичная ошибка: liveness зависит от базы. Если база упала — Kubernetes начинает бесконечно перезапускать поды, хотя проблема не в них. Liveness — про себя, readiness — про готовность к работе.

Коротко

  • Структурные логи — пары ключ-значение в JSON, а не форматированные строки; с Go 1.21 это встроенный log/slog.
  • request_id в контексте сшивает строки одного запроса в единую цепочку.
  • Трейс показывает путь и время каждой операции внутри запроса; стандарт — OpenTelemetry, обёртка otelhttp для net/http.
  • Метрики — числа во времени для всего сервиса целиком; стандарт — Prometheus, эндпоинт /metrics из prometheus/client_golang.
  • Liveness — простая проверка «жив ли процесс», без внешних зависимостей; readiness — «готов ли принимать трафик», можно проверить базу.
  • Перезапуск контейнера не лечит внешние зависимости — не делай liveness зависимым от базы.

Что почитать дальше

  • Middleware и обработка запросов — как организовать цепочку middleware и пробросить request_id в контекст.
  • Context и отмена — как context работает и как его правильно передавать через весь вызов.
  • Ошибки и HTTP — как ошибки доходят до клиента и что логировать при этом.