Health checks: liveness, readiness и startup probes на практике
Когда первый раз настраиваешь health checks в Kubernetes, кажется, что разница между liveness и readiness — формальность. Оба возвращают 200, оба смотрят на «жив ли сервис». Через пару инцидентов разница становится наглядной: где-то под крутится в loop рестартов раз в минуту, где-то под считается готовым раньше времени и принимает трафик с пустым кешем, где-то ваш медленный startup приводит к тому, что Kubernetes убивает контейнер до того, как он успевает прогреться.
Расскажу, как я обычно настраиваю три типа пробников, что в них стоит и не стоит проверять, и какие ошибки регулярно встречаю в чужих проектах.
Три пробника и зачем каждый
Liveness probe
«Жив ли процесс или он завис настолько, что без перезапуска уже не оживёт?» Если возвращает не-200, kubelet перезапускает контейнер.
Используется для случаев, когда процесс зашёл в тупик: deadlock, бесконечный цикл, утечка дескрипторов до невосстановимого состояния. Не для проверки внешних зависимостей.
Readiness probe
«Готов ли я обслуживать трафик прямо сейчас?» Если не-200, под выводится из Endpoints, трафик не идёт. Перезапуска не происходит.
Используется для прогрева кеша, ожидания подключений к БД, временных проблем с зависимостями.
Startup probe
«Я ещё запускаюсь или уже стартанул?» Пока не стал успешным, liveness и readiness не проверяются. Появился в Kubernetes 1.16, спасает медленно стартующие приложения от рестартов.
Используется для приложений с долгим стартапом: Java-приложения, прогрев JIT, инициализация большого кеша.
Самая частая ошибка: «здоровье» как один эндпоинт
В половине проектов, что я видел, liveness и readiness указывают на один и тот же /health, который проверяет всё подряд: соединение с БД, кешем, очередями. Это превращает любой временный сбой зависимости в каскадный рестарт всех подов сервиса.
Сценарий: Postgres переключается на резервный мастер, на 30 секунд становится недоступен. Liveness возвращает 500 во всех подах. kubelet рестартует все контейнеры одновременно. Когда БД оживает, все поды стартуют заново и одновременно бьют по ней с переподключениями. Это не «high availability», это «фестиваль одновременной деградации».
Лекарство — разделить:
- Liveness проверяет только то, что может починить рестарт. Внутреннее состояние процесса. Не БД, не кеш, не внешние API.
- Readiness проверяет, готов ли под обслуживать запросы прямо сейчас. Может включать состояние зависимостей, но с осторожностью.
Что класть в liveness
Минималистичный вариант — просто отвечать 200, если процесс ещё может ответить:
http.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})Это работает на удивление хорошо. Если процесс живёт, accept-loop крутится, request handler вызывается — значит, в общих чертах в порядке.
Можно добавить маленькую внутреннюю проверку: «крутится ли мой главный worker». Это полезно, если в сервисе есть фоновая обработка, и она может застрять, оставив HTTP-ручки живыми.
var workerHeartbeat atomic.Int64
func(w http.ResponseWriter, r *http.Request) {
last := time.Unix(workerHeartbeat.Load(), 0)
if time.Since(last) > 30*time.Second {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}Воркер обновляет heartbeat в начале каждой итерации. Если 30 секунд тишины — что-то застряло, перезапуск.
Что класть в readiness
Тут осторожнее. С одной стороны, под, который не может пользоваться БД, не должен принимать трафик. С другой — массовый отвод подов из Endpoints из-за временного блипа БД может вызвать проблем больше, чем решит.
Я обычно делаю так:
- В readiness проверяю критические зависимости с коротким тайм-аутом и кешем результата.
- Если БД недоступна на момент стартапа — readiness false, не лезем под трафик.
- Если БД отвалилась на середине жизни — продолжаем отвечать 200 какое-то время, надеясь, что это блип.
- Если отвал длится больше 30–60 секунд — переключаемся в not-ready.
type DBProbe struct {
db *sql.DB
lastOk atomic.Int64
cacheTTL time.Duration
}
func (p *DBProbe) Ready(ctx context.Context) bool {
if time.Since(time.Unix(p.lastOk.Load(), 0)) < p.cacheTTL {
return true
}
cctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
if err := p.db.PingContext(cctx); err != nil {
return false
}
p.lastOk.Store(time.Now().Unix())
return true
}Тут readiness не молотит БД на каждом запросе пробника, а проверяет раз в секунду-две.
Startup probe для медленных стартов
Java-приложение со Spring Boot стартует у вас 60 секунд? Без startup probe вы окажетесь в ситуации:
- liveness probe настроен на ожидание 30 секунд после старта. Через 30 секунд начинает проверять.
- Приложение ещё не готово, отвечает 500.
- kubelet рестартует контейнер. Снова 30 секунд ожидания, снова 500, снова рестарт.
Это бесконечный цикл. Лекарство — startup probe с щедрым тайм-аутом:
startupProbe:
httpGet:
path: /startupz
port: 8080
failureThreshold: 30
periodSeconds: 10
livenessProbe:
httpGet:
path: /livez
port: 8080
periodSeconds: 10В этой конфигурации startup может ждать до 5 минут (30 × 10s). Когда startup стал успешным, начинают работать liveness и readiness, и уже строже.
Тайм-ауты и пороги
Параметры пробников — отдельная тема. Дефолты Kubernetes часто слишком оптимистичны.
- periodSeconds — как часто проверять. По умолчанию 10. Для liveness — нормально, для readiness иногда уменьшаю до 5.
- timeoutSeconds — сколько ждать ответ. По умолчанию 1. Для http-эндпоинта на ровном месте — мало; на нагруженной системе один лишний timeout превращается в false-negative. Я ставлю 2–3.
- failureThreshold — сколько подряд неуспешных, чтобы зафейлить. По умолчанию 3. Для liveness иногда 5, чтобы не рестартовать на коротких блипах.
- successThreshold — для readiness, сколько подряд успешных, чтобы вернуть. По умолчанию 1.
Главное — не копировать чужие значения, а понимать, что они означают. На быстро отвечающем сервисе timeout 1 — нормально. На сервисе с шагающим GC — катастрофа.
Health-эндпоинт vs probes
Иногда выделяют отдельный «человеческий» /health, который показывает развёрнутый статус: db: ok, kafka: ok, redis: degraded. Это полезно для дебага и для дашбордов. Но не для probes.
Для probes делайте отдельные минималистичные эндпоинты /livez и /readyz. Они должны быть быстрыми (миллисекунды), без сложных вычислений и без вызовов внешних систем без необходимости. Не путайте observability и health checks.
Probes и graceful shutdown
Связанные темы. При получении SIGTERM приложение должно сразу переключить readiness в not-ready, чтобы Kubernetes увёл трафик. Liveness в это время должен оставаться 200, иначе kubelet решит, что вы зависли, и пришлёт SIGKILL раньше времени.
var (
isReady atomic.Bool
isAlive atomic.Bool
)
func init() {
isReady.Store(true)
isAlive.Store(true)
}
// при SIGTERM
isReady.Store(false)
// дальше дорабатываем активные запросы, потом выходимМетрики на пробниках
Полезно вести счётчик, сколько раз каждый пробник возвращал не-200. Через Prometheus или просто счётчик в коде. Это помогает находить редкие сбои: «оказывается, readiness фэйлится раз в день на 30 секунд». Без метрик такие вещи проходят мимо.
Внешние зависимости в readiness — где граница
Самый дискуссионный вопрос. Если у вас сервис без БД работать не может, разумно делать readiness зависимым от БД. Если БД-зависимость опциональная (например, кеш), включать её в readiness — повод для каскадных проблем.
Моё правило: в readiness проверяем только то, без чего сервис никак. Зависимости второго эшелона — игнорируем. Сервис должен уметь отвечать ошибкой 5xx на конкретный запрос, который не может выполнить, не выходя из строя целиком.
Чек-лист
- Liveness и readiness — разные эндпоинты, не один.
- Liveness не проверяет внешние зависимости.
- Readiness проверяет только критичные, с тайм-аутом и кешем.
- Startup probe настроен для медленно стартующих приложений.
- Тайм-ауты и пороги выставлены под реальное поведение, не дефолтами.
- При SIGTERM readiness переключается в not-ready, liveness остаётся 200.
- Есть метрики «сколько раз пробник вернул не-200».
Что запомнить
Probes в Kubernetes — это интерфейс между приложением и оркестратором. Настройка по умолчанию даёт «вроде работает», но на инцидентах вы увидите разницу между «сервис умеет восстанавливаться» и «сервис уходит в каскадный рестарт». Несколько часов на правильную настройку трёх пробников окупаются первым же инцидентом, когда вы не разбудили команду в три ночи разбираться, почему приложение крутится в loop рестартов.