Graceful shutdown в контейнерах: SIGTERM, дренаж запросов и таймауты
Продвинутое3 минРедакция Innovialabs
Коротко
По SIGTERM приложение должно перестать принимать новую работу, завершить текущие запросы и выйти до конца grace period; убедитесь, что сигнал доходит до процесса как PID 1, и добавьте короткий preStop в Kubernetes.
Коротко
Каждый деплой, уменьшение реплик или drain ноды останавливает контейнеры. Если приложение умирает мгновенно, текущие запросы падают с ошибками, а фоновые задачи обрываются на середине. Graceful shutdown означает:
- сигнал действительно доходит до процесса;
- процесс перестаёт принимать новые соединения и задачи;
- завершает начатое, закрывает соединения с базой и сбрасывает буферы;
- выходит раньше, чем платформа потеряет терпение и пришлёт SIGKILL.
Как Docker и Kubernetes останавливают контейнеры
Docker: docker stop отправляет SIGTERM (или сигнал из STOPSIGNAL) процессу PID 1 контейнера, ждёт таймаут (по умолчанию 10 секунд, меняется через --stop-timeout или -t) и затем отправляет SIGKILL.
Kubernetes делает больше, причём частично параллельно:
- pod помечается Terminating и убирается из endpoints сервиса;
- выполняется preStop hook, если он задан;
- затем контейнер получает SIGTERM;
- если по истечении terminationGracePeriodSeconds (по умолчанию 30) он ещё жив, приходит SIGKILL. Время preStop входит в этот период.
Важная деталь: удаление из endpoints доходит до kube-proxy, ingress-контроллеров и балансировщиков асинхронно. Несколько мгновений после SIGTERM трафик ещё может приходить. Приложение, которое сразу закрывает listener, отклонит эти запросы.
PID 1 и проброс сигналов
Процесс с PID 1 в контейнере ведёт себя особенно: ядро не применяет к нему стандартные действия сигналов. Если у PID 1 нет обработчика SIGTERM, сигнал просто игнорируется, и контейнер ждёт SIGKILL.
Типичные ловушки:
- Shell form
CMD npm startзапускает/bin/sh -c, который становится PID 1 и не пробрасывает сигналы приложению. - npm, yarn и похожие обёртки могут передавать сигналы ненадёжно. Запускайте runtime напрямую.
- Entrypoint-скрипты должны заканчиваться
exec "$@", чтобы приложение заменило shell.
# exec form: PID 1 — сам node
CMD ["node", "server.js"]
Если процесс не умеет обрабатывать сигналы или порождает дочерние процессы, используйте маленький init: docker run --init или tini в entrypoint. Он пробрасывает сигналы и убирает зомби-процессы.
Обработка SIGTERM в коде
Node.js: останавливаем HTTP-сервер, ждём открытые запросы, закрываем ресурсы. Жёсткий таймаут страхует от зависших keep-alive соединений.
process.on("SIGTERM", () => {
server.close(async () => {
await db.end();
process.exit(0);
});
setTimeout(() => process.exit(1), 25000).unref();
});
Python: серверы вроде Gunicorn и Uvicorn уже корректно обрабатывают SIGTERM — главное, чтобы они были PID 1. В собственном цикле воркера ставим флаг и доделываем текущую задачу:
import signal
stopping = False
def on_sigterm(signum, frame):
global stopping
stopping = True
signal.signal(signal.SIGTERM, on_sigterm)
while not stopping:
process_next_job()
close_connections()
Go: http.Server.Shutdown закрывает listeners и ждёт активные запросы, пока не истечёт контекст.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
go srv.ListenAndServe()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)
preStop и таймауты в Kubernetes
Чтобы закрыть гонку с обновлением endpoints, добавьте короткую паузу перед SIGTERM:
spec:
terminationGracePeriodSeconds: 45
containers:
- name: api
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
Для exec в образе нужен бинарник sleep; в новых версиях Kubernetes есть и встроенное действие sleep для preStop. Составьте бюджет времени: пауза preStop + самый долгий обычный запрос + очистка должны укладываться в terminationGracePeriodSeconds, а жёсткий таймаут внутри приложения должен срабатывать раньше SIGKILL от Kubernetes.
Чек-лист
- Exec form в
CMD/ENTRYPOINT,execв shell-скриптах или--init. - Обработчик SIGTERM: прекратить приём, дренировать, выйти с кодом 0.
- Readiness probe начинает падать с началом остановки.
- Пауза в preStop на распространение endpoints.
- Grace period длиннее времени дренажа.
- Консьюмеры очередей подтверждают сообщение только после обработки, чтобы прерванная задача пришла заново.
- Долгие соединения (WebSocket, стриминг) получают сообщение о закрытии, чтобы клиенты переподключились к другому экземпляру.