Liveness, readiness, startup probes в Kubernetes: типичные ошибки и как настроить правильно
Probes — это та часть k8s, где даже опытные команды регулярно ловят пятёрки и шестёрки. Liveness убивает живой pod в момент пика нагрузки, readiness не отдаёт трафик после рестарта секунд тридцать, startup вообще не настроен и приложение с долгим стартом крутится в CrashLoopBackOff. Разберу, что чем отличается, как они на самом деле работают и какие конфиги я ставлю в продакшене.
Версия — k8s 1.30. Примеры на YAML, рабочие, можно копировать.
Три probes, три разных задачи
В Kubernetes есть три типа probes, и их часто путают.
- livenessProbe — «приложение живое или зависло?». Если падает несколько раз подряд — kubelet перезапускает контейнер.
- readinessProbe — «можно ли слать трафик?». Если падает — pod выкидывается из endpoints сервиса, трафик не идёт. Контейнер не перезапускается.
- startupProbe — «приложение уже запустилось?». Пока эта probe не прошла, liveness и readiness не выполняются. После успеха startup отключается навсегда.
Главная путаница: liveness != «приложение работает», а «приложение зависло так, что только рестарт поможет». Если liveness падает, потому что приложение долго стартует — ты получишь бесконечный рестарт. Если оно зависло из-за внешней БД, и ты убиваешь pod — БД от этого не оживёт, ты только усугубишь шторм.
Типичные ошибки
1. Liveness и readiness ходят на один и тот же эндпоинт
Самый частый антипаттерн:
livenessProbe:
httpGet:
path: /health
port: 8080
readinessProbe:
httpGet:
path: /health
port: 8080А внутри /health приложение ходит в БД и Redis. Падает Redis на 5 секунд — все pods вылетают с liveness fail, kubelet их рестартует, новые pods долго стартуют, БД получает шторм коннектов от перезапускающихся клиентов, и вот ты уже в ночной инциденте.
Правильно: разнести healthchecks по смыслу.
livenessProbe:
httpGet:
path: /healthz # просто "процесс жив"
port: 8080
readinessProbe:
httpGet:
path: /readyz # "готов принимать трафик"
port: 8080В /healthz — только проверка, что процесс живой и event loop крутится. Без походов в БД, без проверок зависимостей. Возвращаешь 200 — и всё.
В /readyz — можно проверить, что connection pool к БД жив, что нужные кеши прогреты. Если БД недоступна, pod уходит из endpoints и не получает трафик, но не убивается.
2. Нет startupProbe для медленных приложений
Java-приложения, которые стартуют 60 секунд, или Python с миграциями БД на старте — классика.
Без startupProbe ты вынужден ставить initialDelaySeconds: 90 на liveness, чтобы он не убил приложение раньше времени. Но потом, в работе, тебе хочется, чтобы liveness реагировал быстро — за 5-10 секунд. С одним initialDelaySeconds так не сделать.
startupProbe решает это:
startupProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 30
periodSeconds: 10
# суммарно даёт 300 секунд на старт
livenessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10
failureThreshold: 3
# после старта — 30 секунд на отвалПока startup не прошёл успешно — liveness/readiness не запускаются. Прошёл — и дальше живёт обычная liveness с быстрой реакцией.
3. Слишком жёсткий failureThreshold
По умолчанию failureThreshold: 3 и periodSeconds: 10. То есть после 30 секунд непрерывных факапов pod рестартует. На сетевых glitch-ах это бывает мало, особенно если сервис ходит в БД.
Я обычно ставлю failureThreshold: 5 для liveness — это 50 секунд запаса, достаточно, чтобы не падать на коротких сетевых пиках, но мало, чтобы реально зависший pod не висел вечно.
4. exec-probe, который форкает тяжёлый процесс
Видел такое:
livenessProbe:
exec:
command: ["/bin/sh", "-c", "curl -f http://localhost:8080/health"]Каждые 10 секунд kubelet поднимает shell, потом curl. На 200 pods на ноде это уже заметная нагрузка на CPU. Используй httpGet или tcpSocket, exec — только если реально нет HTTP-эндпоинта.
5. tcpSocket вместо httpGet
Простой tcpSocket просто открывает соединение и закрывает. Если приложение приняло коннект, но потом висит — probe всё равно зелёная.
Я ставлю tcpSocket только для приложений без HTTP (например, gRPC до того, как появилась grpc-проба нативно). С k8s 1.24+ есть grpc: probe — пользуйся ей для gRPC-сервисов.
Графовый dependency check в readyz — отдельная тема
В /readyz мы решаем «готов ли pod к трафику?». Но что считать готовностью?
Жёсткое правило: проверяй только то, без чего pod вообще не может ответить ни на один запрос. БД — да, если все эндпоинты ходят в БД. Redis — нет, если есть fallback на in-memory cache.
Иначе ты ловишь cascade failure: упал Redis → все pods показывают readyz=false → endpoints пустые → клиенты получают 503 → клиенты ретраят → нагрузка на сервис, который мог бы хотя бы отдавать кеш, выросла в 10 раз.
Лучше отдавать 503 на конкретные эндпоинты, которым нужен Redis, и 200 на остальные. Pod остаётся в endpoints, частичная функциональность работает.
Готовый пример из прода
Так я конфигурирую API-сервис на Go в e-commerce. Стартует 2-3 секунды, миграций БД на старте нет (отдельный init-job).
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 4
template:
spec:
containers:
- name: api
image: registry.example.com/api:v1.5.0
ports:
- containerPort: 8080
name: http
startupProbe:
httpGet:
path: /healthz
port: http
failureThreshold: 12
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 5
readinessProbe:
httpGet:
path: /readyz
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
successThreshold: 1
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
memory: 512MiВ коде сервиса:
// /healthz — просто "процесс крутится"
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// /readyz — проверяем зависимости
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
http.Error(w, "db down", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})Обрати внимание на timeoutSeconds: 2 в pробах — без явного таймаута дефолт 1 секунда, что иногда мало.
Graceful shutdown и preStop
Probes не закроют тебе все вопросы рестарта. Когда pod останавливается, k8s одновременно посылает SIGTERM контейнеру и убирает его из endpoints. Но обновление endpoints происходит асинхронно через kube-proxy, ingress-controller, и до них доходит за 1-3 секунды. Всё это время pod может получать новые запросы, а его процесс уже завершается.
Лечится preStop hook, который добавляет искусственную задержку:
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
terminationGracePeriodSeconds: 30Sleep 10 секунд — pod ждёт, пока endpoints обновятся, потом нормально завершается. terminationGracePeriodSeconds должна быть больше preStop + времени на graceful shutdown приложения.
Что запомнить
liveness — про зависание, readiness — про готовность к трафику, startup — про долгий старт. Не ходи в БД из liveness. Используй startupProbe для медленных приложений вместо больших initialDelaySeconds. Разнеси /healthz и /readyz по эндпоинтам, ставь явные timeouts. Добавь preStop hook для graceful shutdown.
Куда копать: официальная документация по probes, и обязательно — посты Tim Hockin про probe semantics. У него хорошо написано, почему именно три типа, а не два.