Базовые контроллеры: ReplicaSet и Deployment

Глубокое погружение в механизмы обеспечения отказоустойчивости и стратегии обновлений приложений. Вы узнаете, как Kubernetes поддерживает желаемое количество реплик и реализует Rolling Update без простоя сервиса.

От одиночных Pod'ов к самоисцелению: концепция контроллеров и ReplicaSet

От одиночных Pod'ов к самоисцелению: концепция контроллеров и ReplicaSet

Вы уже умеете запускать Pod'ы, читать их логи, погружаться в их Namespaces через nsenter и отлаживать процессы с помощью эфемерных контейнеров. Но давайте представим production-сценарий: 3 часа ночи, в дата-центре выходит из строя блок питания на Worker Node, где работает ваш идеально настроенный Pod. Что произойдет с приложением?

Оно умрет. И, что самое страшное, никто его не перезапустит.

В этой статье мы разберем, почему создание «голых» (bare) Pod'ов — это антипаттерн, как Kubernetes реализует принцип самоисцеления (self-healing) и почему для управления приложениями используются контроллеры, простейшим из которых является ReplicaSet.

Хрупкость «голых» Pod'ов

Как мы помним из архитектуры Control Plane, когда вы выполняете kubectl apply -f pod.yaml, компонент kube-scheduler находит подходящий узел и выполняет операцию Binding, жестко прописывая имя узла в поле nodeName манифеста Pod'а.

С этого момента Pod навсегда привязан к конкретному Worker Node. Жизненный цикл Pod'а неразрывно связан с жизненным циклом узла. Если процесс внутри контейнера падает с ошибкой (Exit Code 1), локальный kubelet перезапустит его, опираясь на механизм Exponential Backoff. Но если падает сам узел, kubelet перестает отправлять Heartbeat в kube-apiserver.

Спустя таймаут Control Plane пометит узел как NotReady. Статус вашего Pod'а изменится на Terminating, а затем на Unknown. Kubernetes не будет пытаться перенести этот Pod на другой узел, потому что Pod — это примитив самого низкого уровня. У Control Plane нет инструкций создавать новый Pod взамен утраченного.

Создание одиночных Pod'ов напрямую через kind: Pod в production строго запрещено. Любой Pod должен управляться контроллером.

Паттерн Controller и делегирование ответственности

Чтобы система была отказоустойчивой, нам нужен кто-то, кто будет непрерывно следить за количеством живых экземпляров приложения и реагировать на инциденты. В Kubernetes эту роль выполняет компонент kube-controller-manager.

Внутри него работают десятки различных контроллеров. Их суть сводится к реализации паттерна Reconciliation Loop (цикла согласования), с которым вы уже знакомы по работе kubelet. Но если kubelet следит за состоянием контейнеров на одном узле, то контроллеры в Control Plane следят за глобальным состоянием кластера.

Базовая логика любого контроллера описывается простым псевдокодом:

  1. Прочитать желаемое состояние (Desired State) из etcd.
  2. Прочитать текущее фактическое состояние (Actual State) кластера.
  3. Если Actual \neq Desired, выполнить действия для их уравнивания.

Для управления множеством одинаковых Pod'ов был создан контроллер ReplicaSet. Его единственная, математически строгая задача — гарантировать, что в любой момент времени в кластере работает ровно NN экземпляров (реплик) определенного Pod'а.

Если N=3N=3, а фактически работает 2 (один узел сгорел), ReplicaSet Controller немедленно отправляет запрос в kube-apiserver на создание одного нового Pod'а. Этот новый Pod будет подхвачен планировщиком и запущен на любом другом доступном узле. Так достигается самоисцеление.

Анатомия связи: Labels и Selectors

Возникает фундаментальный архитектурный вопрос: как именно ReplicaSet понимает, какие Pod'ы принадлежат ему?

В императивных системах родительский объект хранил бы массив идентификаторов (ID) созданных им потомков. Kubernetes использует декларативный подход. Связь между объектами устанавливается динамически через систему меток (Labels) и селекторов (Selectors).

  • Labels (Метки) — это простые пары ключ-значение, которые прикрепляются к любому объекту (например, к Pod'у). Они несут бизнес-смысл: app: frontend, env: production, tier: cache.
  • Selector (Селектор) — это запрос, который контроллер использует для поиска своих объектов.

ReplicaSet не знает имен своих Pod'ов. Он регулярно опрашивает API: "Дай мне все Pod'ы, у которых есть метка app=nginx". Если таких Pod'ов меньше, чем нужно — он берет шаблон и штампует новые. Если больше (например, случился сетевой сплит, и старый узел вернулся в строй) — он безжалостно убивает лишние.

Эта слабая связность (loose coupling) — гениальное решение. Она позволяет контроллерам управлять ресурсами, не блокируя друг друга, и дает инженерам огромную гибкость при отладке.

YAML-манифест ReplicaSet

Давайте посмотрим, как желаемое состояние описывается в коде. Манифест ReplicaSet состоит из трех ключевых блоков:

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: frontend-rs
spec:
  # 1. Желаемое количество (Desired State)
  replicas: 3

  # 2. Правило поиска своих Pod'ов
  selector:
    matchLabels:
      app: frontend

  # 3. Шаблон для создания новых Pod'ов (Pod Blueprint)
  template:
    metadata:
      labels:
        app: frontend # Обязательно должно совпадать с selector!
    spec:
      containers:
      - name: nginx
        image: nginx:1.21

Обратите внимание на блок template. Это, по сути, манифест обычного Pod'а, вложенный внутрь ReplicaSet (без apiVersion и kind, так как они наследуются). Когда ReplicaSet видит нехватку реплик, он берет этот template, генерирует уникальное имя (например, frontend-rs-x7b9q) и отправляет его в kube-apiserver как новый Pod.

Защита от сиротства: ownerReferences

Если связь строится только на метках, как Kubernetes понимает, кого удалять при удалении самого ReplicaSet?

Когда ReplicaSet создает Pod на основе шаблона, он неявно внедряет в метаданные Pod'а скрытое поле ownerReferences. Это системная ссылка на родителя.

Вы можете увидеть её, выполнив kubectl get pod <pod-name> -o yaml:

metadata:
  ownerReferences:
  - apiVersion: apps/v1
    blockOwnerDeletion: true
    controller: true
    kind: ReplicaSet
    name: frontend-rs
    uid: 4d5e6f7a-8b9c...

Благодаря ownerReferences работает механизм Cascading Deletion (каскадное удаление). Когда вы удаляете ReplicaSet (kubectl delete rs frontend-rs), Garbage Collector в Control Plane видит эту операцию, находит все дочерние Pod'ы по их ownerReferences и удаляет их вслед за родителем.

Ограничения ReplicaSet

ReplicaSet блестяще справляется со своей единственной задачей — масштабированием (Scaling) и поддержанием количества реплик.

Но что произойдет, если мы захотим обновить версию приложения с nginx:1.21 на nginx:1.22? Мы можем отредактировать манифест ReplicaSet и изменить image в блоке template. Объект в etcd обновится. Но ничего не произойдет.

Почему? Потому что цикл согласования ReplicaSet проверяет только количество Pod'ов, совпадающих по Label Selector. Если N=3N=3 и фактически работает 3 Pod'а с нужными метками, ReplicaSet считает свою работу выполненной. Ему абсолютно неважно, что внутри этих Pod'ов крутится старая версия образа. Новый template будет использован только в том случае, если старый Pod умрет и потребуется создать замену.

Для решения задачи бесшовного обновления (Rolling Update) и управления версиями шаблонов поверх ReplicaSet была создана еще одна абстракция, которая сегодня является стандартом де-факто для запуска stateless-приложений. Но об этом — в следующих главах.

Анатомия ReplicaSet: селекторы, шаблоны и логика масштабирования

Анатомия ReplicaSet: селекторы, шаблоны и логика масштабирования

Представьте, что у вас работает ReplicaSet с десятью репликами приложения. Вы меняете конфигурацию и указываете replicas: 5. Контроллер должен удалить ровно половину. Но как он выбирает «жертв»? Это случайный выбор? Убиваются самые старые? Или самые новые?

В Kubernetes нет магии. За каждым решением контроллера стоит жесткий детерминированный алгоритм. Чтобы управлять production-кластерами, недостаточно знать, что делает контроллер — нужно понимать, как именно он читает конфигурацию, захватывает объекты и принимает решения об их уничтожении.

Три столпа конфигурации

Любой ReplicaSet держится на трех базовых элементах, которые вместе образуют его анатомию.

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: frontend-cache
spec:
  replicas: 3
  selector:
    matchLabels:
      app: redis
      tier: cache
  template:
    metadata:
      labels:
        app: redis
        tier: cache
    spec:
      containers:
      - name: redis
        image: redis:7.0
  1. replicas (Цель): Желаемое количество Pod'ов. Если поле не указано, по умолчанию оно равно 1.
  2. selector (Прицел): Правила, по которым контроллер ищет Pod'ы в кластере, чтобы взять их под свое управление.
  3. template (Матрица): Шаблон, по которому будут создаваться новые Pod'ы, если текущее количество меньше желаемого.

Критически важное правило: метки внутри template.metadata.labels обязаны удовлетворять условиям, описанным в selector. Если это не так, kube-apiserver на этапе Validation отклонит такой манифест. Иначе контроллер создал бы Pod, который сам же не смог бы найти, что привело бы к бесконечному циклу создания (split-brain).

Продвинутый захват: matchExpressions

Базовый matchLabels работает только на точное совпадение (логическое И). Но в сложных системах этого не хватает. Например, вы хотите, чтобы один ReplicaSet захватил Pod'ы с меткой version: v1 и version: v2, или захватил все Pod'ы, у которых в принципе есть метка canary, независимо от ее значения.

Для этого используется блок matchExpressions — селекторы на основе множеств (set-based selectors).

Оператор Описание Пример YAML
In Значение метки должно быть в указанном списке. values: ["v1", "v2"]
NotIn Значение метки не должно быть в списке. values: ["debug", "test"]
Exists Метка должна существовать (значение неважно). values не указывается
DoesNotExist Метки не должно быть на объекте. values не указывается

Если в selector указаны и matchLabels, и matchExpressions, все условия объединяются через логическое И (AND) — Pod должен удовлетворять всем правилам одновременно.

Пример использования:

selector:
  matchExpressions:
    - key: environment
      operator: In
      values: ["production", "staging"]
    - key: debug-mode
      operator: DoesNotExist

Этот селектор захватит Pod'ы для production и staging, но строго проигнорирует те, на которых кто-то вручную повесил метку debug-mode (например, для локальной отладки через kubectl debug).

Алгоритм Scale-Down: кого убивают первым?

Когда фактическое количество Pod'ов превышает replicas (например, при ручном масштабировании вниз), ReplicaSet Controller начинает процесс удаления лишних реплик.

Он не выбирает жертв случайным образом. Существует строгий алгоритм приоритизации (Deletion Cost), который минимизирует влияние на доступность приложения. Контроллер сортирует все захваченные Pod'ы и удаляет их в следующем порядке:

  1. Unassigned (Без узла): Pod'ы со статусом Pending, которые kube-scheduler еще не успел привязать к Worker Node. Их удаление самое безопасное — они все равно еще не работают.
  2. Terminating: Pod'ы, у которых уже установлен DeletionTimestamp (они уже в процессе удаления).
  3. NotReady: Pod'ы, запущенные на узлах, но не прошедшие проверки готовности (Readiness Probes) или находящиеся в состоянии ошибки (например, CrashLoopBackOff).
  4. Maximum Restart Count: Если все Pod'ы здоровы (Ready), контроллер выберет тот, чей контейнер перезапускался наибольшее количество раз. Логика проста: этот Pod наименее стабилен.
  5. Youngest (Самый молодой): Если по предыдущим критериям ничья (все здоровы и без рестартов), удаляется Pod, созданный последним. Старые Pod'ы считаются более "прогретыми" (например, JIT-компиляция в Java, заполненный локальный кэш), поэтому их выгоднее оставить.

Этот алгоритм гарантирует, что при масштабировании вниз Kubernetes в первую очередь избавится от проблемных или бесполезных реплик, сохраняя максимально стабильное ядро приложения.

Ловушка шаблона: состояние Frankenstein

В прошлой главе мы выяснили, что ReplicaSet реагирует на изменение replicas и удаление Pod'ов. Но что произойдет, если мы изменим образ контейнера прямо в работающем ReplicaSet?

kubectl set image rs/frontend-cache redis=redis:7.2

Ответ: ничего не произойдет.

ReplicaSet Controller сравнивает только количество текущих Pod'ов с полем replicas. Он не сверяет конфигурацию существующих Pod'ов с полем template. Шаблон используется только в момент создания нового Pod'а.

Это приводит к опасному поведению. Если после изменения шаблона один из старых Pod'ов упадет (например, узел перезагрузится), контроллер увидит, что не хватает одной реплики, и создаст новую. Но создаст он ее уже по новому шаблону.

В результате в кластере возникает состояние "Frankenstein":

  • 2 Pod'а работают на версии redis:7.0.
  • 1 Pod работает на версии redis:7.2.

Чтобы обновить все Pod'ы через ReplicaSet, администратору придется вручную удалять старые Pod'ы по одному (или все разом), заставляя контроллер пересоздавать их из нового шаблона.

Такой ручной подход неприемлем для production-систем, где требуется автоматическое обновление без простоев (Zero Downtime). Именно эта фундаментальная ограниченность ReplicaSet как простого "счетчика реплик" привела к созданию следующего, более высокоуровневого контроллера, который берет на себя управление версиями и процессом обновления.

Deployment как менеджер версий: управление историей и декларативный апдейт

Deployment как менеджер версий: управление историей и декларативный апдейт

Вспомним тупик, в который мы уперлись с ReplicaSet: если в работающем кластере изменить версию образа в поле template, контроллер это проигнорирует. Существующие Pod'ы продолжат работать на старой версии, и чтобы запустить новые, нам придется вручную удалять старые Pod'ы, заставляя ReplicaSet их пересоздавать.

Такое ручное вмешательство — это возврат к императивному управлению. Нам же нужен декларативный подход: мы хотим просто заявить «теперь версия приложения v2», а Kubernetes должен сам безопасно заменить старые Pod'ы на новые.

Эту задачу решает Deployment — контроллер более высокого уровня, который выступает в роли менеджера версий.

Матрёшка контроллеров: как устроен Deployment

Главный инсайт, который нужно усвоить для понимания архитектуры Kubernetes: Deployment вообще не управляет Pod'ами напрямую.

Его зона ответственности — управление объектами ReplicaSet.

Когда вы создаете Deployment, внутри Control Plane запускается следующая цепочка:

  1. Контроллер Deployment читает ваш манифест и создает ReplicaSet.
  2. В созданный ReplicaSet он копирует ваш template (шаблон Pod'а) и replicas (количество).
  3. Контроллер ReplicaSet видит новый объект, читает его и начинает создавать Pod'ы.

Связь между этими тремя слоями обеспечивается через уже знакомый нам механизм ownerReferences. У Pod'ов в качестве владельца записан ReplicaSet, а у ReplicaSet в качестве владельца записан Deployment. Если вы удалите Deployment, каскадное удаление (Cascading Deletion) уничтожит ReplicaSet, который, в свою очередь, уничтожит все Pod'ы.

Декларативный апдейт: рождение нового ReplicaSet

Зачем нужна эта дополнительная прослойка? Ответ кроется в механике обновления.

Допустим, у вас работает Deployment с тремя репликами Nginx версии 1.14. Вы редактируете манифест, меняя образ на nginx:1.15, и выполняете kubectl apply.

Вот что происходит в этот момент:

  1. Контроллер Deployment замечает изменение в секции template.
  2. Он вычисляет криптографический хеш от нового содержимого template.
  3. Deployment создает абсолютно новый ReplicaSet и добавляет этот хеш к его имени (например, nginx-deployment-7b4859c984).
  4. Теперь в кластере существуют два ReplicaSet, принадлежащих одному Deployment: старый (v1) и новый (v2).
  5. Deployment начинает плавно увеличивать replicas у нового ReplicaSet и одновременно уменьшать replicas у старого.

В результате старые Pod'ы постепенно завершаются, а новые запускаются. Приложение обновляется без простоя (Zero Downtime).

Что считается триггером для обновления?

Важно понимать: Deployment создает новый ReplicaSet только тогда, когда меняется секция template (шаблон Pod'а).

Если вы измените образ контейнера, добавите новый Volume, поменяете метки внутри шаблона или обновите переменные окружения — хеш шаблона изменится, и начнется процесс обновления (Rollout).

Но если вы просто измените поле replicas (например, с 3 на 5), чтобы масштабировать приложение под нагрузкой, template останется прежним. Deployment не станет создавать новый ReplicaSet. Он просто обновит поле replicas в текущем ReplicaSet, и тот досоздаст два недостающих Pod'а.

Управление историей: почему старые ReplicaSet не удаляются

Если после успешного обновления вы выполните команду kubectl get rs, вы увидите странную картину:

NAME                          DESIRED   CURRENT   READY   AGE
nginx-deploy-5c689d88bb       0         0         0       10m
nginx-deploy-7b4859c984       3         3         3       2m

Старый ReplicaSet (заканчивающийся на 5c689d88bb) никуда не исчез. Его счетчик DESIRED равен нулю, поэтому он не потребляет вычислительные ресурсы узлов, но сам объект остался в базе данных etcd.

Это не мусор. Это история ревизий (Revision History).

Каждый ReplicaSet, управляемый Deployment, представляет собой конкретную версию вашего приложения, зафиксированную в прошлом. Сохраняя старые ReplicaSet с нулевым количеством реплик, Kubernetes дает вам возможность мгновенного отката (Rollback).

Если новая версия nginx:1.15 начала падать с ошибками, контроллер Deployment может просто взять старый ReplicaSet, увеличить его replicas до 3, и уменьшить replicas проблемного нового ReplicaSet до 0. Вам не нужно вспоминать, какой YAML был развернут вчера — кластер помнит это сам.

По умолчанию Kubernetes хранит 10 последних ревизий. Это поведение регулируется полем revisionHistoryLimit в спецификации Deployment.

Анатомия манифеста Deployment

Синтаксис Deployment практически идентичен ReplicaSet. Разница лишь в поле kind и некоторых специфичных настройках обновления.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-frontend
spec:
  replicas: 3
  revisionHistoryLimit: 5 # Хранить только 5 старых ReplicaSet
  selector:
    matchLabels:
      app: frontend
  template:
    metadata:
      labels:
        app: frontend
    spec:
      containers:
      - name: web
        image: nginx:1.21

Обратите внимание на жесткую связку, о которой мы говорили в прошлой главе: метки в template.metadata.labels должны строго соответствовать условиям в selector.matchLabels. Иначе Deployment не примет манифест (API-сервер отклонит его на этапе валидации).

Диагностика базовых ошибок

Самая частая проблема при работе с Deployment — зависшее обновление. Вы применили новый YAML, но приложение не обновляется.

Если вы выполните kubectl get rs, то увидите примерно следующее:

NAME                          DESIRED   CURRENT   READY   AGE
my-frontend-old-hash          2         2         2       5d
my-frontend-new-hash          2         2         0       10m

Что здесь произошло? Контроллер Deployment начал обновление. Он создал новый ReplicaSet и попросил его запустить новые Pod'ы. Старый ReplicaSet он успел уменьшить с 3 до 2. Но новые Pod'ы не переходят в статус READY (например, из-за опечатки в имени образа — ImagePullBackOff, или приложение падает при старте — CrashLoopBackOff).

Поскольку новые Pod'ы не готовы обслуживать трафик, Deployment приостанавливает процесс. Он не будет убивать оставшиеся старые Pod'ы, чтобы не оставить пользователей вообще без работающего сервиса.

Именно здесь кроется магия безопасного обновления. Но как именно Deployment решает, сколько старых Pod'ов можно убить, а сколько новых создавать одновременно? За это отвечают параметры maxSurge и maxUnavailable, которые мы детально разберем в следующей главе.

Механика Rolling Update: стратегии maxSurge и maxUnavailable в деталях

Механика Rolling Update: стратегии maxSurge и maxUnavailable в деталях

В прошлой главе мы выяснили, что при изменении шаблона Pod'а Deployment создает новый ReplicaSet. Но как именно происходит передача эстафеты? Если просто удалить старый ReplicaSet и создать новый, пользователи столкнутся с даунтаймом — приложение будет недоступно, пока скачиваются образы и запускаются новые процессы.

Чтобы избежать простоя, Deployment использует стратегию постепенного замещения. В этой статье мы разберем математику этого процесса и научимся управлять «шириной шага» при обновлениях.

Две стратегии: Recreate против RollingUpdate

В манифесте Deployment в секции spec.strategy.type можно указать один из двух вариантов поведения:

  1. Recreate (Пересоздание): Сначала старый ReplicaSet масштабируется до нуля. Deployment ждет полного удаления всех старых Pod'ов, и только затем масштабирует новый ReplicaSet до нужного количества.

    • Плюс: Гарантирует, что две разные версии приложения никогда не работают одновременно (важно для некоторых схем баз данных).
    • Минус: 100% даунтайм на время запуска новых Pod'ов.
  2. RollingUpdate (Постепенное обновление): Включена по умолчанию. Deployment одновременно управляет двумя ReplicaSet, поштучно (или группами) добавляя новые Pod'ы и удаляя старые. Приложение ни на секунду не перестает обрабатывать трафик.

Чтобы RollingUpdate работал плавно, контроллер должен знать границы дозволенного: насколько сильно можно превысить запрошенное количество реплик и сколькими репликами можно пожертвовать в процессе. За это отвечают два параметра: maxSurge и maxUnavailable.

Анатомия параметров RollingUpdate

Оба параметра настраиваются в секции spec.strategy.rollingUpdate. Их можно указывать как в абсолютных числах (например, 2), так и в процентах от общего числа реплик (например, 25%). По умолчанию в Kubernetes оба параметра равны 25%.

maxSurge (Максимальный всплеск)

Определяет, на какое количество Pod'ов можно превысить желаемое состояние (replicas) во время обновления.

Если у вас запрошено 10 реплик, а maxSurge равен 2, то в процессе обновления суммарное количество Pod'ов (старых и новых вместе) никогда не превысит 12.

Формула верхней границы: Podsmax=replicas+maxSurgePods_{max} = replicas + maxSurge

Пример: Если replicas=10replicas = 10, а maxSurge=2maxSurge = 2, контроллер имеет право сразу создать 2 новых Pod'а, не дожидаясь удаления старых. Это требует дополнительных ресурсов (CPU/RAM) на Worker Nodes.

maxUnavailable (Максимальная недоступность)

Определяет, какое количество Pod'ов от желаемого состояния (replicas) может быть недоступно во время обновления.

Если у вас запрошено 10 реплик, а maxUnavailable равен 2, то контроллер гарантирует, что в любой момент времени обновления у вас будет как минимум 8 рабочих Pod'ов (старой или новой версии — неважно).

Формула нижней границы: Podsmin=replicasmaxUnavailablePods_{min} = replicas - maxUnavailable

Пример: Если replicas=10replicas = 10, а maxUnavailable=2maxUnavailable = 2, контроллер имеет право немедленно удалить 2 старых Pod'а в самом начале обновления, даже если новые еще не готовы.

Как они работают вместе: танец контроллера

Рассмотрим пошаговый процесс обновления Deployment, у которого replicas=4replicas = 4, maxSurge=1maxSurge = 1, maxUnavailable=1maxUnavailable = 1.

Исходя из формул:

  • Максимум Pod'ов одновременно: 4+1=54 + 1 = 5.
  • Минимум доступных Pod'ов: 41=34 - 1 = 3.

Шаг 1: Инициализация Старый ReplicaSet имеет 4 готовых Pod'а. Вы меняете образ в шаблоне. Deployment создает новый ReplicaSet с 0 реплик.

Шаг 2: Первый сдвиг Контроллер смотрит на лимиты. Он может удалить 1 старый Pod (потому что maxUnavailable=1maxUnavailable = 1, останется 3) и может добавить новые так, чтобы в сумме стало не больше 5 (maxSurge=1maxSurge = 1). Он отдает команду:

  • Старому ReplicaSet: уменьшиться до 3.
  • Новому ReplicaSet: увеличиться до 2 (3 старых + 2 новых = 5, что равно PodsmaxPods_{max}).

Шаг 3: Ожидание готовности Контроллер ждет. Новый ReplicaSet создал 2 Pod'а, но они пока в статусе ContainerCreating. В этот момент приложение обслуживают 3 старых Pod'а. Условие Podsmin3Pods_{min} \geq 3 выполнено.

Шаг 4: Готовность и следующий шаг Как только один из новых Pod'ов переходит в состояние Ready, общее число доступных Pod'ов становится 4 (3 старых + 1 новый). Теперь контроллер может удалить еще один старый Pod, чтобы освободить место для следующего нового, не нарушая правило maxUnavailablemaxUnavailable.

Процесс повторяется, пока старый ReplicaSet не достигнет 0, а новый — 4.

Популярные стратегии настройки

В зависимости от инфраструктуры и требований бизнеса, параметры можно комбинировать для достижения разных целей.

Стратегия Настройки Описание и применение
Консервативная (Zero capacity loss) maxUnavailable: 0<br>maxSurge: 25% Гарантирует, что емкость приложения никогда не упадет ниже 100%. Контроллер сначала поднимет новые Pod'ы, дождется их готовности, и только потом удалит старые. Требует запаса ресурсов на узлах для одновременной работы старых и новых версий.
Агрессивная (Resource constrained) maxUnavailable: 25%<br>maxSurge: 0 Не требует дополнительных ресурсов кластера. Контроллер сначала убивает часть старых Pod'ов, освобождая место на узлах, и на это место планирует новые. Снижает пропускную способность приложения на время обновления.
Быстрая (Fast replacement) maxUnavailable: 50%<br>maxSurge: 50% Обновление пройдет очень быстро, так как контроллер будет менять Pod'ы большими пачками. Подходит для некритичных внутренних сервисов, где кратковременная просадка доступности допустима.

Что будет, если кластеру не хватит ресурсов?

Представьте ситуацию: вы используете консервативную стратегию (maxUnavailable=0maxUnavailable = 0, maxSurge=25%maxSurge = 25\%). Deployment пытается создать новые Pod'ы, но на Worker Nodes нет свободного места (исчерпаны Allocatable ресурсы).

Что произойдет?

  1. Новый ReplicaSet создаст Pod'ы, но kube-scheduler не сможет привязать их к узлам. Они зависнут в статусе Pending.
  2. Так как новые Pod'ы не становятся Ready, Deployment не имеет права удалять старые Pod'ы (ведь maxUnavailable=0maxUnavailable = 0).
  3. Обновление зависнет.

Это отличный пример того, как декларативный подход защищает систему. В императивном скрипте вы могли бы случайно удалить старые процессы до того, как поняли, что для новых нет серверов. Kubernetes же останавливает процесс (Rollout), но старая версия приложения продолжает штатно обрабатывать трафик.

YAML-конфигурация

На практике эти параметры задаются в манифесте Deployment следующим образом:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend-app
spec:
  replicas: 10
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 2
      maxUnavailable: 0
  selector:
    matchLabels:
      app: backend
  template:
    # ... метаданные и спецификация Pod'а ...

Примечание: Если вы используете проценты, система округляет значения. Для maxSurge округление идет в большую сторону (ceil), а для maxUnavailable — в меньшую (floor). Это сделано для максимальной безопасности: лучше создать чуть больше Pod'ов, чем сделать недоступным больше, чем разрешил администратор.

В этой главе мы разобрали физику процесса обновления. Однако в реальном production обновления часто идут не по плану: приложение падает при старте, база данных не принимает миграции, или логика оказывается сломанной. В следующей главе мы изучим команды kubectl rollout, которые позволяют ставить обновления на паузу, проверять их статус и экстренно откатываться на предыдущие версии.

Откат и пауза: управление жизненным циклом релиза через Rollout

Откат и пауза: управление жизненным циклом релиза через Rollout

Вы применили новый манифест Deployment. Образ скачался, контейнеры запустились, но внутри приложения оказалась критическая ошибка: оно падает при подключении к базе данных. Согласно стратегии RollingUpdate, контроллер уже начал убивать старые Pod'ы (в пределах maxUnavailable) и создавать новые. Если ничего не предпринять, через пару минут вы получите 100% даунтайм.

Как остановить этот процесс в полете? Как быстро вернуть все назад, не переписывая YAML-файлы в панике? Для управления процессом обновления Kubernetes предоставляет механизм rollout.

Наблюдение за релизом: History и Status

Прежде чем управлять релизом, нужно понять, в каком состоянии он находится. Когда вы выполняете kubectl apply, процесс обновления уходит в фон. Чтобы следить за ним в реальном времени, используется команда:

kubectl rollout status deployment/my-app

Она блокирует терминал и выводит лог событий обновления: сколько Pod'ов обновлено, сколько ожидается. Если обновление зависнет, команда будет ждать до истечения таймаута (по умолчанию 10 минут, параметр progressDeadlineSeconds), после чего выдаст ошибку.

Чтобы посмотреть, какие версии вообще существуют, заглянем в историю:

kubectl rollout history deployment/my-app

Вывод покажет список ревизий (Revision). Каждая ревизия — это физический объект ReplicaSet, который Deployment сохранил в системе. По умолчанию колонка CHANGE-CAUSE пуста. Чтобы там отображалась полезная информация (например, номер тикета или версия), хорошей практикой является использование аннотации kubernetes.io/change-cause в манифесте Deployment.

Хирургическое вмешательство: Pause и Resume

Иногда вам нужно остановить выкат намеренно. Классический пример — Canary Deployment (канареечный релиз). Вы хотите выпустить новую версию только на один Pod, пустить туда часть трафика, посмотреть метрики и логи, и только потом обновить остальные.

Для этого используется пауза:

kubectl rollout pause deployment/my-app

Как это работает внутри: Когда вы ставите Deployment на паузу, его контроллер перестает реагировать на изменения желаемого состояния. Он фиксирует текущее количество реплик в старом и новом ReplicaSet и больше не пытается их масштабировать.

Интересный побочный эффект: пока Deployment на паузе, вы можете сколько угодно раз менять его template (например, обновлять переменные окружения, менять лимиты ресурсов). Kubernetes будет накапливать эти изменения, но не создаст новый ReplicaSet и не начнет обновление, пока вы не снимете паузу. Это позволяет сделать несколько правок и выкатить их одним релизом, избегая создания десятков промежуточных ReplicaSet.

Снятие с паузы:

kubectl rollout resume deployment/my-app

Путешествие во времени: Rollout Undo

Если канареечный тест провалился или в production улетела критическая бага, нужно срочно откатываться. Эту задачу решает команда undo:

kubectl rollout undo deployment/my-app

По умолчанию она откатывает Deployment на предыдущую ревизию. Если нужно откатиться на конкретную (например, на стабильную версию недельной давности), используется флаг --to-revision=N.

Как откат работает под капотом: Deployment не извлекает старый YAML из базы данных и не создает новый ReplicaSet с нуля. Он обращается к механизму Revision History.

  1. Контроллер находит в кластере старый ReplicaSet, соответствующий нужной ревизии (тот самый, у которого сейчас 0 реплик).
  2. Он берет template из этого старого ReplicaSet и копирует его в текущий Deployment.
  3. Старому ReplicaSet присваивается новый, самый старший номер ревизии (например, если вы откатились с 3 на 2, то ревизия 2 становится ревизией 4).
  4. Запускается стандартный процесс RollingUpdate: старый-новый ReplicaSet масштабируется вверх, а дефектный — вниз.

Именно поэтому откат происходит мгновенно: Kubernetes не нужно заново вычислять хэши и создавать объекты, он просто меняет replicas у уже существующих контроллеров.

Слепой контроллер: почему RollingUpdate нуждается в Readiness Probe

В прошлой главе мы разобрали параметры maxSurge и maxUnavailable. Но есть один критический нюанс: как контроллер Deployment понимает, что новый Pod успешно запустился и можно убивать следующий старый Pod?

По умолчанию Kubernetes считает Pod "готовым" (Ready), как только процесс с PID 1 внутри контейнера запустился и не упал. Но для реальных приложений этого недостаточно. Java-приложению может потребоваться 30 секунд на прогрев кэшей, а Node.js серверу — время на установку соединения с базой данных.

Если контроллер будет опираться только на статус процесса, он стремительно создаст новые Pod'ы (которые еще не готовы обрабатывать запросы), сочтет их успешными и убьет все старые рабочие Pod'ы. Результат — даунтайм.

Чтобы сделать обновление по-настоящему безопасным (Zero Downtime), контроллеру нужен "зрячий" поводырь. Эту роль выполняет Readiness Probe (проба готовности).

Readiness Probe — это правило в конфигурации Pod'а (например, HTTP-запрос на /healthz или выполнение скрипта), которое kubelet регулярно проверяет. Только когда проба возвращает успех, Pod получает статус Ready и его IP-адрес добавляется в балансировщик нагрузки (Service).

Как Readiness Probe спасает RollingUpdate: Контроллер Deployment жестко привязан к статусу Ready. При обновлении он создает новый Pod и ждет. Пока Readiness Probe не станет успешной, этот Pod считается недоступным (Unavailable). Если количество недоступных Pod'ов достигает лимита maxUnavailable, контроллер останавливает выкат.

Именно связка maxUnavailable и Readiness Probe гарантирует, что сломанный релиз остановится сам, сохранив достаточное количество старых реплик для обслуживания пользователей.

Практикум: Реализация Zero Downtime Deployment и отладка неудачных обновлений

Практикум: Реализация Zero Downtime Deployment и отладка неудачных обновлений

Вы настроили Deployment, выставили maxSurge: 1 и maxUnavailable: 0. Вы запускаете kubectl apply, контроллер аккуратно создает новые Pod'ы, ждет их запуска и плавно удаляет старые. Команда kubectl rollout status рапортует об успехе. Но в графиках мониторинга вы видите всплеск ошибок 502 Bad Gateway ровно в момент обновления. Как такое возможно, если контроллер ни на секунду не опускал количество доступных реплик ниже нормы?

Истинный Zero Downtime Deployment (ZDD) — это не только правильная математика реплик в Deployment. Это синхронизация трех независимых процессов: запуска приложения, маршрутизации сетевого трафика и корректного завершения старых соединений.

Невидимый мост: Service и Readiness Probe

В предыдущих главах мы выяснили, что Deployment управляет количеством Pod'ов, а Readiness Probe блокирует удаление старых версий, пока новые не будут готовы. Но кто направляет пользовательский трафик?

Этим занимается абстракция Service. Она непрерывно сканирует кластер в поисках Pod'ов с нужными метками (Labels). Однако Service не отправляет трафик на любой найденный Pod. Он отправляет его только на те Pod'ы, чьи IP-адреса добавлены в специальный системный список — Endpoints.

Правило Kubernetes гласит: IP-адрес Pod'а попадает в Endpoints только тогда, когда его Readiness Probe возвращает успех. Если проба падает, IP мгновенно вычеркивается из списка, и новые запросы перестают на него поступать.

Без Readiness Probe контроллер считает Pod готовым ровно в ту секунду, когда стартовал процесс (PID 1) внутри контейнера. Но Java-приложению может потребоваться 30 секунд на прогрев кэшей и подключение к БД. Если пробы нет, Service сразу направит трафик на еще «глухой» контейнер, а Deployment радостно убьет старую реплику. Результат — даунтайм.

Анатомия проб: Liveness, Readiness и Startup

Чтобы управлять состоянием приложения, Kubernetes предоставляет три типа проб. У них одинаковый синтаксис (HTTP-запрос, TCP-сокет или выполнение команды exec), но принципиально разное назначение.

Тип пробы Назначение Реакция Kubernetes на провал пробы
Readiness Готово ли приложение принимать трафик? Исключает Pod из Endpoints. Контейнер не перезапускается.
Liveness Живо ли приложение в принципе (не зависло ли в deadlock)? Kubelet убивает контейнер и перезапускает его (согласно restartPolicy).
Startup Успело ли тяжелое приложение запуститься? Блокирует выполнение Liveness/Readiness, пока не пройдет успешно. При провале — рестарт.

Каждая проба настраивается математически. Рассмотрим ключевые параметры на примере:

readinessProbe:
  httpGet:
    path: /healthz
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 10
  failureThreshold: 3
  successThreshold: 1
  timeoutSeconds: 2
  • initialDelaySeconds — пауза перед первым опросом.
  • periodSeconds — как часто опрашивать (здесь — каждые 10 секунд).
  • failureThreshold — сколько подряд неудач нужно, чтобы признать пробу проваленной.
  • timeoutSeconds — сколько ждать ответа на один запрос.

Время, через которое Kubernetes отреагирует на зависшее приложение, вычисляется по формуле: Tfail=periodSeconds×failureThresholdT_{fail} = periodSeconds \times failureThreshold. В нашем примере трафик перестанет идти на Pod через 10×3=3010 \times 3 = 30 секунд после реальной поломки.

Иллюзия Graceful Shutdown и гонка состояний

Допустим, вы настроили Readiness Probe. Новые Pod'ы прогреваются и только потом получают трафик. Но ошибки 502 при обновлении всё ещё появляются. Почему?

Проблема кроется в моменте удаления старого Pod'а. Когда Deployment решает удалить старую реплику, запускаются два параллельных процесса:

  1. kube-apiserver убирает IP-адрес Pod'а из Endpoints. Это запускает асинхронное обновление правил iptables на всех Worker Nodes, чтобы прекратить маршрутизацию новых запросов на этот Pod.
  2. kubelet отправляет процессу в контейнере сигнал SIGTERM, требуя завершить работу.

Это классическая гонка состояний (race condition). Обновление iptables через kube-proxy занимает время (иногда до секунды). А приложение, получив SIGTERM, может завершиться за миллисекунды. В результате Pod уже мертв, а iptables на узлах всё ещё отправляют туда доли секунды пользовательского трафика. Эти запросы падают в пустоту.

Решение: preStop hook

Чтобы разорвать гонку, мы должны заставить Pod подождать, пока кластер обновит сетевые правила, и только потом начинать остановку. Для этого используется preStop hook — команда, которая выполняется до отправки SIGTERM.

Самый популярный и надежный паттерн — заставить контейнер просто «поспать» несколько секунд:

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

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

  1. Pod помечается на удаление.
  2. IP удаляется из Endpoints (начинается обновление сети).
  3. Запускается preStop (контейнер спит 5 секунд, продолжая честно обрабатывать старые соединения).
  4. За время сна iptables во всем кластере обновляются. Новые запросы сюда больше не идут.
  5. Завершается sleep, контейнер получает SIGTERM и корректно закрывает БД-коннекты.

Важно согласовать это с параметром terminationGracePeriodSeconds (по умолчанию 30 секунд). Это жесткий таймер. Если preStop + время на обработку SIGTERM превысят этот лимит, kubelet безжалостно убьет процесс сигналом SIGKILL. Правило: TgraceTpreStop+TshutdownT_{grace} \geq T_{preStop} + T_{shutdown}.

Практикум: Идеальный манифест ZDD

Соберем все знания в единый production-ready манифест Deployment, который гарантирует обновление без потери единого запроса.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend-api
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1        # Создаем по одному новому Pod'у
      maxUnavailable: 0  # Никакого снижения емкости во время апдейта
  selector:
    matchLabels:
      app: backend
  template:
    metadata:
      labels:
        app: backend
    spec:
      terminationGracePeriodSeconds: 40 # Даем больше времени на остановку
      containers:
      - name: api
        image: my-api:v2.0
        ports:
        - containerPort: 8080

        # 1. Защита от преждевременного трафика
        readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5

        # 2. Защита от зависаний в процессе работы
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 15
          periodSeconds: 20

        # 3. Защита от потери соединений при удалении
        lifecycle:
          preStop:
            exec:
              command: ["/bin/sh", "-c", "sleep 10"]

Отладка неудачных обновлений в бою

Даже идеальный манифест не спасет, если вы выкатили образ с багом. Допустим, вы применили манифест выше, но приложение версии v2.0 падает при подключении к БД.

Вы пишете kubectl get pods и видите:

  • 3 Pod'а старой версии в статусе Running (и они принимают трафик).
  • 1 Pod новой версии в статусе CrashLoopBackOff.
  • Обновление зависло.

Ваши действия как инженера:

Шаг 1. Понять, на каком этапе мы застряли. kubectl rollout status deployment/backend-api Вывод покажет, что ожидается завершение обновления (Waiting for rollout to finish: 1 out of 3 new replicas have been updated).

Шаг 2. Найти проблемный ReplicaSet. kubectl get rs Вы увидите два ReplicaSet: старый с DESIRED 3 / READY 3 и новый с DESIRED 1 / READY 0. Deployment остановил выкат, потому что сработал лимит maxUnavailable: 0 — он не может удалить старый Pod, пока новый не станет Ready.

Шаг 3. Изучить логи падающего Pod'а. kubectl logs -l app=backend --tail=50 Если контейнер рестартится, посмотрите логи предыдущей попытки запуска: kubectl logs -l app=backend --previous

Шаг 4. Откатить релиз. Поняв, что проблема в коде, вы возвращаете кластер в стабильное состояние одной командой: kubectl rollout undo deployment/backend-api

Контроллер мгновенно переключит DESIRED у нового (сломанного) ReplicaSet в 0, убьет падающий Pod и вернет систему к исходному состоянию. Пользователи даже не узнают, что была попытка выката сбоящего кода, так как трафик всё это время шел только на старые, успешно проходящие Readiness Probe реплики.