Graceful shutdown: preStop, SIGTERM, PID 1
Когда Pod удаляется — будь то rollout, scale-down, eviction или просто kubectl delete pod — Kubernetes запускает асинхронный pipeline из ~6 шагов, прежде чем процесс полностью исчезнет. Если приложение не подготовлено к этому pipeline — клиенты получают 5xx, in-flight requests обрываются, transactions остаются half-committed.
Эта боль — типичная “graceful shutdown problem” — не специфична для K8s, она существует везде, где есть load balancer и multiple instances. Но в K8s она имеет конкретные точки контроля: preStop hook, terminationGracePeriodSeconds, обработка SIGTERM в приложении. Понять каждый шаг pipeline — значит знать, где поставить sleep, где flush в-flight requests, и почему shell-form ENTRYPOINT убивает graceful shutdown.
Сигналы и kill: как правильно завершать процессы
Pipeline удаления Pod: 6 шагов
Когда вы делаете kubectl delete pod web-abc12 (или это происходит автоматически из rollout/eviction), запускается следующий pipeline:
Важные особенности:
- Шаги 2 и 2’ идут параллельно. EndpointSlice update идёт через apiserver → kube-controller-manager → kube-proxy на каждой ноде. kubelet начинает SIGTERM-pipeline сразу, не дожидаясь сетевых распространений.
- Шаг 3 (preStop) и шаг 4 (SIGTERM) sequential — SIGTERM отправляется только после возврата preStop.
- terminationGracePeriodSeconds включает в себя ВСЁ: preStop + grace после SIGTERM. Если preStop спит 10s, у приложения остаётся 20s после SIGTERM (при default 30s total).
preStop hook: что это и зачем
preStop — синхронный hook, который kubelet вызывает перед SIGTERM. Два формата:
spec:
containers:
- name: app
image: my-app:v1
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10 && /app/graceful-shutdown.sh"]
terminationGracePeriodSeconds: 30
Или HTTP:
lifecycle:
preStop:
httpGet:
path: /shutdown
port: 8080
Зачем нужен preStop:
- Compensate race condition между EndpointSlice removal и kube-proxy propagation. Об этом ниже.
- Trigger в приложении “начать draining” до получения SIGTERM (для приложений, которые не умеют handle SIGTERM сами).
- Cleanup: deregister из service mesh, flush metrics, close DB transactions.
Race condition: EndpointSlice vs kube-proxy
Это самая болезненная часть graceful shutdown в K8s, которую большинство приложений делают неправильно.
Что происходит:
- Pod помечен Terminating, EndpointSlice controller удаляет IP из endpoint.
- Параллельно kubelet шлёт SIGTERM, приложение начинает graceful shutdown.
- Параллельно kube-controller-manager публикует обновлённый EndpointSlice в etcd.
- Параллельно kube-proxy на каждой ноде watch EndpointSlice → обновляет iptables/IPVS rules.
Проблема: шаг 4 распространяется сетево на все ноды, что занимает 1-5 секунд. За это время kube-proxy на части нод ещё держит старые правила → отправляет трафик на Terminating Pod. Но приложение уже SIGTERM-нуто и не принимает новые connections → клиенты получают connection refused.
Решение — preStop sleep:
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
preStop блокирует SIGTERM на 5 секунд. За это время:
- EndpointSlice update распространяется на все ноды.
- kube-proxy на всех нодах обновляет правила.
- Трафик прекращает идти на Pod.
- Только потом kubelet шлёт SIGTERM.
Результат: к моменту SIGTERM трафик уже не приходит — клиенты не пострадали.
sleep 5-10 — типичное значение. Меньше — может не хватить для kube-proxy. Больше — увеличивает rollout time без пользы. Подбирается опытным путём для конкретного кластера.
terminationGracePeriodSeconds: бюджет на shutdown
spec:
terminationGracePeriodSeconds: 60 # default 30
Это полное время от DELETE до SIGKILL. Включает:
- preStop execution
- SIGTERM grace
- Если процесс не завершился — SIGKILL
T+0 DELETE → DeletionTimestamp = T+60
T+0 kubelet: preStop start
T+10 preStop returns (sleep 10)
T+10 SIGTERM sent
T+10-60 application doing graceful shutdown
T+60 SIGKILL (if app не exit)
Когда увеличивать:
- App долго flush buffer (write-heavy DB-проксирующий сервис).
- Long-running requests (large file uploads/downloads).
- gRPC streaming connections, которые надо корректно закрыть.
Когда уменьшать (редко):
- Stateless app с быстрым shutdown — можно 10s для faster rollout.
terminationGracePeriodSeconds: 0
— немедленный SIGKILL без grace. Использовать только когда точно знаешь, что shutdown не критичен (например, batch jobs которые перезапустятся retried).
Приложение должно handle SIGTERM
Половина graceful shutdown работает в K8s, другая половина — в приложении. Что должен делать app по получению SIGTERM:
- Stop accepting new connections (close HTTP listener).
- Wait for in-flight requests to complete (с разумным таймаутом).
- Drain background tasks: flush metrics, finish ongoing batch.
- Close DB connection pool: ROLLBACK uncommitted, close sockets.
- Exit with code 0.
Пример на Go:
srv := &http.Server{Addr: ":8080", Handler: handler}
go srv.ListenAndServe()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
<-sig // ждём SIGTERM
log.Println("SIGTERM received, draining...")
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
srv.Shutdown(ctx) // graceful: ждёт in-flight handlers
db.Close()
log.Println("shutdown complete")
Python (with Uvicorn / gunicorn handle SIGTERM в самом server):
import signal
import asyncio
def handle_sigterm(signum, frame):
print("SIGTERM received, draining...")
asyncio.create_task(graceful_shutdown())
signal.signal(signal.SIGTERM, handle_sigterm)
Если приложение игнорирует SIGTERM (или handler сломан) — оно проработает до конца terminationGracePeriodSeconds, потом SIGKILL прибьёт его насильно. In-flight requests умрут, connections оборвутся. SIGTERM handler — обязательная часть production app.
PID 1 problem: shell-form ENTRYPOINT убивает graceful shutdown
В Linux процесс с PID 1 — особенный. Если он умирает — kernel убивает весь namespace (контейнер). Если он получает сигнал, который он не reaped — он его игнорирует, потому что kernel считает PID 1 особенным и не убивает его дефолтно.
Проблема: если ENTRYPOINT в shell-form, PID 1 это /bin/sh, а не ваше приложение:
# Плохо — shell-form
ENTRYPOINT nginx -g "daemon off;"
# Что происходит:
# PID 1: /bin/sh -c "nginx -g 'daemon off;'"
# PID N: nginx (child of sh)
sh не forwarding SIGTERM в child. Когда kubelet шлёт SIGTERM в PID 1 — sh его получает и игнорирует. nginx не знает, что shutdown происходит. Через 30s — SIGKILL прибивает всех.
Решение — exec-form ENTRYPOINT:
# Хорошо — exec-form, nginx становится PID 1
ENTRYPOINT ["nginx", "-g", "daemon off;"]
Теперь:
- PID 1: nginx напрямую.
- SIGTERM → nginx → graceful shutdown.
tini / dumb-init: когда нужен init-процесс
Иногда нужен wrapper script, который делает setup (например, chown на volumes, генерирует config) перед запуском app. Тогда script — PID 1, и тот же сигнал-forwarding problem.
Решение — init-процесс (tini или dumb-init):
FROM alpine:3.20
RUN apk add --no-cache tini
COPY entrypoint.sh app /app/
ENTRYPOINT ["/sbin/tini", "--", "/app/entrypoint.sh"]
tini — minimal init-процесс (~ 60KB). Что он делает:
- Запускается как PID 1.
- Spawn-ит указанную команду как child.
- Forward все сигналы child процессу.
- Reap zombie процессы (zombie reaping — другая обязанность PID 1).
- Exit с кодом child.
С Docker 1.13+ есть встроенный init: docker run --init. В K8s — pod.spec.shareProcessNamespace: true дает доступ к нескольким способам, но самый идиоматичный — собрать tini в образ.
# Альтернатива: shell exec
# В entrypoint.sh:
#!/bin/sh
echo "setup..."
chown -R app:app /data
exec /app/binary "$@" # exec заменяет PID 1 на binary
exec в shell заменяет процесс, не fork-ит. После exec /app/binary shell исчезает, binary становится PID 1, получает SIGTERM напрямую.
exec — лучший друг init-script в production. Без него shell остаётся PID 1 и ломает signal forwarding. Всегда exec /app/binary в финальной строке wrapper script.
Полный production-grade шаблон
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 4
template:
spec:
terminationGracePeriodSeconds: 60 # total budget
containers:
- name: web
image: my-web:v1.2.3 # exec-form ENTRYPOINT
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
lifecycle:
preStop:
exec:
command:
- sh
- -c
- "sleep 10" # дать kube-proxy 10s
И в коде приложения:
// pseudocode
signal.Notify(sigChan, syscall.SIGTERM)
<-sigChan
ctx, cancel := context.WithTimeout(ctx, 45*time.Second) // 60 - 10 - safety
defer cancel()
httpServer.Shutdown(ctx)
db.Close()
os.Exit(0)
Бюджет: 60s total = 10s preStop sleep + 45s graceful in app + 5s safety.
Killer-моменты
- preStop time ВКЛЮЧЕНО в terminationGracePeriodSeconds. Если preStop спит 20s, а GP=30s — у приложения остаётся 10s после SIGTERM. Не забывать.
- EndpointSlice update vs kube-proxy — race condition, которое preStop sleep compensates. Без него — connection drops на rollout.
- PID 1 problem — главная скрытая причина “graceful shutdown не работает”. Решение: exec-form ENTRYPOINT или tini.
- shell
exec— must в wrapper scripts. Без exec shell остаётся PID 1. - terminationGracePeriodSeconds=0 — мгновенный SIGKILL, никакого graceful. Использовать только сознательно.
- App без SIGTERM handler — гарантированный disaster при rollout. Stay alive до SIGKILL, in-flight requests умирают.
- Не путать с liveness probe: liveness restart-ит unhealthy Pod, не graceful shutdown. Pre-stop hook — это про controlled exit.