Команда выкатывает новую версию сервиса поздним вечером, когда трафик щадящий. Сборка зелёная, тесты прошли, контейнер загружен в registry, Deployment честно катит новую ревизию. Через минуту на графике ошибок вырастает узкий штрих: часть пользователей получила 502 от балансировщика, часть увидела оборванный ответ, часть вообще ничего не заметила. Код в этой области не менялся, откат ничего не даёт, в логах видны запросы, которые пришли в под и не вернулись. Источник проблемы обычно лежит на два уровня ниже кода: в Dockerfile, скопированном из старого туториала, и в отсутствии обработчика SIGTERM в самом приложении. Ниже разбор того, как Kubernetes завершает контейнеры при обновлении, почему типовая связка с npm start прячет процесс от сигналов и как выглядит shutdown, после которого релизы перестают попадать в чат дежурных.
Как Kubernetes завершает под при rolling update и что видит процесс в контейнере
Механика обновления Deployment одинакова почти для всех сценариев. Новый ReplicaSet поднимает свежие поды, старые получают статус Terminating. Дальше кластер действует в строгом порядке: контроллер endpoints убирает адрес завершаемого пода из списка Service, kubelet запускает preStop хук, если он описан в манифесте, затем процессу с номером 1 в каждом контейнере отправляется SIGTERM, и только после истечения grace period приходит SIGKILL, который перехватить нельзя. Терпение кластера по умолчанию равно 30 секундам, задаётся полем terminationGracePeriodSeconds и отсчитывается с момента запуска preStop хука. Отдельный таймаут, progressDeadlineSeconds, по умолчанию равен 600 секундам: если за это время обновление не сходится, Deployment получает статус Progressing с причиной ProgressDeadlineExceeded. Это стандартный сигнал, что новые поды не проходят readiness из-за ошибки в образе или конфигурации, а старая версия всё ещё несёт трафик.
Для приложения это означает окно возможностей: закрыть слушающий сокет, дождаться завершения текущих запросов, вытолкнуть накопленные события в очереди, закрыть пул соединений с базой и выйти с нулевым кодом. Если обработчика сигнала нет, поведение определяется тем, кто именно оказался процессом с номером 1, и вот здесь начинается самое интересное. Команда kubectl describe pod честно покажет диагноз: Exit Code 143 означает завершение по SIGTERM, Exit Code 137 говорит о SIGKILL, которым кластер принудительно закрыл контейнер после истечения grace period. Второй вариант означает, что приложение так и не узнало, что его просят освободить место, и его остановили посреди запроса.
Почему npm start в Dockerfile прячет Node.js процесс от сигналов
Типичный образ из туториала выглядит примерно так:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD npm start
Последняя строка выглядит безобидно, но именно она ломает всё. Запись CMD npm start это shell-форма: Docker оборачивает команду в вызов /bin/sh -c, и первым процессом контейнера становится sh, за ним npm, и только третьим node. Сигнал SIGTERM приходит процессу с номером 1, то есть оболочке. Оболочка не пересылает сигнал дочерним процессам, npm тоже исторически не спешит делиться им с приложением. Итог предсказуем: node спокойно работает, пока не истекут 30 секунд, после чего SIGKILL завершает всё дерево процессов разом, оборвав текущие запросы на середине.
Первая очевидная правка, exec-форма CMD ["node", "server.js"], действительно делает node процессом с номером 1. И здесь разработчика ждёт второй сюрприз, о котором догадываются немногие. В Linux процесс с номером 1 особый: ядро не применяет к нему действия сигналов по умолчанию, если обработчик не установлен явно. Node.js в обычном режиме получает SIGTERM и завершается с кодом 128 + 15 = 143 благодаря встроенному поведению, Node.js в роли PID 1 без собственного слушателя события SIGTERM сигнал просто игнорирует. Кластер снова ждёт 30 секунд и снова применяет SIGKILL. Две противоположные ошибки приводят к одному и тому же результату. Отдельная обязанность первого процесса, забирать завершённые дочерние процессы, node тоже не выполняет, поэтому для приложений, порождающих подпроцессы, в образ ставят крошечный init вроде tini или используют флаг --init у Docker.
Локально разработчик этой проблемы не видит. Команда docker stop отправляет SIGTERM и ждёт 10 секунд, а не 30, локальный трафик состоит из одного браузера, и ощущение, что контейнер просто "немного долго останавливается", никого не настораживает. Разница всплывает только в кластере под реальным трафиком.
Graceful shutdown в коде Node.js сервиса от флага готовности до closeIdleConnections
Рабочий каркас shutdown для Express занимает около тридцати строк:
let ready = true;
app.use((req, res, next) => {
if (!ready) {
res.set("Connection", "close");
return res.status(503).json({ status: "draining" });
}
next();
});
app.get("/readyz", (req, res) => res.status(ready ? 200 : 503).end());
const server = app.listen(3000);
function shutdown() {
ready = false;
server.close(() => process.exit(0));
server.closeIdleConnections();
setTimeout(() => server.closeAllConnections(), 15000).unref();
setTimeout(() => process.exit(0), 25000);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
Логика по шагам. Флаг ready мгновенно роняет readiness проверку, и контроллер endpoints перестаёт направлять трафик в под. Ответ 503 с заголовком Connection close говорит балансировщику и клиенту две вещи: запрос стоит повторить на другом поде и соединение переиспользовать не нужно. Метод server.close перестаёт принимать новые соединения и сам ждёт, пока существующие закончатся, и вот здесь спрятан классический подвох: keep-alive соединения не закрываются, пока не истечёт таймаут простоя, по умолчанию 5 секунд в Node. Процесс зависает в цикле ожидания, который ни к чему не приводит. Методы closeIdleConnections и closeAllConnections, добавленные в Node 18.2 и в релизе 16.17, закрывают подвисшие соединения вручную: первый трогает только простаивающие, второй через таймер добирается и до упрямых долгих запросов. Финальный страховочный таймер с process.exit(0) гарантирует, что процесс не застрянет навсегда из-за одного забытого соединения.
Перед выходом закрывают и внешние ресурсы: пул соединений с базой через pool.end, открытые дескрипторы файлов, отправку накопленных метрик. Буферизованные логи сбрасывают явно, иначе последние секунды работы сервиса исчезают из системы наблюдения именно тогда, когда они нужнее всего. Порядок простой: сначала флаг готовности, потом дренаж соединений, в самом конце ресурсы и выход с нулевым кодом.
Гонка обновления endpoints и preStop хук в манифесте Deployment
Даже идеальный код в приложении не закрывает другую проблему: удаление пода из списка endpoints асинхронно. Контроллер обновляет EndpointSlices, отдельные модули маршрутизации трафика на каждом узле и контроллеры входящего трафика подтягивают изменения со своей задержкой. В этом окне новые запросы продолжают приезжать в под, который уже готовится к завершению. Если приложение закрывается мгновенно, такие запросы попадают в закрытый сокет и превращаются в 502 на балансировщике. Чем крупнее кластер, тем заметнее эффект: на паре узлов гонку можно не заметить, на сотне узлов она проявляется на каждом релизе. Похожая механика работает при Drain узла: kubelet по указанию контроллера вытесняет поды по очереди, соблюдая PodDisruptionBudget. Без бюджета с minAvailable 1 техобслуживание узла способно одновременно остановить все реплики сервиса, и тогда плавность shutdown перестаёт иметь значение, потому что отвечать становится некому.
Стандартное лекарство описывается прямо в манифесте:
containers:
- name: api
image: registry/api:1.4.2
readinessProbe:
httpGet:
path: /readyz
port: 3000
periodSeconds: 2
failureThreshold: 2
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
ports:
- containerPort: 3000
terminationGracePeriodSeconds: 40
Хук preStop исполняется до отправки SIGTERM, и время его работы входит в grace period. Приложение просто спит десять секунд и продолжает полноценно отвечать на запросы, а инфраструктура за это время догоняет список endpoints. Сигнал приходит уже в под, из которого не сыпется новый трафик, и дренаж проходит тихо. Grace period в этой схеме растягивают так, чтобы в него уместился и сон preStop, и время на доигрывание текущих запросов: 10 секунд на хук и запас на дренаж дают комфортные 40 секунд.
Реальные таймауты от grace period до лимитов балансировщика перед сервисом
Подбор чисел сводится к одному правилу: всё, что может длиться дольше grace period, обязательно проявится в самый неудобный момент. Числа берутся из метрик, а не из головы. Время обработки запроса берут по перцентилю P99, а не по среднему: среднее в 80 миллисекунд при хвосте в 4 секунды означает, что дренаж обязан длиться не меньше этих 4 секунд. К P99 добавляют длительность preStop хука и секунды запаса на сетевые задержки. Обратное ограничение действует со стороны балансировщика: его таймаут запроса должен быть меньше grace period, иначе он станет сбрасывать живые соединения раньше, чем кластер завершит под. Readiness проверку делают частой, periodSeconds 2 и failureThreshold 2 выводят под из ротации за 4 секунды после прихода SIGTERM. Liveness держат простой и не вешают на неё тяжёлые проверки базы: отказавший коннект к базе не должен перезапускать контейнер, который и так спокойно деградирует на уровне HTTP. Стартовую задержку liveness подбирают так, чтобы загрузка образа и инициализация приложения не отправили свежий под в CrashLoopBackOff.
Проценты rolling update тоже стоят внимания. По умолчанию Deployment держит maxSurge 25 и maxUnavailable 25: во время обновления кластер временно добавляет четверть реплик сверху и одновременно убирает четверть снизу. Для сервиса с жёсткими требованиями к пропускной способности maxUnavailable ставят в 0, и тогда старые поды не уходят, пока новые не прошли readiness.
Проверить связку можно и до продакшена. Тестовый контур, нагрузка от wrk или k6 и команда kubectl rollout restart дают честную картину того, сколько ошибок приносит один релиз. Хороший ориентир простой: ноль ошибок 5xx на плавном обновлении достижим на конфигурации из этого разбора, а пара сотен оборванных соединений за релиз это сигнал, что один из таймаутов подобран неверно. Команда kubectl rollout undo пригодится, если после обновления что-то пошло не так: она возвращает предыдущую ревизию за секунды, без пересборки образа.
Пошаговая последовательность доведения деплоя Node.js сервиса до тихих релизов
Проверенный порядок сборки деплоя, который проходит ревью с первого раза, выглядит так:
- Перевести CMD в exec-форму и убедиться, что процесс node получает SIGTERM напрямую, без посредников вроде sh или npm;
- Добавить слушатели SIGTERM и SIGINT, флаг готовности для readiness и защитные таймеры на случай зависших соединений;
- Описать readiness и liveness проверки с раздельными ролями и подобрать стартовые задержки под реальное время инициализации;
- Вписать preStop хук с паузой 10 секунд и расширить terminationGracePeriodSeconds до суммы этой паузы и хвоста P99 по времени запроса;
- Прогнать обновление под нагрузкой в тестовом контуре и сверить по метрикам количество ошибок 5xx на релиз до и после;
- Сверить таймауты балансировщика и вышестоящих шлюзов с grace period, чтобы ни один компонент не рвал соединения раньше кластера.
Когда упрощённый образ без обработки сигналов не приносит вреда проекту
Не всякой работе нужен graceful shutdown. Одноразовые задачи CronJob, воркеры, которые читают сообщение из очереди и подтверждают его только после обработки, утилиты сборки и локальная разработка прекрасно живут без обработчиков: SIGKILL для них означает, что задача не выполнена и будет повторена, а семантика повторов уже заложена в архитектуру. Упрощённый образ из туториала допустим там, где потеря процесса на середине работы стоит нуля.
Живой HTTP сервис, к которому подключены пользователи и другие команды, заслуживает полной схемы: exec-форма, слушатели сигналов, флаг готовности, preStop пауза и честный grace period. Разница между двумя конфигурациями измеряется одним вечером работы, и вечер окупается на первом же дневном релизе, когда обновление проходит мимо графиков ошибок и заметен в нём только номер версии в ответе сервиса.