убрать 502 при деплое за счёт корректного завершения подов

Корректное завершение подов в Kubernetes: PodDisruptionBudget, PreStop и отведение соединений

13 минут

Ошибки 502 при деплое и drain нод часто связаны не со стратегией обновления, а с тем, как под принимает SIGTERM. Сочетайте отведение соединений в приложении, паузу PreStop, PDB для добровольных нарушений и учёт сайдкаров — тогда пользователи не замечают выкат.

Почему rolling update всё ещё даёт 502

Пользователи видят 502 и обрывы соединений при выкате, даже когда стратегия обновления выглядит верной. Kubernetes может остановить под в любой момент: rolling update, drain ноды, сокращение Cluster Autoscaler, вытеснение (preemption). По умолчанию kubelet шлёт SIGTERM, ждёт terminationGracePeriodSeconds (30 с), затем SIGKILL. Большинство приложений не успевают завершить текущую работу. Важна последовательность: под переходит в Terminating и асинхронно убирается из Endpoints и EndpointSlices, параллельно выполняется PreStop, затем контейнер получает SIGTERM. Если процесс сразу выходит, kube-proxy и облачный балансировщик ещё могут слать трафик на умирающий IP. Сайдкары service mesh иногда гаснут раньше приложения и режут исходящие вызовы. Без PodDisruptionBudget один drain может снять все реплики сразу. По отдельности сбои выглядят мелкими; во время выката они складываются в заметные пользователям ошибки.

Отведение нагрузки в приложении по SIGTERM

Настройки платформы не спасут приложение, которое игнорирует SIGTERM. По этому сигналу нужно перестать принимать новую работу, дожать уже открытую и выйти с кодом ноль. HTTP-сервер закрывает listener, ждёт активные обработчики и завершается. gRPC вызывает GracefulStop: новые RPC отклоняются, текущие доводятся до конца. Воркеры очередей заканчивают текущее сообщение и не берут следующие. Пул к базе закрывает свободные соединения и ждёт занятые, а не рвёт сокеты. Добавьте журналы: получен SIGTERM, сколько активных соединений, когда завершение окончено. SIGKILL перехватить нельзя — всё полезное должно случиться до него.

PreStop закрывает гонку с удалением эндпоинтов

PreStop выполняется до SIGTERM и входит в terminationGracePeriodSeconds. Короткая пауза даёт kube-proxy, потребителям EndpointSlice и облачному балансировщику время перестать слать новый трафик, пока процесс ещё не начал отведение соединений. Для ClusterIP часто хватает пяти секунд; для mesh и внешних балансировщиков — десяти–пятнадцати. Держите hook простым: зависание съест всё окно завершения. Сочетайте PreStop с readiness: в Terminating пробы должны падать, чтобы под быстрее ушёл из Ready. Формула для grace period: задержка PreStop плюс самая длинная ожидаемая обработка запроса плюс запас. Тридцать секунд по умолчанию редко хватает платёжным API и long-poll.

YAML · пауза PreStop до SIGTERM
lifecycle:
  preStop:
    exec:
      command:
        - /bin/sh
        - -c
        - sleep 10

PodDisruptionBudget ограничивает добровольные нарушения

PodDisruptionBudget ограничивает добровольные нарушения — drain, плановые обновления нод, добровольный eviction — но не падения нод и не OOM. minAvailable задаёт нижнюю границу здоровых подов. maxUnavailable удобен в процентах, когда число реплик меняется. Для критичных API с фиксированным полом реплик чаще берут абсолютный minAvailable. На нагрузках с несколькими репликами и пользовательским трафиком PDB обязателен: без него один drain может убрать все поды. PDB не мешает Deployment снизить replicas до нуля по вашей команде, а принудительный drain может обойти бюджет. С Kubernetes 1.26+ unhealthyPodEvictionPolicy определяет, блокирует ли PDB выселение подов, которые так и не стали Ready.

YAML · PDB с minAvailable
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-server-pdb
  namespace: production
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: api-server
YAML · PDB с maxUnavailable в процентах
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-server-pdb
  namespace: production
spec:
  maxUnavailable: "25%"
  selector:
    matchLabels:
      app: api-server

Продакшен-схема: rolling update, PreStop и PDB вместе

Сведите осторожную стратегию обновления, паузу PreStop, реалистичный grace period, readiness и PDB. maxUnavailable ноль при небольшом maxSurge сохраняет ёмкость, пока новые поды не станут Ready. Sleep в PreStop закрывает задержку распространения эндпоинтов. terminationGracePeriodSeconds должен превышать PreStop плюс отведение в приложении. PDB не даёт добровольным drain опуститься ниже порога доступности. Этот каркас не заменяет обработку SIGTERM — он лишь даёт приложению нужное окно времени.

YAML · Deployment payment-service и PDB
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-service
  namespace: production
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: payment-service
  template:
    metadata:
      labels:
        app: payment-service
    spec:
      terminationGracePeriodSeconds: 90
      containers:
        - name: payment-service
          image: registry.example.com/payment-service:v2.4.1
          ports:
            - containerPort: 8080
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 10"]
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            periodSeconds: 5
            failureThreshold: 2
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 512Mi
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: payment-service-pdb
  namespace: production
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: payment-service

Завершение с учётом сайдкаров и native sidecars

Классические внедрённые прокси mesh часто получали SIGTERM одновременно с приложением и гасли первыми, обрывая исходящие вызовы посреди отведения. Старые обходы в Istio — длинный PreStop и annotation proxy.istio.io/config с terminationDrainDuration, чтобы Envoy жил, пока приложение заканчивает работу. holdApplicationUntilProxyStarts решает порядок запуска, а не остановки — не путайте эти настройки. Предпочтительнее native sidecars Kubernetes (в свежих релизах стабильны и часто включены по умолчанию): прокси объявляют в initContainers с restartPolicy Always. Такой сайдкар стартует до приложения и остаётся до выхода обычных контейнеров — это убирает большинство гонок «прокси умер первым». Linkerd и актуальный Istio опираются на эту модель; перед снятием drain-аннотаций проверьте версии кластера и mesh. shareProcessNamespace и обёртки оставляйте только как мост на старых кластерах.

Проверка, метрики и согласование со SLO

Проверьте путь на стенде: сделайте kubectl drain на ноде с репликой или пошлите SIGTERM внутрь пода и убедитесь, что клиенты не теряют запросы. Согласуйте kubectl drain --grace-period с terminationGracePeriodSeconds пода, иначе drain пришлёт SIGKILL раньше. Смотрите долю 5xx и обрывов соединений во время Deployment; сопоставляйте с событиями Terminating и журналами отведения в приложении. Если p99 — две секунды, а grace 30 с при PreStop 10 с, длинные обработчики всё равно могут попасть под SIGKILL — считайте окно по худшей длительности запроса плюс PreStop. У headless Service и StatefulSet другая семантика эндпоинтов; клиенты должны повторять запрос и заново резолвить DNS при сбое. Корректное завершение незаметно, когда работает: пользователи не видят выкат. Когда нет — каждый деплой играет против вашего error budget.

Когда поды корректно завершаются, проверяйте выкат через автоматический анализ canary и откат по SLO.

Доступность при нарушениях входит в error budget из гайда по SLO и SLI для платформенных команд.