Когда сервис запущен в продакшне, нужно следить за тем, что он делает: читать логи, собирать метрики, проверять состояние. Всё это называется observability — наблюдаемость. В этой статье разберём, как правильно её настроить в Go-сервисе: куда вешать /metrics и /health, как формат логов зависит от окружения, и почему гистограмма с неправильными границами врёт вам про SLO.
Зачем нужен отдельный management-порт
Представьте: у вас один порт :8080 для всего. Prometheus каждые 15 секунд стучится за метриками, Kubernetes каждые 5 секунд проверяет /health — и всё это в том же потоке, что и реальные запросы пользователей.
Проблема двойная. Первая — нагрузка на пул обработчиков бизнес-сервера. Вторая — безопасность: /pprof (профилировщик памяти) не должен быть доступен с того же адреса, откуда приходит публичный трафик.
Решение: два http.Server в одном процессе.
// cmd/server/main.go
func run(ctx context.Context, cfg Config) error {
router := buildRouter(deps)
businessSrv := &http.Server{
Addr: cfg.Addr, // :8080 — для внешнего трафика
Handler: otelhttp.NewHandler(router, cfg.ServiceName),
}
managementSrv := platform.StartManagement(cfg.ManagementAddr, deps) // :8081
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error { return businessSrv.ListenAndServe() })
g.Go(func() error { return managementSrv.ListenAndServe() })
g.Go(func() error {
<-gCtx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = businessSrv.Shutdown(shutCtx)
_ = managementSrv.Shutdown(shutCtx)
return nil
})
return g.Wait()
}
errgroup запускает оба сервера параллельно и корректно останавливает их при сигнале завершения.
Management-сервер монтирует ровно четыре маршрута:
// internal/platform/metrics/server.go
func StartManagement(addr string, deps Deps) *http.Server {
mux := http.NewServeMux()
mux.Handle("GET /metrics", promhttp.Handler())
mux.HandleFunc("GET /health/live", liveHandler)
mux.Handle("GET /health/ready", deps.ReadyHandler)
mux.HandleFunc("GET /info", infoHandler(deps.Version, deps.Commit, deps.BuildTime))
mux.HandleFunc("PUT /log-level", logLevelHandler(deps.LogLevel))
return &http.Server{Addr: addr, Handler: mux}
}
В Kubernetes network policy разрешает Prometheus подключаться на 8081, а Ingress публикует только 8080. Scraping-трафик и health-пробы не попадают в пул обработчиков бизнес-сервера.
Важное правило: /pprof на management-порт в продакшне не монтировать без mTLS или сетевой политики. Профили памяти раскрывают внутренние данные сервиса.
Формат логов по APP_ENV
В локальной разработке хочется читаемые логи. В продакшне — структурированный JSON, который Loki или Datadog могут распарсить без регулярных выражений.
Один switch на переменной окружения решает это:
// internal/platform/log/setup.go
func New(env string, level *slog.LevelVar) *slog.Logger {
opts := &slog.HandlerOptions{Level: level}
if env == "production" {
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}
return slog.New(slog.NewTextHandler(os.Stdout, opts))
}
Локально (APP_ENV=dev) вывод выглядит так:
10:42:03.211 INFO order_confirmed order_id=ORD-9912 customer_id=C-441
В продакшне (APP_ENV=production) — JSON с трейс-идентификатором:
{"time":"2026-06-19T10:42:03.211Z","level":"INFO","msg":"order_confirmed","order_id":"ORD-9912","customer_id":"C-441","trace_id":"4b3e..."}
Логгер создаётся один раз при старте и передаётся в хендлеры через конструктор — slog.SetDefault(...) в пакетах не используется. Это позволяет в тестах передать любой логгер, не трогая глобальное состояние.
Динамический уровень логов
Частая ситуация в продакшне: что-то идёт не так, но в логах уровня INFO видно только симптомы. Хочется переключиться на DEBUG — и снова на INFO, когда разобрались. Без перезапуска сервиса.
slog.LevelVar — это мьютекс-защищённая переменная уровня. Её можно изменить в любой момент, и все последующие сообщения будут фильтроваться по новому порогу:
// cmd/server/main.go
var logLevel slog.LevelVar // по умолчанию 0 = INFO
log := platform.New(cfg.AppEnv, &logLevel)
Management endpoint принимает PUT-запрос и меняет уровень:
// internal/platform/metrics/server.go
func logLevelHandler(level *slog.LevelVar) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct{ Level string `json:"level"` }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var l slog.Level
if err := l.UnmarshalText([]byte(body.Level)); err != nil {
http.Error(w, "unknown level", http.StatusBadRequest)
return
}
level.Set(l)
w.WriteHeader(http.StatusNoContent)
}
}
Переключение: curl -X PUT :8081/log-level -d '{"level":"DEBUG"}'.
Histogram buckets и SLO
Prometheus измеряет латентность гистограммой: каждый запрос попадает в один из заранее определённых bucket'ов (корзин). По этим корзинам потом считается процентиль — например, p99.
Проблема с prometheus.DefBuckets — он задаёт универсальные границы от 5ms до 10s. Если у вас SLO «p99 < 500ms», граница на 0.5 в стандартных bucket'ах есть, но соседние — 0.25 и 1.0. Интерполяция даст неточный результат. Нужно задать bucket'ы явно с границей ровно на SLO-пороге:
// internal/platform/metrics/http.go
var httpRequestDurationSeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request latency",
Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0},
}, []string{"method", "path", "status_class"})
Bucket 0.5 даёт точный histogram_quantile(0.99, ...) без интерполяции для SLO «p99 < 500ms».
Стандартные метки service, env, version добавляются один раз через MustCurryWith — не в каждом вызове .WithLabelValues(...):
// internal/platform/metrics/common.go
func CommonLabels(cfg Config) prometheus.Labels {
return prometheus.Labels{
"service": cfg.ServiceName,
"env": cfg.AppEnv,
"version": cfg.Version,
}
}
// internal/order/metrics.go
type OrderMetrics struct {
created *prometheus.CounterVec
}
func NewOrderMetrics(cfg platform.Config) *OrderMetrics {
return &OrderMetrics{
created: ordersCreatedTotal.MustCurryWith(platform.CommonLabels(cfg)),
}
}
func (m *OrderMetrics) OrderCreated(paymentMethod string) {
m.created.WithLabelValues(paymentMethod).Inc()
}
Cardinality: почему path в метрике не должен быть raw URL
Если записать в метку path значение вроде /orders/ORD-9912, Prometheus создаст отдельную time series для каждого заказа. Тысяча заказов — тысяча time series. Это называется взрывом кардинальности, и это убивает Prometheus.
Правильно: в метке хранить шаблон маршрута — /orders/{orderID}. Chi предоставляет его через RouteContext:
// internal/platform/middleware/metrics.go
func Metrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
routeCtx := chi.RouteContext(r.Context())
path := "unknown"
if routeCtx != nil && routeCtx.RoutePattern() != "" {
path = routeCtx.RoutePattern()
}
status := statusClass(ww.Status())
httpRequestsTotal.WithLabelValues(r.Method, path, status).Inc()
httpRequestDurationSeconds.WithLabelValues(r.Method, path, status).
Observe(time.Since(start).Seconds())
})
}
chi.RouteContext(r.Context()).RoutePattern() возвращает /orders/{orderID} — фиксированное число time series независимо от числа заказов.
Та же логика применима к user_id, order_id и другим идентификаторам: они не должны быть значениями меток. Для отслеживания конкретного запроса — traces (атрибуты OTel span), не метрики.
Информация о сборке через /info
Полезный эндпоинт: при проблемах в проде сразу видно, какая версия сервиса запущена.
Значения инжектируются на этапе сборки через -ldflags:
// cmd/server/version.go
var (
version = "dev"
commit = "none"
buildTime = "unknown"
)
# Makefile
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME)"
go build $(LDFLAGS) -o bin/order-service ./cmd/server
func infoHandler(version, commit, buildTime string) http.HandlerFunc {
payload, _ := json.Marshal(map[string]string{
"version": version,
"commit": commit,
"build_time": buildTime,
})
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(payload)
}
}
Коротко
- Два
http.Serverв одном процессе: бизнес-трафик на:8080, management (/metrics,/health/*,/info) на:8081. errgroupзапускает оба сервера и корректно завершает при сигнале.APP_ENV=production→slog.NewJSONHandler, иначеslog.NewTextHandler.slog.LevelVarпозволяет менять уровень логов через PUT-запрос без перезапуска.- Histogram buckets задаются при регистрации с явной границей на SLO-пороге, а не через
prometheus.DefBuckets. - Стандартные метки
service/env/version— один раз черезMustCurryWith, не в каждом вызове. - В метке
path— шаблон маршрута (/orders/{orderID}), не raw URL — иначе взрыв кардинальности. /pprofв продакшне только за mTLS или network policy.
Что почитать дальше
- Context propagation —
RequestID-middleware, порядок в chi-цепочке, ctx в горутинах. - Health checks —
liveHandler, readiness с TTL-кешем, pgx ping. - Logging — структурные поля, OTel-slog bridge, маскировка PII.
- Metrics — RED-middleware, бизнес-счётчики,
GoCollector. - Tracing — OTel setup,
otelhttp,otelpgx,defer span.End().