Здоровье и жизненный цикл: честные пробы, окно grace при SIGTERM и preStop
Health-чек, пингующий порт, пока приложение зависло, хуже его отсутствия. Честное здоровье — это раздельные liveness, readiness и startup, а жизненный цикл SIGTERM → grace → SIGKILL с preStop-хуком — то, что заставляет раскат не терять ни одного запроса.
Все дашборды были зелёными, пока checkout API лежал. Каждый под рапортовал здоров, балансировщик продолжал маршрутизировать, а клиенты получали таймауты. Health-чек был TCP-пробой на слушающий порт: процесс Go был ещё жив и сокет ещё открыт, так что проба проходила — но каждый запрос блокировался на дедлоке пула соединений к базе, что сделала failover. Приложение было трупом с пульсом. Фиксом был не больший мониторинг, а честный health-чек. Readiness-пробу сменили на эндпоинт /readyz, реально гонявший 200 мс запрос к пулу, и он покраснел за один интервал. Балансировщик увёл под, трафик ушёл на здоровые реплики, и алерт on-call сработал на причину, а не на симптом. Health-чек, который не может упасть, когда приложение зависло, — не страховка, а зелёный свет, нарисованный поверх кирпичной стены.
Три пробы, три разных вопроса
Кардинальная ошибка — один health-чек, отвечающий «процесс поднят?», когда проду нужны три разных ответа. HEALTHCHECK Docker и пробы Kubernetes формализуют различие:
HEALTHCHECK --interval=10s --timeout=2s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8080/healthz || exit 1
# --interval: каждые 10с --retries=3: 3 подряд провала → unhealthy
# --start-period=30s: провалы в первые 30с не считаются (grace на медленный старт)- Liveness отвечает «процесс завис и неисправим?» — при провале оркестратор перезапускает контейнер. Сделай её дешёвой и без зависимостей: зависший event loop или дедлокнутый процесс должны её провалить, но кратковременный сбой базы — нет, иначе ты превратишь downstream-аварию в шторм перезапусков, делающий всё хуже.
- Readiness отвечает «должен ли этот инстанс прямо сейчас получать трафик?» — при провале балансировщик перестаёт маршрутизировать на него, но не перезапускает. Здесь проверяют реальные зависимости: 200 мс запрос к пулу соединений, как в Hook. Под, потерявший базу, должен стать NotReady (слить трафик), не став non-live (перезапуск не починит аварию базы).
- Startup отвечает «медленный старт закончился?» — она гейтит две другие во время инициализации, чтобы JVM, греющаяся 40 с, не была liveness-убита на 10-й секунде. Эквивалент Docker —
--start-period.
Опасный дефолт — проба, проходящая, пока приложение зависло: TCP-порт-чек или /healthz, возвращающий 200 из статичного обработчика, никогда не трогающего путь работы. Health-чек честен лишь если может покраснеть по той же причине, по которой упал бы запрос пользователя. Классический режим отказа работает и наоборот: liveness-проба, зовущая базу, превращает 30-секундную икоту базы в одновременный перезапуск каждого пода, стирая тёплые кэши и пулы ровно когда система и так под стрессом.
База сервиса делает failover на 20 секунд. Какая конфигурация проб справляется лучше?
Жизненный цикл выключения: SIGTERM, grace, SIGKILL, preStop
Правильно настроенные пробы — это только половина дела. Если последовательность выключения теряет запросы на выходе, дашборды снова покрасят стену в зелёный.
Раскат завершает старые поды, и то, как он это делает, решает, выживут ли запросы в полёте. Контракт — фиксированная последовательность. Решив остановить под, оркестратор (1) опционально запускает preStop-хук, затем (2) шлёт SIGTERM в PID 1, потом (3) ждёт до terminationGracePeriodSeconds (дефолт 30 с) и наконец (4) шлёт SIGKILL всему ещё работающему. Твоя задача — полностью слиться внутри окна grace, чтобы шаг 4 никогда не сработал на живом запросе.
lifecycle:
preStop:
exec: { command: ["sh", "-c", "sleep 5"] } # перекрыть гонку де-регистрации LB
terminationGracePeriodSeconds: 45 # > худшего слива (p99 + распространение LB)Тонкий баг — гонка, не имеющая отношения к твоему обработчику сигнала. В момент, когда под помечается Terminating, одновременно происходят две вещи: доставляется SIGTERM и эндпоинт убирается из Service. Но удаление эндпоинта распространяется асинхронно на каждый kube-proxy (агент Kubernetes, управляющий сетевыми правилами на узле) и балансировщик — секунду-две трафик ещё маршрутизируется на под, который уже начал выключаться. Если твой SIGTERM-обработчик сразу закрывает listener, эти запросы в пути бьются в закрытый сокет и получают connection-refused. Фикс — preStop-хук: короткий sleep (обычно 5–15 с), задерживающий SIGTERM, держа под полностью обслуживающим, пока распространяется де-регистрация, чтобы новый трафик не терялся. Затем приходит SIGTERM, обработчик гасит readiness (для надёжности), сливает запросы в полёте и выходит с 0 — всё с запасом внутри grace-периода, размеренного выше p99 плюс эта задержка распространения. Поставь grace слишком малым — SIGKILL рвёт живые соединения посреди ответа (клиент видит RST); забудь preStop — гонка LB теряет запросы даже при идеальном обработчике.
Твой SIGTERM-обработчик корректен — сливает запросы в полёте и выходит чисто — но пара запросов всё равно падает в начале каждого раската. Почему и что закрывает разрыв?
- 01Различи liveness, readiness и startup пробы по вопросу, на который отвечает каждая, и действию, которое запускает каждая — и дай режим отказа от размещения проверки базы не в той.
- 02Пройди последовательность выключения пода и объясни, почему корректный SIGTERM-обработчик всё равно теряет запросы без preStop-хука.
Продовое здоровье — это две раздельные дисциплины, которые команды рутинно схлопывают в одну. Первая — честное пробирование: health-чек обязан мочь покраснеть по той же причине, по которой упал бы реальный запрос, так что TCP-порт-чек или статичный обработчик 200, проходящий пока приложение зависло на мёртвой базе, бесполезен — он держал дашборды зелёными сквозь полную аварию. Раздели три вопроса, что реально задаёт оркестратор. Liveness спрашивает, завис ли процесс и неисправим ли, и перезапускает контейнер при провале, так что держи её дешёвой и без внешних зависимостей, иначе кратковременный сбой базы становится синхронным штормом перезапусков, стирающим тёплые кэши и пулы. Readiness спрашивает, должен ли этот инстанс брать трафик прямо сейчас, и лишь останавливает маршрутизацию балансировщика на него, так что здесь живут настоящие проверки зависимостей — реальный запрос к пулу. Startup гейтит две другие сквозь медленный старт (—start-period Docker), чтобы 40-секундный прогрев JVM не был liveness-убит преждевременно. Вторая дисциплина — жизненный цикл выключения: раскат запускает опциональный preStop-хук, шлёт SIGTERM в PID 1, ждёт до terminationGracePeriodSeconds (дефолт 30 с), затем SIGKILL’ит остаток. Размер grace выше худшего слива (p99 плюс распространение де-регистрации LB) не даёт SIGKILL порвать живое соединение и послать клиенту RST. А тонкую гонку — удаление эндпоинта распространяется асинхронно, пока доставляется SIGTERM, так что LB ещё маршрутизирует секунду-две — перекрывает короткий preStop sleep, задерживающий SIGTERM, пока де-регистрация приземлится, так что корректный обработчик сливает каждый запрос в полёте и в пути до нулевой потери. Теперь, когда ты видишь провалы запросов на границах раскатов, задай два вопроса, прежде чем добавлять ретраи: нет ли зависимости в liveness-пробе, и даёт ли preStop LB время на де-регистрацию до закрытия listener?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.