Архитектура бэкенда
Сигналы и grace period: SIGTERM, SIGKILL и PID 1
«Дай процессу сначала закончить» — славная идея, но кто говорит процессу начать заканчивать и сколько у него времени? Ответ — точный контракт между оркестратором и твоим контейнером, и ошибиться в нём — один из самых частых продовых сюрпризов. Команда добавляет чистый SIGTERM-обработчик, который дренирует до 45 секунд, — и обнаруживает, что их запросы всё равно обрываются на полпути на каждом деплое. Обработчик был верен; проблема в том, что сигнал до него не дошёл. Их контейнер стартовал приложение через шелл (sh -c "node server.js"), так что PID 1 был шеллом, шелл не пробросил SIGTERM, и после grace period оркестратор послал единственный сигнал, который никто не может поймать — SIGKILL — прямо сквозь вежливый обработчик, ждавший SIGTERM, которого ему не видать. Shutdown начинается с понимания, какие именно сигналы срабатывают, в каком порядке, какому процессу и против каких часов.
Последовательность завершения
Когда Kubernetes решает остановить под, он запускает фиксированную последовательность — и та же форма держится у большинства планировщиков:
- Под помечается Terminating и убирается из endpoints сервиса (про эту гонку — в следующем уроке).
- Запускается preStop hook, если ты его определил. Это команда или HTTP-вызов, выполняемый перед отправкой сигнала, и оркестратор блокируется на нём. Часто используется, чтобы поспать немного, давая маршрутизации стечь, или флипнуть флаг readiness.
- SIGTERM шлётся в PID 1 контейнера — вежливый сигнал «пожалуйста, остановись». Это момент, когда должен сработать твой обработчик shutdown.
- Оркестратор ждёт grace period —
terminationGracePeriodSeconds, по умолчанию 30 секунд в Kubernetes. Этот таймер покрывает preStop hook и пост-SIGTERM shutdown вместе. - Шлётся SIGKILL, если процесс ещё жив, когда таймер истёк. SIGKILL нельзя поймать, заблокировать или обработать — ядро уничтожает процесс немедленно.
Так что весь твой graceful shutdown — дренаж, закрытие, выход — должен уместиться в этот grace period, минус то, что уже съел preStop hook. Grace period — не предложение; это жёсткий дедлайн, обеспеченный непойманным сигналом.
SIGTERM против SIGKILL
Эти два сигнала — не два вкуса одного; они категорически разные. SIGTERM (сигнал 15) — это запрос: он прерывает процесс и запускает обработчик, который ты зарегистрировал, давая шанс дренировать и прибраться. SIGKILL (сигнал 9) — это команда ядру, а не твоему процессу: он никогда не доставляется твоему коду, так что нет обработчика, нет уборки, нет финального сброса. Вся игра в том, чтобы сделать уборку во время SIGTERM, чтобы SIGKILL вообще не сработал — если SIGKILL срабатывает, ты уже потерял работу в полёте, ровно как в случае резкого выхода из урока один.
Ловушка PID 1
Внутри контейнера первый запущенный процесс становится PID 1, и PID 1 особенный: это процесс, которому оркестратор шлёт сигнал, и ядро даёт ему необычную сигнальную семантику. Две ловушки следом:
- Сигналы идут только в PID 1. Если ты запускаешь приложение как дочерний процесс шелла —
sh -c "node server.js"или ничего не подозревающий entrypoint-скрипт — то шелл — это PID 1, SIGTERM доставляется шеллу, а многие шеллы не пробрасывают его детям. Твоё приложение никогда не видит сигнал, ничего не дренирует и получает SIGKILL на дедлайне. Фикс — сделать приложение PID 1 (exec-форма:CMD ["node", "server.js"], илиexec node server.jsв конце скрипта) или запустить крошечный init вродеtini, который пробрасывает сигналы и пожинает зомби. - У PID 1 нет дефолтных обработчиков сигналов. Обычно ядро ставит дефолтные действия (вроде «завершиться по SIGTERM»), но для PID 1 оно этого не делает. Если твоё приложение — PID 1, и ты забыл зарегистрировать обработчик, дефолт — игнорировать сигнал, так что процесс продолжает работать и, опять же, ест SIGKILL.
Почему это работает
Почему ядро обходится с PID 1 настолько иначе, что отсутствующий обработчик значит игнор сигнала, а не завершение процесса? PID 1 происходит от роли init в обычной Linux-системе — первого userspace-процесса, предка всего и того, кто отвечает за пожинание осиротевших детей и поддержание системы живой. Ядро намеренно его защищает: если бы PID 1 могли убить случайным дефолтным действием сигнала, вся система (или, в контейнере, весь контейнер) умерла бы по случайности, так что ядро не применяет обычные дефолтные диспозиции к PID 1. Сигнал без явно зарегистрированного обработчика просто отбрасывается. Это разумное правило безопасности для настоящей init-системы, но в контейнерах оно становится граблями, потому что твоё приложение — написанное в предположении, что оно обычный процесс, где SIGTERM по умолчанию «завершиться» — внезапно носит корону init, не зная того. Тот же код, что выключился бы нормально при обычном запуске, теперь как PID 1 полностью игнорирует SIGTERM. Вот почему существуют два канонических фикса: либо явно зарегистрировать обработчик SIGTERM, чтобы у PID 1 было что запустить, либо вставить настоящий init-процесс (tini, dumb-init или флаг платформы --init) как PID 1, чья вся работа — пробрасывать сигналы приложению и пожинать зомби. Глубже точка в том, что «послать SIGTERM и ждать» работает, только если SIGTERM реально доходит до кода, который его слушает, а контейнеризация молча меняет и то, доходит ли он, и то, что происходит, когда доходит.
| Шаг | Что срабатывает | Ловится? | Твоя возможность |
|---|---|---|---|
| Terminating | Под убран из endpoints | — | Маршрутизация начинает стекать |
| preStop hook | Команда / HTTP, блокирует | n/a | Поспать на распространение, флипнуть readiness |
| SIGTERM | Сигнал 15 в PID 1 | Да | Запустить обработчик: дренаж + закрытие |
| Grace period | terminationGracePeriodSeconds (30s) | — | Жёсткий дедлайн для всего выше |
| SIGKILL | Сигнал 9 через ядро | Нет | Никакой — процесс уничтожен, работа потеряна |
Команда добавляет верный 45-секундный SIGTERM-дренаж, но запросы всё равно обрываются на каждом деплое. Контейнер запускает приложение через sh -c 'node server.js'. В чём дело?
Почему вся работа по дренажу и уборке должна завершиться до истечения grace period?
Расставь последовательность завершения пода в Kubernetes:
- 1 Под помечается Terminating и убирается из endpoints сервиса
- 2 preStop hook отрабатывает до конца (если определён), блокируя следующий шаг
- 3 SIGTERM доставляется в PID 1; обработчик shutdown должен дренировать и прибраться
- 4 После terminationGracePeriodSeconds SIGKILL уничтожает процесс, если он ещё жив
- 01Какова последовательность завершения и чем различаются SIGTERM и SIGKILL?
- 02Что такое ловушка PID 1 и как её починить?
У окна, что обещал прошлый урок, есть точная форма. Kubernetes помечает под Terminating и убирает из endpoints, запускает блокирующий preStop hook, шлёт SIGTERM в PID 1, ждёт terminationGracePeriodSeconds (по умолчанию 30s, покрывая preStop плюс shutdown), затем шлёт SIGKILL, если процесс ещё жив. SIGTERM — ловимый запрос, запускающий твой обработчик; SIGKILL — непойманная команда ядру, уничтожающая процесс без уборки — так что grace period жёсткий дедлайн, и вся цель в том, чтобы закончить во время SIGTERM, чтобы SIGKILL не сработал. Печально известный провал — PID 1: сигналы доходят только до PID 1, так что приложение, запущенное под шеллом, никогда не видит SIGTERM, ведь шелл его глотает, и даже как PID 1 отсутствующий обработчик значит игнор сигнала, потому что ядро не даёт дефолтных диспозиций защищённому слоту init. Сделай приложение PID 1 через exec-форму или вставь крошечный init вроде tini, и зарегистрируй обработчик. С приходящим сигналом и понятыми часами следующий урок берётся за первое, с чем должен совладать обработчик — гонку между SIGTERM и балансировщиком, где трафик продолжает приходить после того, как тебе сказали остановиться.