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.