Опирается на правила: R-OBS-HC-1, R-OBS-HC-2, R-OBS-HC-3, R-OBS-HC-X1, R-OBS-HC-X2, R-OBS-HC-X3 из Observability Style Guide → раздел 4. Health checks.

Важно знать

  • Liveness и readiness — две разные probe с разной семантикой и разными зависимостями.
  • /health/live — UP пока процесс жив. Зависит только от самого процесса — внешних систем нет.
  • /health/ready — UP когда сервис готов принимать трафик: БД подключена, зависимости прогреты.
  • Custom-checker с TTL-кешем для внешних систем — без кеша Kubernetes probe дудосит зависимости.
  • /info отдаёт version, commit, build_time — чтобы знать, что реально крутится в проде.
  • Health — техническое состояние процесса, не бизнес. pendingOrders > N → DOWN — антипаттерн.
  • Liveness не зависит от DB: при лаге Postgres K8s пересоздаст pod и попадёт в restart-loop.
  • Management-сервер — отдельный порт от бизнес-сервера (R-OBS-CFG-1).

Health-probe — сигнал для Kubernetes и load balancer: пускать трафик в pod или нет. Неверная реализация даёт cascading failure: временный лаг БД → liveness DOWN → K8s убивает pod → новый pod стартует, та же БД лагает → loop, сервис за минуту полностью недоступен.

Liveness vs Readiness — разделение семантик

R-OBS-HC-1: два отдельных endpoint на management-сервере. В Go Actuator-а нет — реализуем руками, что даёт полный контроль.

// internal/platform/metrics/server.go
func StartManagement(addr string, ready Checker) *http.Server {
    mux := http.NewServeMux()
    mux.Handle("/metrics", promhttp.Handler())
    mux.HandleFunc("/health/live", liveHandler)
    mux.Handle("/health/ready", &readyHandler{check: ready})
    mux.HandleFunc("/info", infoHandler(buildVersion, buildCommit, buildTime))
    return &http.Server{Addr: addr, Handler: mux}
}

func liveHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write([]byte(`{"status":"UP"}`))
}

type readyHandler struct {
    check Checker
}

func (h *readyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    if err := h.check.Check(r.Context()); err != nil {
        w.WriteHeader(http.StatusServiceUnavailable)
        _ = json.NewEncoder(w).Encode(map[string]string{
            "status": "DOWN",
            "reason": "dependency unavailable",
        })
        return
    }
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write([]byte(`{"status":"UP"}`))
}
EndpointЧто проверяетРеакция Kubernetes
/health/liveПроцесс жив, не deadlockedDOWN → рестарт pod
/health/readyБД доступна, зависимости прогретыDOWN → снять из Service endpoints

Сценарий: Postgres лагает на 30 секунд из-за VACUUM FULL. Readiness → DOWN, трафик уходит на другие реплики. Liveness остаётся UP — рестарт ничего не изменит, БД та же. Без этого разделения K8s убивает все pods, сервис недоступен полностью.

Kubernetes-манифест:

spec:
  containers:
    - name: order-service
      livenessProbe:
        httpGet:
          path: /health/live
          port: 9090
        initialDelaySeconds: 10
        periodSeconds: 10
        failureThreshold: 3
      readinessProbe:
        httpGet:
          path: /health/ready
          port: 9090
        initialDelaySeconds: 5
        periodSeconds: 5
        failureThreshold: 2

Порт 9090 — management-порт (R-OBS-CFG-1), не бизнес 8080. Ограничивается сетевой политикой — внешние клиенты не видят /metrics и /health.

Custom-checker с TTL-кешем

R-OBS-HC-2: для каждой критичной внешней системы — отдельный Checker. Без TTL-кеша: K8s проверяет readiness каждые 5 секунд, 10 реплик → 20 ping/s на Postgres только от health-probe.

Интерфейс:

// internal/platform/health/checker.go
type Checker interface {
    Check(ctx context.Context) error
}

Реализация для pgxpool.Pool:

// internal/platform/health/postgres.go
type pgChecker struct {
    db     *pgxpool.Pool
    mu     sync.Mutex
    lastOK time.Time
    ttl    time.Duration
}

func NewPostgresChecker(db *pgxpool.Pool, ttl time.Duration) Checker {
    return &pgChecker{db: db, ttl: ttl}
}

func (c *pgChecker) Check(ctx context.Context) error {
    c.mu.Lock()
    defer c.mu.Unlock()
    if time.Since(c.lastOK) < c.ttl {
        return nil
    }
    if err := c.db.Ping(ctx); err != nil {
        return fmt.Errorf("postgres ping: %w", err)
    }
    c.lastOK = time.Now()
    return nil
}

Несколько зависимостей — composite-checker:

// internal/platform/health/composite.go
type compositeChecker struct {
    checkers []Checker
}

func NewComposite(checkers ...Checker) Checker {
    return &compositeChecker{checkers: checkers}
}

func (c *compositeChecker) Check(ctx context.Context) error {
    for _, ch := range c.checkers {
        if err := ch.Check(ctx); err != nil {
            return err
        }
    }
    return nil
}

Сборка в main.go:

pgChecker := health.NewPostgresChecker(pgPool, 10*time.Second)
redisChecker := health.NewRedisChecker(redisClient, 10*time.Second)
ready := health.NewComposite(pgChecker, redisChecker)

managementSrv := platform.StartManagement(cfg.ManagementAddr, ready)

Для сервиса customer-service с зависимостью на внешний SberPay API — checker делает легкий HEAD /ping с timeout 2s, не платёжную операцию.

/info с версией и коммитом

R-OBS-HC-3: при инциденте первый вопрос — «какая версия сейчас в проде». /info отвечает сразу.

// internal/platform/metrics/server.go
func infoHandler(version, commit, buildTime string) http.HandlerFunc {
    body, _ := 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(body)
    }
}

Значения передаются через -ldflags при сборке:

# Makefile
BUILD_VERSION := $(shell git describe --tags --always)
BUILD_COMMIT  := $(shell git rev-parse --short HEAD)
BUILD_TIME    := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)

build:
    go build \
      -ldflags="-X main.buildVersion=$(BUILD_VERSION) \
                -X main.buildCommit=$(BUILD_COMMIT) \
                -X main.buildTime=$(BUILD_TIME)" \
      ./cmd/order-service

В main.go:

var (
    buildVersion = "dev"
    buildCommit  = "unknown"
    buildTime    = "unknown"
)

Результат — /info отдаёт:

{
  "version": "v1.4.2",
  "commit": "5380f21",
  "build_time": "2026-05-25T22:25:30Z"
}

Сборка management-сервера в main.go

Полный пример с errgroup — бизнес и management работают параллельно, shutdown скоординирован:

// cmd/order-service/main.go
func run(ctx context.Context, cfg Config) error {
    pgPool, err := pgxpool.New(ctx, cfg.DatabaseURL)
    if err != nil {
        return fmt.Errorf("pgxpool: %w", err)
    }
    defer pgPool.Close()

    ready := health.NewComposite(
        health.NewPostgresChecker(pgPool, 10*time.Second),
    )

    router := buildRouter(cfg, pgPool)
    businessSrv := &http.Server{
        Addr:    cfg.Addr,
        Handler: otelhttp.NewHandler(router, "order-service"),
    }
    managementSrv := platform.StartManagement(cfg.ManagementAddr, ready)

    g, gCtx := errgroup.WithContext(ctx)
    g.Go(func() error {
        if err := businessSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            return fmt.Errorf("business server: %w", err)
        }
        return nil
    })
    g.Go(func() error {
        if err := managementSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            return fmt.Errorf("management server: %w", err)
        }
        return nil
    })
    g.Go(func() error {
        <-gCtx.Done()
        _ = businessSrv.Shutdown(context.Background())
        _ = managementSrv.Shutdown(context.Background())
        return nil
    })
    return g.Wait()
}

При SIGTERM: readiness переходит в DOWN (новые запросы не поступают), in-flight запросы дообрабатываются за ShutdownTimeout, процесс завершается.

Что запрещено

АнтипаттернПравилоЧто взамен
Business-state в readiness (pendingOrders > N)R-OBS-HC-X1техническое состояние; бизнес → SLO и Prometheus
liveness проверяет DB / Redis / внешний APIR-OBS-HC-X2только readiness зависит от внешних систем
Probe вызывает бизнес-операцию (INSERT INTO product_checks)R-OBS-HC-X3lightweight: db.Ping() или TTL-кешированный результат
Checker без TTL-кеша при periodSeconds 5R-OBS-HC-2TTL 5–30 секунд, реализация через sync.Mutex + time.Time
/health/* на бизнес-порту без сетевой защитыR-OBS-CFG-1отдельный management-порт, сетевая политика
Нет /info — версия неизвестна в продеR-OBS-HC-3-ldflags с version/commit/build_time

Куда дальше

  • Конфигурация — management-порт, APP_ENV, slog.LevelVar, pprof без auth.
  • Context propagation — RequestID и Auth middleware; ctx в горутинах.
  • Logging — slog.NewJSONHandler, структурные поля, ошибки атрибутом.
  • Metrics — promauto, RED-middleware на chi, бизнес-метрики.
  • SLO и алерты — SLO через RED-гистограммы, multi-window burn-rate.
  • Tracing — otelhttp, otelpgx, defer span.End(), sampling.