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

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