Когда сервис работает в продакшене и что-то идёт не так — нужно понять: что случилось, с каким запросом, в какой момент. Без инструментов наблюдаемости это почти невозможно — остаётся только гадать по косвенным признакам.
Наблюдаемость держится на трёх опорах: логи (что происходило), трейсы (путь конкретного запроса), метрики (числа во времени). К ним добавляются 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 — как ошибки доходят до клиента и что логировать при этом.