Learning Platform
Глоссарий Troubleshooting
Урок 20.02 · 25 мин
Продвинутый
graceful shutdownSIGTERMpreStopterminationGracePeriodSecondsPID 1tiniEndpointSlicekube-proxy

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:

Pipeline удаления Pod (асинхронный)
1. API: DeletionTimestampkube-apiserver получает DELETE Pod. Не удаляет сразу — устанавливает metadata.deletionTimestamp = now + terminationGracePeriodSeconds. Этот таймер начал тикать. Pod status в etcd показывает Terminating.
событие watched контроллерами
2. EndpointSlice updateEndpointSlice controller замечает Pod в Terminating и УДАЛЯЕТ его IP из всех EndpointSlice (where он matched Service selector). Это сигнал для kube-proxy: 'не отправляй сюда новый трафик'.
2'. kubelet начинает graceful shutdownПАРАЛЛЕЛЬНО с (2) kubelet на ноде видит Pod в Terminating. Запускает graceful shutdown pipeline ВНУТРИ ноды. Это асинхронно от EndpointSlice — kubelet и controller-manager независимы.
kubelet вызывает hook
3. preStop hookЕсли в spec.containers[].lifecycle.preStop определён — kubelet вызывает его. Exec в контейнере или HTTP GET. Блокирует следующий шаг. preStop время ВКЛЮЧЕНО в terminationGracePeriodSeconds.
после preStop возврата
4. SIGTERMkubelet отправляет SIGTERM в PID 1 каждого контейнера Pod. Это сигнал 'graceful exit'. Приложение должно: закрыть listener, дождаться in-flight requests, освободить ресурсы, exit с кодом 0.
ждём до terminationGracePeriodSeconds
5. Grace periodМежду SIGTERM и SIGKILL — оставшееся время до dialineTimestamp (т.е. terminationGracePeriodSeconds минус время preStop). По умолчанию 30s, можно увеличить для slow shutdown apps.
если процесс ещё жив
6. SIGKILLПосле grace period — kubelet kill -9 (SIGKILL). Этот сигнал нельзя intercept или обработать. Kernel мгновенно прибивает процесс. Все open file descriptors, sockets закрываются операционкой.

Важные особенности:

  • Шаги 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:

  1. Compensate race condition между EndpointSlice removal и kube-proxy propagation. Об этом ниже.
  2. Trigger в приложении “начать draining” до получения SIGTERM (для приложений, которые не умеют handle SIGTERM сами).
  3. Cleanup: deregister из service mesh, flush metrics, close DB transactions.

Race condition: EndpointSlice vs kube-proxy

Это самая болезненная часть graceful shutdown в K8s, которую большинство приложений делают неправильно.

Что происходит:

  1. Pod помечен Terminating, EndpointSlice controller удаляет IP из endpoint.
  2. Параллельно kubelet шлёт SIGTERM, приложение начинает graceful shutdown.
  3. Параллельно kube-controller-manager публикует обновлённый EndpointSlice в etcd.
  4. Параллельно kube-proxy на каждой ноде watch EndpointSlice → обновляет iptables/IPVS rules.

Проблема: шаг 4 распространяется сетево на все ноды, что занимает 1-5 секунд. За это время kube-proxy на части нод ещё держит старые правила → отправляет трафик на Terminating Pod. Но приложение уже SIGTERM-нуто и не принимает новые connections → клиенты получают connection refused.

Race condition: SIGTERM приходит до того, как kube-proxy обновил правила
t=0kubectl delete pod. apiserver устанавливает DeletionTimestamp.
t=0+msПАРАЛЛЕЛЬНО: EndpointSlice controller убирает IP, kubelet начинает shutdown pipeline.
t=0.5skubelet шлёт SIGTERM. App закрывает listener — больше не accept connections. НО kube-proxy на других нодах ЕЩЁ не получил update — iptables правило существует, трафик идёт.
t=0.5-3skube-proxy на нодах watch обновление EndpointSlice. У некоторых latency больше: iptables sync raз в N миллисекунд, IPVS rebuild ~100ms. Окно race condition.
результатКлиент получает connection refused или RST. Если retry policy в LB есть — retried на другой Pod. Если нет — пользователь видит ошибку.

Решение — preStop sleep:

lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 5"]

preStop блокирует SIGTERM на 5 секунд. За это время:

  1. EndpointSlice update распространяется на все ноды.
  2. kube-proxy на всех нодах обновляет правила.
  3. Трафик прекращает идти на Pod.
  4. Только потом kubelet шлёт SIGTERM.

Результат: к моменту SIGTERM трафик уже не приходит — клиенты не пострадали.

TIP

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:

  1. Stop accepting new connections (close HTTP listener).
  2. Wait for in-flight requests to complete (с разумным таймаутом).
  3. Drain background tasks: flush metrics, finish ongoing batch.
  4. Close DB connection pool: ROLLBACK uncommitted, close sockets.
  5. 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)
WARNING

Если приложение игнорирует 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.
PID 1 problem с shell-form vs exec-form ENTRYPOINT
shell-form (плохо)ENTRYPOINT nginx -g 'daemon off;' (без скобок). Docker создаёт /bin/sh -c '...'. Shell — PID 1. nginx — child процесс с PID 2+. SIGTERM приходит в sh, sh ничего не делает с child. nginx не получает уведомление о shutdown.
exec-form (хорошо)ENTRYPOINT ["nginx", "-g", "daemon off;"] (JSON array). Docker exec-ит nginx напрямую, без shell. nginx — PID 1. SIGTERM приходит nginx, он inkin nginx_signal_handler начинает graceful shutdown.
последствияС shell-form — SIGTERM не доходит до приложения, всегда SIGKILL после 30s. In-flight requests обрываются. С exec-form — SIGTERM доходит, приложение делает graceful shutdown, exit с 0 до истечения grace period.

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). Что он делает:

  1. Запускается как PID 1.
  2. Spawn-ит указанную команду как child.
  3. Forward все сигналы child процессу.
  4. Reap zombie процессы (zombie reaping — другая обязанность PID 1).
  5. 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 напрямую.

NOTE

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.

Проверка знанийKnowledge check
terminationGracePeriodSeconds=30, preStop sleep 15. Сколько секунд приложение имеет на graceful shutdown ПОСЛЕ SIGTERM?
ОтветAnswer
15 секунд. preStop включён в terminationGracePeriodSeconds: 30 - 15 = 15. За эти 15s после SIGTERM app должен: stop accepting connections, wait in-flight requests, close DB pool, exit. Если не успел — SIGKILL прибивает. Если 15s не хватает — увеличить terminationGracePeriodSeconds до 60 (preStop=10 + 50s grace) или уменьшить preStop. Главное — учитывать, что preStop тратит время, и оно ВНУТРИ общего бюджета.
Проверка знанийKnowledge check
Зачем нужен preStop sleep 10s, если приложение само handles SIGTERM correctly?
ОтветAnswer
Из-за race condition между EndpointSlice update и kube-proxy propagation. Когда Pod удаляется: (1) controller-manager убирает IP из EndpointSlice. (2) kube-proxy на нодах watch update и rebuild iptables/IPVS правила — это занимает 1-5 секунд для распространения. (3) ПАРАЛЛЕЛЬНО kubelet начинает SIGTERM pipeline. Без preStop sleep: SIGTERM пришёл, app закрыл listener — но kube-proxy на других нодах ЕЩЁ держит старые правила. Трафик идёт на dead Pod → connection refused. preStop sleep 5-10s блокирует SIGTERM на это время, давая kube-proxy обновиться. После sleep — трафик уже не приходит, SIGTERM безопасен.
Проверка знанийKnowledge check
Dockerfile содержит 'ENTRYPOINT /app/binary'. Pod не shutdown gracefully — всегда SIGKILL. В чём проблема и как починить?
ОтветAnswer
Это shell-form ENTRYPOINT (без квадратных скобок). Docker запускает /bin/sh -c /app/binary — shell становится PID 1, binary это child. SIGTERM от kubelet приходит в shell, shell его не forwarding в child. /app/binary не знает что shutdown происходит. После terminationGracePeriodSeconds — SIGKILL прибивает всё, in-flight requests умирают. Fix: exec-form ENTRYPOINT с JSON array (квадратные скобки и quoted args). Docker exec-ит binary напрямую, оно становится PID 1, получает SIGTERM. Альтернатива — tini как init (запускается первым, форкает приложение, forwards signals). Если есть entrypoint script — последняя строка должна быть exec /app/binary, иначе shell остаётся PID 1.
Проверка знанийKnowledge check
Приложение делает long-running requests (file upload до 10 минут). terminationGracePeriodSeconds=30 явно мало. Как сделать graceful shutdown для таких apps?
ОтветAnswer
Увеличить terminationGracePeriodSeconds до значения, которое покрывает максимальный request: например 660s (11 минут — long upload + buffer). Pipeline: (1) preStop sleep 5-10s (race condition с kube-proxy). (2) SIGTERM. (3) App stop accepting new uploads, но дожидается in-flight. (4) Exit когда все done. В коде: счётчик in-flight requests, после SIGTERM ждать decrement до 0. Альтернатива для очень long-running (часы): не использовать Deployment, а Job или KEDA-based pattern, где finite work explicitly modeled. PodDisruptionBudget c maxUnavailable=1 + проектирование с idempotent retry на клиенте — практичный compromise.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. terminationGracePeriodSeconds=30, preStop sleep 10. Сколько секунд приложение имеет НА graceful shutdown ПОСЛЕ получения SIGTERM?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5