В этом уроке разберем по косточкам три типа проб - liveness, readiness и startup. Это базовый kubernetes health check, и при этом тема, где ломают себе прод чаще всего: достаточно поставить агрессивный kubernetes liveness, и кластер начнет крушить совершенно здоровые поды под нагрузкой. Цель урока - чтобы ты понимал не только синтаксис, но и механику: кто проверяет, когда, что происходит при провале, и как пробы связаны с rolling update и graceful shutdown.
Кто и как проверяет: механика kubelet
Первое, что надо выбить из головы: пробы выполняет не control plane, не api-server, а kubelet - агент на той самой ноде, где живет под. Это важно. Kubelet локально, без обращения по сети к мастерам, дергает твой контейнер по расписанию и принимает решение. Поэтому проба - это не мониторинг снаружи, а внутренний healthcheck с точки зрения ноды.
Каждая проба - это периодический вызов. Kubelet раз в periodSeconds выполняет проверку, ждет ответа до timeoutSeconds, и считает результат успехом или провалом. Дальше включается счетчик: чтобы признать пробу проваленной, нужно failureThreshold подряд неудач; чтобы признать восстановившейся - successThreshold подряд успехов (для liveness и startup successThreshold всегда 1, его менять нельзя).
Есть четыре способа, как именно kubelet щупает контейнер:
- httpGet - GET-запрос на путь и порт. Успех - это код ответа от 200 до 399 включительно. Все, что 400+, или таймаут, или отказ соединения - провал. Самый частый и правильный выбор для веб-сервисов.
- tcpSocket - просто пытается открыть TCP-соединение на порт. Открылось - успех. Подходит для всего, что не HTTP: базы, брокеры, gRPC без health-сервиса.
- exec - запускает команду внутри контейнера. Exit code 0 - успех, любой другой - провал. Гибко, но дорого: каждый вызов это форк процесса внутри контейнера, на высокой частоте exec-пробы заметно грузят ноду.
- grpc - нативная gRPC-проба (стабильна с давних версий, по умолчанию включена с 1.24). Дергает стандартный gRPC Health Checking Protocol на указанном порту. До нее приходилось тащить в образ бинарь grpc_health_probe и звать его через exec - теперь это родное поле.

Liveness: когда нужен перезапуск, а когда это вредительство
Liveness-проба отвечает на один вопрос: жив ли контейнер или его надо перезапустить. Если liveness проваливается failureThreshold раз подряд, kubelet убивает контейнер (не под целиком, а именно этот контейнер) и рестартит его согласно restartPolicy. Счетчик рестартов ты видишь в колонке RESTARTS у kubectl get pods.
Смысл liveness - вытащить процесс из состояния, из которого он сам не выберется: дедлок, утечка дескрипторов, зависший event loop. Перезапуск как удар по системнику, когда комп завис.
И вот тут - главная граната всего урока. Агрессивный liveness рестартит здоровый под. Сценарий из жизни: ты поставил liveness на тяжелый эндпоинт /health, который внутри ходит в базу и в Redis. Прилетела нагрузка, база подтормаживает, /health начинает отвечать за 3 секунды, а у тебя timeoutSeconds: 1. Проба падает, под рестартится, нагрузка перераспределяется на оставшиеся поды, у них /health тоже начинает тупить, они тоже рестартятся - каскад. Ты своими руками устроил отказ из-за health check там, где сервис бы просто продышался.
Правила безопасного liveness:
- Liveness-эндпоинт должен быть дешевым и локальным. Он проверяет "жив ли мой процесс", а не "здорова ли вся система". Никаких походов в БД, кэш, соседние сервисы. В идеале это легкий /healthz, который просто отвечает 200, если event loop крутится.
- Ставь щадящие пороги. failureThreshold 3 и periodSeconds 10 дают 30 секунд на провал перед рестартом - этого хватает, чтобы не реагировать на случайный всплеск.
- Если контейнеру в принципе нечем зависнуть так, чтобы помог только рестарт - liveness можно вообще не ставить. Отсутствие liveness - это нормально, а не пробел.
Связка liveness readiness - то, что путают чаще всего, поэтому разведем жестко. Liveness - "перезапустить?", readiness - "пускать ли трафик?". Readiness probe не рестартит ничего. Если она проваливается, kubelet помечает под как not ready, и эндпоинт-контроллер убирает его из EndpointSlice соответствующего Service. Под живой, крутится, но Service перестает слать на него запросы. Как только readiness снова зеленая - под возвращается в балансировку.
Зачем это нужно:
- Прогрев на старте. Приложение поднялось, но еще грузит конфиги, прогревает JIT, наполняет кэш, открывает пул коннектов к БД. Пока не готово - readiness красная, трафик не идет, пользователь не ловит ошибки "из-за того что под только проснулся".
- Временная недоступность зависимости. Отвалилась база - readiness может уйти в красное, под выпадет из балансировки, но не рестартнется. Восстановилась база - под сам вернулся. Тут readiness как раз может и должна щупать зависимости, в отличие от liveness.
- Защита при перегрузке. Под на пределе по нагрузке - временно говорит "я не ready", балансировщик его щадит.
И зеркальный антипаттерн: liveness, проверяющий внешнюю зависимость. Отвалился Redis - и если на нем висит liveness, у тебя разом рестартятся все поды сервиса. Внешние зависимости - это про readiness, никогда про liveness.
Startup: защита медленного старта от liveness
Третья проба родилась из конкретной боли. Допустим, у тебя легаси на Java или монолит, который стартует 90 секунд. Чтобы liveness не убил его при нормальном старте, наивно увеличивают initialDelaySeconds до 120. Но это палка о двух концах: ты выставил огромную задержку для нормального liveness уже работающего пода, и если он зависнет через час, kubelet будет полминуты-минуту тупить, прежде чем заметить.
Startup-проба решает это чисто. Пока она не прошла хотя бы раз, liveness и readiness вообще не запускаются - они заморожены. Это дает медленному приложению столько времени, сколько нужно, не размазывая задержки на боевые пробы.
Окно стартапа считается просто: failureThreshold умножить на periodSeconds. Поставил failureThreshold: 30 и periodSeconds: 10 - получил до 300 секунд на запуск. Не уложился - kubelet убивает контейнер как не стартовавший. А как только startup отдала первый успех - она больше не вызывается, и в дело вступают liveness с readiness уже с боевыми, агрессивными порогами.
Параметры: разбираем поля по одному
Код: Выделить всё
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
replicas: 3
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
image: registry.example.ru/orders-api:1.8.2
ports:
- containerPort: 8080
startupProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 30
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
successThreshold: 1
- initialDelaySeconds - сколько ждать после старта контейнера до первой проверки. Если есть startupProbe, для liveness/readiness это поле почти не нужно - стартап и так их прикроет.
- periodSeconds - интервал между проверками. По умолчанию 10.
- timeoutSeconds - сколько ждать ответа. По умолчанию 1 - и это коварно мало для реальных сервисов под нагрузкой. Часто корень "почему под рестартится без причины" именно тут.
- failureThreshold - сколько провалов подряд до вердикта. Для liveness 3 - разумный минимум.
- successThreshold - сколько успехов подряд для восстановления. Для liveness и startup только 1, менять можно лишь у readiness.
Связь с rolling update и graceful shutdown
Пробы - не изолированная игрушка, они вшиты в жизненный цикл деплоя.
Rolling update. При обновлении Deployment поднимает новый под и ждет, пока его readiness станет зеленой, прежде чем гасить старый (это регулируется maxUnavailable и maxSurge). Если readiness настроена честно - пользователь не заметит выкатку: новый под начнет получать трафик строго после прогрева. Если readiness нет или она фиктивная (просто возвращает 200 сразу) - Deployment сочтет под готовым мгновенно, пошлет на него трафик до прогрева, и ты получишь всплеск ошибок на каждом релизе. Readiness - это то, что делает rolling update бесшовным.
Graceful shutdown. Когда под удаляют, происходит вот что, и порядок тут важен. Kubelet почти одновременно: убирает под из EndpointSlice (новый трафик перестает приходить) и шлет процессу SIGTERM. Дальше дается terminationGracePeriodSeconds (по умолчанию 30) на корректное завершение - дослать ответы текущим клиентам, закрыть коннекты. Не уложился - прилетает SIGKILL.
Тонкость, на которой спотыкаются: удаление из EndpointSlice и доставка SIGTERM - асинхронны, между ними есть гонка. Поэтому грамотные приложения на SIGTERM не падают сразу, а сначала переводят readiness в красное и держат сервер открытым еще пару секунд - чтобы добрать запросы, которые балансировщик успел отправить до того, как узнал об удалении пода. Связка readiness + preStop-хук со sleep тут спасает от пятисоток на каждом масштабировании вниз.
Кстати, начиная с версии 1.25 у каждой пробы можно задать свой terminationGracePeriodSeconds - например, дать liveness-провалу убивать контейнер быстро, не дожидаясь общего льготного периода пода. Если задан и на поде, и на пробе - kubelet берет значение с пробы.
Практика: смотрим, как пробы валят и поднимают под
Накатим деплой и посмотрим события. Самое полезное при отладке проб - kubectl describe, секция Events:
Код: Выделить всё
$ kubectl describe pod orders-api-7d9f8c6b54-xk2lp
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 62s default-scheduler Successfully assigned default/orders-api-7d9f8c6b54-xk2lp to node-2
Normal Pulled 60s kubelet Container image "orders-api:1.8.2" already present on machine
Normal Created 60s kubelet Created container app
Normal Started 60s kubelet Started container app
Warning Unhealthy 8s (x3 over 28s) kubelet Liveness probe failed: Get "http://10.244.1.7:8080/healthz": context deadline exceeded
Normal Killing 8s kubelet Container app failed liveness probe, will be restarted
Текущую готовность видно сразу в get pods - колонка READY показывает "готовых контейнеров из всех":
Код: Выделить всё
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
orders-api-7d9f8c6b54-xk2lp 0/1 Running 2 (12s ago) 3m
orders-api-7d9f8c6b54-jh4mn 1/1 Running 0 3m
Проверить, реально ли под в балансировке, можно через EndpointSlice - туда попадают только ready-поды:
Код: Выделить всё
$ kubectl get endpointslices -l kubernetes.io/service-name=orders-api
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
orders-api-abcde IPv4 8080 10.244.2.4 5m
Грабли и антипаттерны - сводка
- timeoutSeconds: 1 по умолчанию. Самая частая причина ложных рестартов. Под нагрузкой даже легкий эндпоинт может не уложиться. Ставь 2-3 осознанно.
- Тяжелый liveness-эндпоинт. Liveness, который ходит в БД, превращает тормоза зависимости в каскад рестартов. Liveness - только локальная проверка процесса.
- Liveness на внешнюю зависимость. Отвалился Redis - рестартнулся весь сервис. Зависимости - в readiness.
- Readiness без liveness на виснущем сервисе. Завис - выпал из трафика навсегда, никто не перезапустит. Нужна пара.
- Раздувание initialDelaySeconds вместо startupProbe. Для медленного старта используй startup, а не калечь задержку боевого liveness.
- Фиктивная readiness (мгновенный 200). Ломает бесшовность rolling update - трафик идет до прогрева.
- Возьми любой образ с HTTP, например свой сервис или nginx. Создай Deployment с liveness httpGet на путь, которого нет (например /nope), с timeoutSeconds: 1 и failureThreshold: 3.
- Накати: kubectl apply -f deploy.yaml. Подожди и смотри kubectl get pods -w - увидишь, как RESTARTS растет, под в CrashLoopBackOff.
- Сделай kubectl describe pod на этот под, найди в Events строки Unhealthy и Killing. Прочитай Message - это твой диагноз.
- Поправь путь liveness на реальный (/ для nginx), добавь readinessProbe и startupProbe. Передеплой. Убедись, что под стал 1/1 и RESTARTS замер на нуле.
- Проверь kubectl get endpointslices - адрес здорового пода должен появиться в ENDPOINTS.
- Бонус: временно сломай readiness (несуществующий путь) при рабочем liveness - под станет 0/1 без рестартов и исчезнет из EndpointSlice. Это наглядно показывает разницу между двумя пробами.
- Что произойдет с подом, если провалится liveness-проба, и что - если провалится readiness? В чем принципиальная разница последствий?
- Почему опасно вешать liveness на эндпоинт, который внутри ходит в базу данных? Опиши каскад.
- Зачем нужна startup-проба, если можно просто увеличить initialDelaySeconds у liveness? Что именно она дает медленному приложению?
- Как readiness-проба связана с бесшовностью rolling update и с graceful shutdown пода?
Три пробы - три разных вопроса к контейнеру. Liveness спрашивает "тебя перезапустить?" и должна быть дешевой и локальной. Readiness спрашивает "пускать ли трафик?", не рестартит, управляет попаданием в EndpointSlice и делает выкатки бесшовными. Startup прикрывает медленный старт, замораживая две первые пробы, пока приложение не поднялось. Главные грабли - агрессивный liveness и тяжелые эндпоинты: они превращают временный тормоз в самоподдерживающийся отказ. Настроишь пробы с холодной головой - и ночные дежурства станут заметно скучнее, чего тебе и желаю.