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

Kubernetes и балансировщики нагрузки постоянно спрашивают у вашего сервиса: «ты жив? можно слать запросы?». Если не ответить правильно — сервис либо перестаёт получать трафик, либо, наоборот, получает его тогда, когда не готов. Разберём, как сделать это правильно в Go.

Почему одного /health недостаточно

Представьте: Postgres лагает на 30 секунд из-за тяжёлой операции. Сервис живой, процесс работает нормально — просто база временно недоступна.

Если у вас один /health, который возвращает DOWN при проблемах с базой, Kubernetes видит DOWN → перезапускает pod → новый pod стартует → база всё ещё лагает → новый pod тоже DOWN → ещё рестарт. За минуту все поды в цикле перезапуска, сервис полностью недоступен. А проблема была в базе, а не в процессе.

Решение — два отдельных endpoint с разной семантикой:

EndpointЧто означаетРеакция Kubernetes
/health/liveПроцесс жив и не зависDOWN → рестарт pod
/health/readyСервис готов принимать трафикDOWN → убрать из балансировки

При лаге базы: readiness → DOWN (трафик уходит на другие реплики), liveness остаётся UP (рестарта нет). Когда база восстановится — readiness снова UP, трафик возвращается.

Liveness — только сам процесс

Liveness проверяет одно: процесс жив и не завис в дедлоке. Больше ничего. Никаких баз, никаких внешних API.

Реализация простая — всегда возвращает 200:

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

Частая ошибка — добавить в liveness проверку базы. Этого делать нельзя: при любом лаге базы Kubernetes начнёт перезапускать pod, хотя рестарт ситуацию не улучшит.

Readiness — готовность принимать трафик

Readiness проверяет, что все внешние зависимости доступны. Если база не отвечает или кеш недоступен — readiness возвращает 503, Kubernetes временно убирает pod из балансировщика.

Удобно сделать через интерфейс:

type Checker interface {
    Check(ctx context.Context) error
}

Тогда readiness-handler просто вызывает checker:

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"}`))
}

TTL-кеш для проверок зависимостей

Kubernetes проверяет readiness каждые 5 секунд. Если у вас 10 реплик — это 2 пинга в секунду на Postgres только от health-probe. Со временем это заметная нагрузка.

Решение: кешировать результат проверки на несколько секунд:

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
}

TTL 10 секунд — разумный баланс: при реальном сбое Kubernetes узнает в течение одной-двух проверок, а нагрузка на базу минимальная.

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

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)

/info — что сейчас в проде

При инциденте первый вопрос — «какая версия сейчас запущена?». Endpoint /info отвечает на это сразу:

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

Значения прошиваются в бинарник через флаги компилятора при сборке:

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"
)

Handler для /info готовит тело один раз при старте:

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)
    }
}

Management-сервер на отдельном порту

Health endpoints держат на отдельном порту (обычно 9090), не на бизнес-порту (8080). Это позволяет закрыть /metrics и /health от внешних клиентов сетевой политикой — Kubernetes видит их, а интернет нет.

Функция запуска management-сервера:

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}
}

Бизнес-сервер и management-сервер запускаются параллельно через errgroup:

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 — новые запросы перестают поступать, уже начатые запросы дообрабатываются, процесс завершается чисто.

Kubernetes-манифест для probe:

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

Частые ошибки

Бизнес-состояние в readiness. pendingOrders > 1000 → DOWN — неправильно. Health говорит о техническом состоянии процесса: база доступна, соединения установлены. Бизнес-метрики (очереди, задержки) — это SLO и Prometheus-алерты, не health.

Liveness проверяет базу или Redis. При любом сбое внешней системы Kubernetes начнёт перезапускать pod. Рестарт не поможет — база та же. Liveness должен зависеть только от самого процесса.

Probe вызывает бизнес-операцию. INSERT INTO product_checks в health-probe — плохая идея: 20 реплик × каждые 5 секунд = постоянная нагрузка на базу. Правильно: db.Ping() или TTL-кешированный результат.

Checker без TTL-кеша. Без кеша probe на каждую проверку делает реальный запрос к базе. С TTL 10 секунд нагрузка снижается в 10 раз.

Коротко

  • Два endpoint с разной семантикой: /health/live — процесс жив, /health/ready — готов к трафику.
  • Liveness зависит только от самого процесса — никаких баз и внешних API.
  • Readiness проверяет внешние зависимости, возвращает 503 если недоступны.
  • TTL-кеш в checker снижает нагрузку на зависимости от health-probe.
  • Composite-checker объединяет несколько зависимостей в одну readiness-проверку.
  • /info с version, commit, build_time — первая помощь при инциденте.
  • Management-сервер на отдельном порту: Kubernetes видит, внешние клиенты — нет.

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

  • Логирование в Go — slog, JSON и context.Context.
  • Метрики в Go — promauto, RED-middleware на chi, бизнес-метрики.
  • Трейсинг в Go — otelhttp, otelpgx, sampling.