Kubernetes для самых маленьких. Часть 7: Как работают Deployment и ReplicaSet?

Все части серии «Kubernetes для самых маленьких»

В прошлой части мы разобрали под: как он стартует, как kubelet проверяет его здоровье и как он аккуратно останавливается. Но под сам по себе беззащитен. Удалили — его нет. Узел k8s-node-1 выключился — поды на нём пропали, и никто не создаст их заново на k8s-node-2. Нужно четыре одинаковых пода — создавайте четыре манифеста с разными именами. Нужно обновить версию — удаляйте и создавайте по одному, следя, чтобы приложение не легло. Всё это делают контроллеры, и для приложений без состояния главный из них — Deployment, который управляет подами не напрямую, а через ReplicaSet.

Цикл согласования — контроллер сравнивает желаемое состояние из spec с фактическим и устраняет разницу — был описан в первой части, а то, как контроллеры следят за объектами через watch, — в третьей. Здесь посмотрим на два конкретных контроллера: как они устроены, как вместе выкатывают новую версию hello и как её откатывают.


Цепочка контроллеров

 Deployment hello                       spec: 4 реплики, шаблон T2, стратегия
   │
   │  deployment-controller (kube-controller-manager)
   │
   ├──► ReplicaSet hello-8c9694f75      шаблон T1 (hello:1.0), replicas: 0   ревизия 1
   └──► ReplicaSet hello-85854d9fd8     шаблон T2 (hello:1.1), replicas: 4   ревизия 2
          │
          │  replicaset-controller (kube-controller-manager)
          │
          ├──► Pod hello-85854d9fd8-4mjwb
          ├──► Pod hello-85854d9fd8-cmcqg
          ├──► Pod hello-85854d9fd8-ptchf
          └──► Pod hello-85854d9fd8-z4h9z

Работа разделена так:

  • ReplicaSet отвечает за одно: чтобы подов по его шаблону было ровно replicas. Меньше — создаёт, больше — удаляет. Что в шаблоне и зачем, ему безразлично.
  • Deployment отвечает за версии. Каждый вариант шаблона пода — отдельный ReplicaSet. Обновление шаблона — это новый ReplicaSet и постепенный перенос реплик со старого на новый. Старые ReplicaSet-ы с нулём реплик остаются как история, к которой можно вернуться.

Оба контроллера — циклы внутри процесса kube-controller-manager на мастере k8s-master-1. Ни один из них не ходит на узлы: они только создают, меняют и удаляют объекты в API. Поды на узлах запускает kubelet, как в прошлой части.

Связи между объектами записаны в metadata.ownerReferences: у ReplicaSet владелец — Deployment, у пода — ReplicaSet, с флагом controller: true. По этим ссылкам работает сборщик мусора в controller-manager: при удалении Deployment он удаляет его ReplicaSet-ы, а за ними и поды. kubectl delete deployment hello --cascade=orphan удаляет только сам Deployment: сборщик мусора убирает у его ReplicaSet-ов ссылку на владельца, и они вместе с подами остаются сиротами — работают дальше, но ими больше никто не управляет.


ReplicaSet

hello-rs.yaml:

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: hello-rs
spec:
  replicas: 3
  selector:
    matchLabels:
      app.kubernetes.io/name: hello-rs
  template:
    metadata:
      labels:
        app.kubernetes.io/name: hello-rs
    spec:
      containers:
      - name: hello
        image: ghcr.io/example/hello:1.0

Три поля: сколько (replicas), какие поды считать своими (selector) и по какому шаблону создавать недостающие (template). Метки шаблона обязаны подходить под селектор, иначе API-сервер не примет объект: контроллер создавал бы поды, которых сам не видит, и плодил бы их бесконечно. Если в шаблоне написать app.kubernetes.io/name: other, ответ будет таким:

$ kubectl apply -f hello-rs.yaml
The ReplicaSet "hello-rs" is invalid: spec.template.metadata.labels: Invalid value: {"app.kubernetes.io/name":"other"}: `selector` does not match template `labels`

Метки, селекторы и соглашения об именах app.kubernetes.io/* подробно — в части 11. ReplicaSet пришёл на смену ReplicationController — объекту с тем же назначением, но с селектором только по точному равенству меток; ReplicationController всё ещё есть в API ради совместимости, но для нового его не используют.

Как работает цикл replicaset-controller:

  1. Через watch он получает изменения ReplicaSet-ов и подов в свой локальный кеш (informer) и ставит изменившийся ReplicaSet в очередь.
  2. Для ReplicaSet из очереди он находит поды, подходящие под селектор: свои (с ownerReference на этот ReplicaSet) и бесхозные. Бесхозный под с подходящими метками ReplicaSet усыновляет — дописывает ему ownerReference. Свой под, переставший подходить под селектор (кто-то поменял ему метку), он отпускает.
  3. Считает активные поды (не завершённые и не удаляемые) и сравнивает с replicas. Не хватает — создаёт поды по шаблону с именами <имя ReplicaSet>-<5 случайных символов>. Лишние — удаляет.
  4. Записывает в status число подов, готовых и доступных.

Чтобы не создать лишнего, пока свежесозданные поды ещё не дошли до кеша, контроллер ведёт expectations — «я создал три пода и жду, что увижу три события о создании». Пока ожидание не исполнилось, следующий проход для этого ReplicaSet пропускается.

Какие поды удаляются при уменьшении replicas, тоже не случайно: сначала ещё не назначенные на узел и не запущенные (Pending), затем неготовые, затем поды с меньшей «стоимостью удаления» — аннотацией controller.kubernetes.io/pod-deletion-cost, — затем те, что на узлах с большим числом реплик этого ReplicaSet, затем более молодые (время создания сравнивается грубо, по логарифмической шкале). Аннотацией и можно повлиять на выбор: поды с меньшей стоимостью удаляются первыми.

Почему не создавать ReplicaSet напрямую

ReplicaSet не умеет обновлять поды. Поменяйте в нём образ на hello:1.1 — работающие поды останутся на 1.0: шаблон используется только при создании новых, и отличия существующих подов от шаблона контроллер не интересуют. Новая версия появится только у подов, созданных после правки, — то есть после того, как старые кто-то удалит. Отката, истории, контроля скорости замены у ReplicaSet нет.

Второй сюрприз — усыновление. ReplicaSet с селектором app.kubernetes.io/name: hello-rs заберёт себе любой бесхозный под с такой меткой в неймспейсе, созданный руками или другим инструментом, и при превышении replicas может удалить именно его. Оба эффекта видно за минуту:

$ kubectl apply -f hello-rs.yaml
replicaset.apps/hello-rs created
$ kubectl run stray --image=ghcr.io/example/hello:1.0 --labels=app.kubernetes.io/name=hello-rs
pod/stray created
$ kubectl get pods -l app.kubernetes.io/name=hello-rs
NAME             READY   STATUS              RESTARTS   AGE
hello-rs-8x855   1/1     Running             0          11s
hello-rs-sbbkz   0/1     ContainerCreating   0          11s
hello-rs-v9247   1/1     Running             0          11s
stray            0/1     Terminating         0          3s
$ kubectl get pod stray -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}{"\n"}'
ReplicaSet/hello-rs
$ kubectl set image replicaset/hello-rs hello=ghcr.io/example/hello:1.1
replicaset.apps/hello-rs image updated
$ kubectl get pods -l app.kubernetes.io/name=hello-rs -o custom-columns=NAME:.metadata.name,IMAGE:.spec.containers[0].image
NAME             IMAGE
hello-rs-8x855   ghcr.io/example/hello:1.0
hello-rs-sbbkz   ghcr.io/example/hello:1.0
hello-rs-v9247   ghcr.io/example/hello:1.0
stray            ghcr.io/example/hello:1.0

Под stray ReplicaSet усыновил (ownerReference уже указывает на hello-rs), насчитал четыре пода при replicas: 3 и удалил лишний — самый молодой, то есть чужой. А смена образа в шаблоне не тронула ни одного работающего пода.

Поэтому ReplicaSet-ы создают только через Deployment. У селектора ReplicaSet, созданного Deployment-ом, есть дополнительная метка pod-template-hash, и чужие поды под него не попадают.

$ kubectl delete replicaset hello-rs
replicaset.apps "hello-rs" deleted from default namespace

Deployment

hello-deploy.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello
  labels:
    app.kubernetes.io/name: hello
  annotations:
    kubernetes.io/change-cause: "hello 1.0"
spec:
  replicas: 4
  revisionHistoryLimit: 5
  minReadySeconds: 5
  progressDeadlineSeconds: 120
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app.kubernetes.io/name: hello
  template:
    metadata:
      labels:
        app.kubernetes.io/name: hello
    spec:
      terminationGracePeriodSeconds: 30
      containers:
      - name: hello
        image: ghcr.io/example/hello:1.0
        ports:
        - name: http
          containerPort: 8080
        startupProbe:
          httpGet: {path: /healthz, port: http}
          periodSeconds: 2
          failureThreshold: 15
        livenessProbe:
          httpGet: {path: /healthz, port: http}
          periodSeconds: 10
          timeoutSeconds: 2
        readinessProbe:
          httpGet: {path: /readyz, port: http}
          periodSeconds: 5
          timeoutSeconds: 2
          failureThreshold: 2
        lifecycle:
          preStop:
            sleep:
              seconds: 5

Шаблон пода — тот же hello, что в прошлой части, без sidecar-а nginx: он был нужен, чтобы показать устройство пода. Поля Deployment, которые относятся к выкатке:

Поле По умолчанию Смысл
strategy.type RollingUpdate постепенная замена; второй вариант — Recreate
rollingUpdate.maxSurge 25% на сколько подов можно превысить replicas во время выкатки
rollingUpdate.maxUnavailable 25% на сколько подов число доступных может опуститься ниже replicas
minReadySeconds 0 сколько секунд под должен быть готов, прежде чем считаться доступным
progressDeadlineSeconds 600 сколько секунд выкатка может не продвигаться, прежде чем её объявят застрявшей
revisionHistoryLimit 10 сколько старых ReplicaSet-ов хранить для отката

Аннотация kubernetes.io/change-cause на самом Deployment — описание ревизии, которое покажет kubectl rollout history: при создании нового ReplicaSet контроллер копирует её туда. Поэтому её меняют вместе с образом, в той же правке. Старый способ — флаг --record у kubectl — объявлен устаревшим.

Селектор Deployment в apps/v1 неизменяем: после создания его не поменять ни apply, ни edit. Сменить метки приложения — значит создать новый Deployment. Поэтому селектор с самого начала делают узким и стабильным: только имя приложения, без версии и прочего, что будет меняться.

$ kubectl apply -f hello-deploy.yaml
deployment.apps/hello created
$ kubectl rollout status deployment/hello
Waiting for deployment spec update to be observed...
Waiting for deployment spec update to be observed...
Waiting for deployment "hello" rollout to finish: 0 out of 4 new replicas have been updated...
Waiting for deployment "hello" rollout to finish: 0 of 4 updated replicas are available...
Waiting for deployment "hello" rollout to finish: 0 of 4 updated replicas are available...
Waiting for deployment "hello" rollout to finish: 0 of 4 updated replicas are available...
Waiting for deployment "hello" rollout to finish: 0 of 4 updated replicas are available...
Waiting for deployment "hello" rollout to finish: 0 of 4 updated replicas are available...
Waiting for deployment "hello" rollout to finish: 1 of 4 updated replicas are available...
Waiting for deployment "hello" rollout to finish: 2 of 4 updated replicas are available...
Waiting for deployment "hello" rollout to finish: 3 of 4 updated replicas are available...
deployment "hello" successfully rolled out
$ kubectl get deployment,replicaset,pods -l app.kubernetes.io/name=hello
NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/hello   4/4     4            4           19s

NAME                              DESIRED   CURRENT   READY   AGE
replicaset.apps/hello-8c9694f75   4         4         4       19s

NAME                        READY   STATUS    RESTARTS   AGE
pod/hello-8c9694f75-4nf7n   1/1     Running   0          18s
pod/hello-8c9694f75-9ghql   1/1     Running   0          18s
pod/hello-8c9694f75-g2rqk   1/1     Running   0          18s
pod/hello-8c9694f75-j87gg   1/1     Running   0          19s

kubectl rollout status печатает строку на каждое изменение статуса Deployment, поэтому одинаковые строки повторяются. Колонки Deployment: READY — готовые поды из желаемых, UP-TO-DATE — поды с актуальным шаблоном, AVAILABLE — готовые не меньше minReadySeconds.

pod-template-hash

8c9694f75 в имени ReplicaSet — pod-template-hash: хеш шаблона пода, который вычисляет deployment-controller (до 10 символов, поэтому имена ReplicaSet-ов одного Deployment бывают разной длины). Он же записан меткой в шаблон ReplicaSet-а и в его селектор:

$ kubectl get replicaset hello-8c9694f75 -o jsonpath='{.spec.selector.matchLabels}{"\n"}'
{"app.kubernetes.io/name":"hello","pod-template-hash":"8c9694f75"}

Так поды разных ReplicaSet-ов одного Deployment не пересекаются: у всех метка app.kubernetes.io/name=hello, но каждый ReplicaSet видит только поды со своим хешем. Одинаковый шаблон даёт одинаковый хеш — поэтому, вернув шаблон к прежнему виду, Deployment не создаёт новый ReplicaSet, а находит старый. Метку pod-template-hash руками не трогают никогда.


Rolling update по шагам

Обновление начинается с любого изменения шаблона пода (spec.template): образа, переменных, проб, меток шаблона, аннотаций шаблона. Изменение replicas или полей стратегии выкаткой не является — это просто масштабирование или новая настройка.

Deployment-controller переносит реплики со старого ReplicaSet на новый в рамках двух ограничений:

  • всего подов (старых и новых, готовых и нет) не больше replicas + maxSurge;
  • доступных подов не меньше replicas − maxUnavailable.

Завершающиеся поды (Terminating) в оба счёта не входят: убранный старый под ещё несколько секунд выполняет preStop и обрабатывает последние запросы, а его место уже занимает новый. Поэтому на время выкатки подов и потребляемых ресурсов бывает больше, чем replicas + maxSurge.

Проценты пересчитываются в штуки: maxSurge округляется вверх, maxUnavailable — вниз, и оба не могут быть нулём одновременно — тогда выкатка не смогла бы сделать ни шага.

По умолчанию (25% и 25%) при replicas: 4 получается maxSurge = 1, maxUnavailable = 1: не больше 5 подов, не меньше 3 доступных. Так прошла выкатка на стенде (точный порядок зависит от того, когда поды становятся готовыми):

Шаг Что делает контроллер Старый RS Новый RS (доступны) Всего Доступно
0 — 4 0 4 4
1 новый +1 (до 5 всего); старый −1 (до 3 доступных) 3 1 (0) 4 3
2 новый +1: убранный старый под завершается и не считается, снова 5 3 2 (0) 5 3
3 два новых стали доступны: старый −1, новый +1 2 3 (2) 5 4
4 старый −1, новый +1 1 4 (2) 5 3
5 новые доступны: старый −1 0 4 (4) 4 4

Быстро — на стенде 25 секунд, — но в процессе доступно только три пода из четырёх: четверть мощности уходит на выкатку. Для приложения с четырьмя репликами, которые все нужны под нагрузкой, это заметно.

maxSurge: 1, maxUnavailable: 0 — то, что стоит в нашем манифесте: ни одного старого пода не убирают, пока ему на смену не пришёл доступный новый.

Шаг Что делает контроллер Старый RS Новый RS (доступны) Всего Доступно
0 — 4 0 4 4
1 новый +1 4 1 (0) 5 4
2 новый доступен: старый −1, новый +1 3 2 (1) 5 4
3 старый −1, новый +1 2 3 (2) 5 4
4 старый −1, новый +1 1 4 (3) 5 4
5 старый −1 0 4 (4) 4 4

Медленнее — по одному поду за шаг, на стенде 40 секунд, — зато мощность не падает ниже четырёх реплик, а на время выкатки нужен ресурс узлов всего на один лишний под (не считая завершающихся). Для небольшого числа реплик это лучший выбор. Обратный вариант, maxSurge: 0, maxUnavailable: 1, нужен, когда лишнего пода разместить негде: кластер забит или каждый под занимает уникальный ресурс.

minReadySeconds и readiness probe

«Доступный» — ключевое слово. Контроллер переходит к следующему шагу, только когда новые поды стали доступными: готовыми по readiness probe (условие Ready=True) и остающимися готовыми не меньше minReadySeconds. Отсюда прямая связь между пробами и безопасностью выкатки:

  • Без readinessProbe под считается готовым в момент старта контейнера. Если новая версия падает через секунду после старта или отвечает ошибками, контроллер этого не заметит и заменит все четыре пода на сломанные — выкатка «успешна».
  • С readinessProbe сломанная версия не станет готовой, и выкатка остановится на первом новом поде. Старые продолжат работать.
  • minReadySeconds ловит то, что readiness не поймает: приложение, которое проходит первую проверку и падает через несколько секунд (не дождалось базы, кончилась память на прогреве кеша). Под должен продержаться готовым minReadySeconds, прежде чем на его место уберут старый. У нас 5 секунд; для приложений с долгим прогревом — 30 и больше.

progressDeadlineSeconds

Если выкатка застряла — новый под не становится доступным, — контроллер ждёт progressDeadlineSeconds (у нас 120, по умолчанию 600) с момента последнего продвижения и выставляет в status.conditions условие Progressing со статусом False и причиной ProgressDeadlineExceeded. kubectl rollout status при этом завершается с ошибкой, и конвейер развёртывания, который его вызвал, узнаёт о провале.

Важно, чего Deployment не делает: он не откатывается сам. Новый ReplicaSet остаётся со своими неготовыми подами, старый — с работающими, и всё это ждёт решения человека или внешнего инструмента. Состояния условия Progressing:

status reason Значение
True NewReplicaSetCreated, FoundNewReplicaSet, ReplicaSetUpdated выкатка идёт
True NewReplicaSetAvailable выкатка завершена
False ProgressDeadlineExceeded выкатка застряла
Unknown DeploymentPaused Deployment на паузе (о ней ниже)

Rollover

Если поменять шаблон снова, пока предыдущая выкатка не закончилась, Deployment не ждёт её завершения: создаёт ещё один ReplicaSet и начинает переносить реплики на него, а недокатанный становится старым и сворачивается вместе с остальными. Промежуточная версия до конца так и не доедет — это нормально.


Выкатка hello 1.0 → 1.1

Собираем и загружаем новую версию — тот же Dockerfile из части 5, другая версия:

$ docker buildx build --platform linux/amd64,linux/arm64 \
    --build-arg VERSION=1.1 \
    -t ghcr.io/example/hello:1.1 --push .

Меняем в манифесте образ и описание ревизии и сначала смотрим, что именно изменится в кластере, — kubectl diff сравнивает файл с живым объектом на сервере:

$ sed -i 's/hello:1.0/hello:1.1/; s/"hello 1.0"/"hello 1.1"/' hello-deploy.yaml
$ kubectl diff -f hello-deploy.yaml
diff -u -N /tmp/LIVE-4064112325/apps.v1.Deployment.default.hello /tmp/MERGED-16719779/apps.v1.Deployment.default.hello
--- /tmp/LIVE-4064112325/apps.v1.Deployment.default.hello	2026-10-26 22:09:53.564641082 +0300
+++ /tmp/MERGED-16719779/apps.v1.Deployment.default.hello	2026-10-26 22:09:53.565641119 +0300
@@ -5,9 +5,9 @@
     deployment.kubernetes.io/revision: "1"
     kubectl.kubernetes.io/last-applied-configuration: |
       {"apiVersion":"apps/v1","kind":"Deployment","metadata":{"annotations":{"kubernetes.io/change-cause":"hello 1.0"},…}
-    kubernetes.io/change-cause: hello 1.0
+    kubernetes.io/change-cause: hello 1.1
   creationTimestamp: "2026-10-26T19:09:12Z"
-  generation: 1
+  generation: 2
   labels:
     app.kubernetes.io/name: hello
   name: hello
@@ -33,7 +33,7 @@
         app.kubernetes.io/name: hello
     spec:
       containers:
-      - image: ghcr.io/example/hello:1.0
+      - image: ghcr.io/example/hello:1.1
         imagePullPolicy: IfNotPresent
         lifecycle:
           preStop:

Строка last-applied-configuration здесь сокращена: это аннотация, в которую kubectl apply записывает весь применённый манифест одной строкой JSON, — к ней мы ещё вернёмся. generation вырастет на единицу: так API-сервер отмечает каждое изменение spec. imagePullPolicy: IfNotPresent в живом объекте — то самое умолчание из части 5, подставленное API-сервером при создании. Если различия есть, kubectl diff завершается с кодом 1, если нет — с 0, и это удобно проверять в скриптах.

Применяем и наблюдаем за ReplicaSet-ами:

$ kubectl apply -f hello-deploy.yaml
deployment.apps/hello configured
$ kubectl get replicaset -l app.kubernetes.io/name=hello -w
NAME              DESIRED   CURRENT   READY   AGE
hello-8c9694f75   4         4         4       47s
hello-85854d9fd8   1         0         0       0s
hello-85854d9fd8   1         0         0       0s
hello-85854d9fd8   1         1         0       0s
hello-85854d9fd8   1         1         1       5s
hello-85854d9fd8   1         1         1       10s
hello-8c9694f75    3         4         4       58s
hello-85854d9fd8   2         1         1       10s
hello-8c9694f75    3         4         4       58s
hello-8c9694f75    3         3         3       58s
hello-85854d9fd8   2         1         1       10s
hello-85854d9fd8   2         2         1       11s
hello-85854d9fd8   2         2         2       16s
hello-8c9694f75    3         3         3       65s
hello-85854d9fd8   2         2         2       20s
hello-8c9694f75    2         3         3       68s
…
hello-85854d9fd8   4         4         4       36s
hello-8c9694f75    1         1         1       85s
hello-85854d9fd8   4         4         4       40s
hello-8c9694f75    0         1         1       88s
hello-8c9694f75    0         1         1       88s
hello-8c9694f75    0         0         0       88s
hello-8c9694f75    0         0         0       95s

-w печатает строку на каждое изменение объекта, поэтому одинаковые строки идут подряд, а колонки выровнены только по первой. Это таблица maxSurge: 1, maxUnavailable: 0: новый под готов через 5 секунд, ещё 5 — minReadySeconds, и только после этого старый ReplicaSet уменьшается на один, а новый получает следующую реплику. Если образа 1.1 на узле ещё нет, к первому шагу добавляется время его скачивания. CURRENT у старого ReplicaSet падает сразу вместе с DESIRED: удаляемый под в нём уже не считается. Он ещё семь секунд завершается — preStop 5 секунд и выход по SIGTERM из прошлой части, — и строка с теми же числами через 7 секунд (65s после 58s) — это он окончательно исчез.

Те же шаги — в событиях Deployment:

$ kubectl describe deployment hello | sed -n '/^Events:/,$p'
Events:
  Type    Reason             Age   From                   Message
  ----    ------             ----  ----                   -------
  Normal  ScalingReplicaSet  100s  deployment-controller  Scaled up replica set hello-8c9694f75 from 0 to 4
  Normal  ScalingReplicaSet  52s   deployment-controller  Scaled up replica set hello-85854d9fd8 from 0 to 1
  Normal  ScalingReplicaSet  42s   deployment-controller  Scaled down replica set hello-8c9694f75 from 4 to 3
  Normal  ScalingReplicaSet  42s   deployment-controller  Scaled up replica set hello-85854d9fd8 from 1 to 2
  Normal  ScalingReplicaSet  32s   deployment-controller  Scaled down replica set hello-8c9694f75 from 3 to 2
  Normal  ScalingReplicaSet  32s   deployment-controller  Scaled up replica set hello-85854d9fd8 from 2 to 3
  Normal  ScalingReplicaSet  22s   deployment-controller  Scaled down replica set hello-8c9694f75 from 2 to 1
  Normal  ScalingReplicaSet  22s   deployment-controller  Scaled up replica set hello-85854d9fd8 from 3 to 4
  Normal  ScalingReplicaSet  12s   deployment-controller  Scaled down replica set hello-8c9694f75 from 1 to 0

Deployment-controller не трогает поды — только поле replicas двух ReplicaSet-ов. Поды создаёт и удаляет replicaset-controller.

Проверяем версию через port-forward к Deployment (kubectl выберет один из его подов):

$ kubectl port-forward deployment/hello 8080:8080 &
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080
$ curl -si http://127.0.0.1:8080/ | grep -E 'X-App-Version|hello from'
Handling connection for 8080
X-App-Version: 1.1
hello from hello-85854d9fd8-lnnbv
$ kill %1

История и откат

$ kubectl rollout history deployment/hello
deployment.apps/hello
REVISION  CHANGE-CAUSE
1         hello 1.0
2         hello 1.1

$ kubectl rollout history deployment/hello --revision=1 | grep -E 'Image|change-cause'
  Annotations:	kubernetes.io/change-cause: hello 1.0
    Image:	ghcr.io/example/hello:1.0

Ревизия — аннотация deployment.kubernetes.io/revision на ReplicaSet; история — это просто список ReplicaSet-ов этого Deployment, в том числе с нулём реплик. revisionHistoryLimit: 5 ограничивает, сколько таких пустых ReplicaSet-ов хранить; более старые удаляются, и откатиться к ним уже нельзя. revisionHistoryLimit: 0 отключает откат совсем.

Откат на предыдущую ревизию:

$ kubectl rollout undo deployment/hello
Warning: resource deployments/hello was previously managed with 'kubectl apply'. Rolling back will not update the kubectl.kubernetes.io/last-applied-configuration annotation, which may cause unexpected behavior on future 'kubectl apply' operations. Consider using 'kubectl apply' with your previous configuration file instead.
deployment.apps/hello rolled back
$ kubectl rollout status deployment/hello
…
deployment "hello" successfully rolled out
$ kubectl get replicaset -l app.kubernetes.io/name=hello
NAME               DESIRED   CURRENT   READY   AGE
hello-85854d9fd8   0         0         0       98s
hello-8c9694f75    4         4         4       2m10s
$ kubectl rollout history deployment/hello
deployment.apps/hello
REVISION  CHANGE-CAUSE
2         hello 1.1
3         hello 1.0

undo — не машина времени, а обычная выкатка: kubectl копирует шаблон из ReplicaSet нужной ревизии в Deployment (вместе с аннотацией change-cause), и всё идёт по тем же правилам maxSurge и maxUnavailable. Шаблон совпал со старым, хеш тот же — поэтому ожил прежний hello-8c9694f75, а не создался новый. Его ревизия стала 3: ревизия 1 исчезла из истории, потому что тот же ReplicaSet теперь последний. Вернуться к конкретной ревизии — kubectl rollout undo deployment/hello --to-revision=2.

Одна оговорка про undo в реальной работе, о которой kubectl предупреждает сам. Он меняет объект в кластере, но не файл hello-deploy.yaml и не аннотацию last-applied-configuration: в файле по-прежнему 1.1, и следующий kubectl apply снова выкатит 1.1. Если манифесты живут в Git, откатывают коммитом в Git и применяют его — так кластер и репозиторий не расходятся. undo — инструмент аварийной ситуации, после которого манифест приводят в соответствие. К этому мы вернёмся в части 41, где кластер синхронизируется из Git автоматически.

Вернём 1.1 — файл уже такой:

$ kubectl apply -f hello-deploy.yaml
deployment.apps/hello configured

Выкатка, которая не прошла

Ошибка в теге — версии 1.2 в реестре нет:

$ kubectl set image deployment/hello hello=ghcr.io/example/hello:1.2
deployment.apps/hello image updated
$ kubectl rollout status deployment/hello --timeout=3m
Waiting for deployment spec update to be observed...
Waiting for deployment "hello" rollout to finish: 0 out of 4 new replicas have been updated...
Waiting for deployment "hello" rollout to finish: 1 out of 4 new replicas have been updated...
error: deployment "hello" exceeded its progress deadline
$ kubectl get pods -l app.kubernetes.io/name=hello
NAME                     READY   STATUS             RESTARTS   AGE
hello-85854d9fd8-5zw99   1/1     Running            0          2m33s
hello-85854d9fd8-h87n6   1/1     Running            0          2m55s
hello-85854d9fd8-pqkhz   1/1     Running            0          2m22s
hello-85854d9fd8-rfhcx   1/1     Running            0          2m44s
hello-8d8f8775c-hk7jb    0/1     ImagePullBackOff   0          2m1s
$ kubectl get deployment hello -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'
Available=True MinimumReplicasAvailable
Progressing=False ProgressDeadlineExceeded

kubectl set image — императивная правка одного поля, здесь удобная для демонстрации; в обычной работе образ меняют в манифесте. Новый под не скачал образ (в его событиях — failed to resolve image: ghcr.io/example/hello:1.2: not found), перешёл в ErrImagePull, затем в ImagePullBackOff и не стал готовым. С maxUnavailable: 0 ни один из четырёх старых подов не тронут — приложение работает на 1.1 в полную силу. Через 120 секунд без продвижения Deployment объявил выкатку застрявшей, а rollout status вернул ошибку и код выхода 1. Условие Available=True говорит, что доступных подов не меньше, чем требует стратегия.

С настройками по умолчанию (maxUnavailable: 25%) картина была бы хуже: один старый под убрали бы сразу, и приложение работало бы на трёх подах из четырёх, пока кто-нибудь не вмешается.

Та же история со сломанной версией, которая образ скачивает, но не проходит readiness: выкатка встанет на первом же поде. Именно поэтому readiness probe и maxUnavailable: 0 — лучшая страховка от плохого релиза, которая есть у Deployment без внешних инструментов.

Заодно видно, чего стоит история без аннотации:

$ kubectl rollout history deployment/hello
deployment.apps/hello
REVISION  CHANGE-CAUSE
3         hello 1.0
4         hello 1.1
5         hello 1.1

Ревизия 5 — это 1.2, но set image аннотацию kubernetes.io/change-cause не трогал, и новый ReplicaSet получил прежнее описание. История честна ровно настолько, насколько аккуратно эту аннотацию меняют.

Откат:

$ kubectl rollout undo deployment/hello
Warning: resource deployments/hello was previously managed with 'kubectl apply'. Rolling back will not update the kubectl.kubernetes.io/last-applied-configuration annotation, which may cause unexpected behavior on future 'kubectl apply' operations. Consider using 'kubectl apply' with your previous configuration file instead.
deployment.apps/hello rolled back
$ kubectl get replicaset -l app.kubernetes.io/name=hello
NAME               DESIRED   CURRENT   READY   AGE
hello-85854d9fd8   4         4         4       4m39s
hello-8c9694f75    0         0         0       5m11s
hello-8d8f8775c    0         0         0       2m7s

Сломанный под удалён, hello-85854d9fd8 и не менялся. Пустой hello-8d8f8775c удалять руками не нужно: со временем его вытеснит revisionHistoryLimit. А в манифесте hello-deploy.yaml по-прежнему 1.1 — set image его не трогал, так что расхождения с файлом нет.


pause, resume, restart

kubectl rollout pause замораживает выкатку: изменения шаблона принимаются API-сервером, но новый ReplicaSet не создаётся. Это нужно, когда изменений несколько (образ, переменная, ресурсы) и выкатывать их хочется одной выкаткой, а не тремя:

$ kubectl rollout pause deployment/hello
deployment.apps/hello paused
$ kubectl set env deployment/hello GOMAXPROCS=2
deployment.apps/hello env updated
$ kubectl set env deployment/hello GOMEMLIMIT=96MiB
deployment.apps/hello env updated
$ kubectl get replicaset -l app.kubernetes.io/name=hello | grep -c hello
3
$ kubectl rollout undo deployment/hello
error: you cannot rollback a paused deployment; resume it first with 'kubectl rollout resume' and try again
$ kubectl rollout resume deployment/hello
deployment.apps/hello resumed
$ kubectl get replicaset -l app.kubernetes.io/name=hello
NAME               DESIRED   CURRENT   READY   AGE
hello-77b6f6d957   4         4         4       41s
hello-85854d9fd8   0         0         0       5m23s
hello-8c9694f75    0         0         0       5m55s
hello-8d8f8775c    0         0         0       2m51s

GOMAXPROCS и GOMEMLIMIT — переменные среды выполнения Go: сколько потоков ОС использовать для кода и мягкий предел памяти, при приближении к которому сборщик мусора работает активнее. Пока Deployment на паузе, ReplicaSet-ов по-прежнему три — новых не появилось, условие Progressing стоит в Unknown с причиной DeploymentPaused, а rollout undo отказывается работать. После resume обе правки уехали одной выкаткой — новым hello-77b6f6d957.

Здесь это был эксперимент, и вернуть Deployment к манифесту хочется простым kubectl apply -f hello-deploy.yaml. Но так не выйдет:

$ kubectl apply -f hello-deploy.yaml
deployment.apps/hello unchanged
$ kubectl get deployment hello -o jsonpath='{.spec.template.spec.containers[0].env}{"\n"}'
[{"name":"GOMAXPROCS","value":"2"},{"name":"GOMEMLIMIT","value":"96MiB"}]

kubectl apply удаляет из объекта только те поля, которые сам когда-то записал: их список он хранит в аннотации last-applied-configuration. Переменных там никогда не было — их добавил set env, — и apply их не трогает. Поле, которое в файле есть, apply вернёт к значению из файла (образ, replicas), а поле, добавленное мимо файла, тихо останется в кластере. Поэтому правки, сделанные kubectl set, либо вносят и в манифест, либо убирают той же командой:

$ kubectl set env deployment/hello GOMAXPROCS- GOMEMLIMIT-
deployment.apps/hello env updated
$ kubectl get replicaset -l app.kubernetes.io/name=hello
NAME               DESIRED   CURRENT   READY   AGE
hello-77b6f6d957   0         0         0       86s
hello-85854d9fd8   4         4         4       6m8s
hello-8c9694f75    0         0         0       6m40s
hello-8d8f8775c    0         0         0       3m36s

Шаблон снова совпал с ревизией 1.1, и ожил hello-85854d9fd8 — с новыми подами, а ReplicaSet с переменными остался в истории.

Пауза также останавливает выкатку на полпути, если что-то пошло не так и нужно время разобраться: часть подов на новой версии, часть на старой, и ничего не меняется. Пока Deployment на паузе, progressDeadlineSeconds не отсчитывается.

kubectl rollout restart пересоздаёт все поды без изменения версии: kubectl добавляет в шаблон аннотацию kubectl.kubernetes.io/restartedAt с текущим временем (2026-10-26T22:17:39+03:00), шаблон изменился — значит, новый ReplicaSet и обычная выкатка по правилам стратегии. Нужно, чтобы приложение перечитало что-то при старте: смонтированный Secret, ConfigMap в переменных окружения (часть 12), обновлённый корневой сертификат.


Стратегия Recreate

spec:
  strategy:
    type: Recreate

Все старые поды удаляются, контроллер дожидается их полного завершения и только потом создаёт новые. Между этими моментами приложение недоступно. Гарантия касается только выкатки: если под удалить руками, ReplicaSet создаст замену сразу, не дожидаясь, пока старый завершится.

Recreate нужен, когда две версии не могут работать одновременно: приложение держит том с доступом ReadWriteOnce, который смонтируется только на одном узле (часть 21); новая версия меняет схему базы так, что старая с ней не работает; приложение — единственный потребитель очереди, где два экземпляра недопустимы. Если ни одной из таких причин нет — RollingUpdate.


Масштабирование

$ kubectl scale deployment/hello --replicas=6
deployment.apps/hello scaled
$ kubectl get replicaset -l app.kubernetes.io/name=hello
NAME               DESIRED   CURRENT   READY   AGE
hello-77b6f6d957   0         0         0       98s
hello-85854d9fd8   6         6         6       6m20s
hello-8c9694f75    0         0         0       6m52s
hello-8d8f8775c    0         0         0       3m48s

Масштабирование меняет только spec.replicas Deployment, тот переносит число в текущий ReplicaSet — новой ревизии не появляется. Под капотом kubectl scale пишет в подресурс scale; через него же работает горизонтальный автомасштабировщик HPA (часть 32).

Если масштабировать посреди выкатки, Deployment раскладывает новые реплики пропорционально между старым и новым ReplicaSet-ом, чтобы не нарушить ограничения стратегии и не перекосить выкатку в одну сторону. На стенде застрявшая выкатка (6 старых подов и 1 новый) после --replicas=10 стала 9 и 2.

Одна ловушка. В манифесте replicas: 4, а кто-то сделал kubectl scale --replicas=6 — следующий kubectl apply вернёт 4: это поле в файле есть. С HPA так же: он поставил 8, а apply сбросил на 4. Когда количеством реплик управляет HPA, поле replicas из манифеста убирают.

$ kubectl scale deployment/hello --replicas=4
deployment.apps/hello scaled

Смотрим руками

Связи владения. Под указывает на ReplicaSet, ReplicaSet — на Deployment:

$ kubectl get pod hello-85854d9fd8-4mjwb -o jsonpath='{.metadata.ownerReferences}' | jq
[
  {
    "apiVersion": "apps/v1",
    "blockOwnerDeletion": true,
    "controller": true,
    "kind": "ReplicaSet",
    "name": "hello-85854d9fd8",
    "uid": "a439717d-1518-4c16-8ef4-935e092e9f5f"
  }
]
$ kubectl get replicaset hello-85854d9fd8 -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}{"\n"}'
Deployment/hello

Статус Deployment — то, по чему ориентируются kubectl rollout status и все инструменты доставки:

$ kubectl get deployment hello -o jsonpath='{.metadata.generation}{"\n"}'
13
$ kubectl get deployment hello -o json | jq '.status | del(.conditions)'
{
  "availableReplicas": 4,
  "observedGeneration": 13,
  "readyReplicas": 4,
  "replicas": 4,
  "terminatingReplicas": 0,
  "updatedReplicas": 4
}

generation вырос до 13: каждая правка spec за эту часть — apply, undo, set, pause, resume, scale — прибавляла единицу. observedGeneration равен metadata.generation — контроллер обработал последнюю версию spec. Если generation больше, контроллер ещё не видел изменения, и любые выводы по status преждевременны. updatedReplicas — поды текущего шаблона, availableReplicas — доступные, terminatingReplicas — поды в процессе корректного завершения, которых в replicas уже не считают (feature gate DeploymentReplicaSetTerminatingReplicas, бета с 1.35, включён по умолчанию).

Что лежит в etcd. На мастере, клиентом etcdctl из пода etcd, с сертификатами, которые kubeadm положил в /etc/kubernetes/pki/etcd/ (подробно etcd — в части 34):

$ kubectl -n kube-system exec etcd-k8s-master-1 -- etcdctl \
    --endpoints=https://127.0.0.1:2379 \
    --cacert=/etc/kubernetes/pki/etcd/ca.crt \
    --cert=/etc/kubernetes/pki/etcd/healthcheck-client.crt \
    --key=/etc/kubernetes/pki/etcd/healthcheck-client.key \
    get --prefix --keys-only /registry/ | grep -E 'deployments/default/hello|replicasets/default/hello|pods/default/hello'
/registry/deployments/default/hello
/registry/pods/default/hello-85854d9fd8-4mjwb
/registry/pods/default/hello-85854d9fd8-cmcqg
/registry/pods/default/hello-85854d9fd8-ptchf
/registry/pods/default/hello-85854d9fd8-z4h9z
/registry/replicasets/default/hello-77b6f6d957
/registry/replicasets/default/hello-85854d9fd8
/registry/replicasets/default/hello-8c9694f75
/registry/replicasets/default/hello-8d8f8775c

Один ключ на объект; пустые ReplicaSet-ы истории — такие же полноценные объекты, как работающие. Значения хранятся в protobuf — двоичном формате сериализации от Google, а не в JSON, поэтому читать их удобнее через API.

Самовосстановление. Удалим под и через секунду посмотрим список:

$ kubectl delete pod hello-85854d9fd8-4mjwb --wait=false
pod "hello-85854d9fd8-4mjwb" deleted from default namespace
$ kubectl get pods -l app.kubernetes.io/name=hello
NAME                     READY   STATUS              RESTARTS   AGE
hello-85854d9fd8-2rzmr   0/1     ContainerCreating   0          1s
hello-85854d9fd8-4mjwb   1/1     Terminating         0          60s
hello-85854d9fd8-cmcqg   1/1     Running             0          50s
hello-85854d9fd8-ptchf   1/1     Running             0          39s
hello-85854d9fd8-z4h9z   1/1     Running             0          73s

Удаляемый под ещё завершается, а ReplicaSet уже создал замену: под в Terminating для него не считается активным. Ровно поэтому terminatingReplicas появился в статусе — чтобы было видно, что на время завершения подов фактически больше, чем replicas.

Controller-manager за работой. Тот же эпизод в журнале controller-manager — логах статического пода на мастере. На уровне подробности по умолчанию этих строк нет; с --v=4 в манифесте /etc/kubernetes/manifests/kube-controller-manager.yaml (kubelet сам перезапустит под после правки файла) видно, как replicaset-controller замечает удаление, создаёт замену и как следом синхронизируется Deployment (команда выполнена через несколько секунд после удаления пода):

$ kubectl -n kube-system logs kube-controller-manager-k8s-master-1 --since=20s | grep 'default/hello' \
    | grep -E '"(Too few replicas|Controller created pod|Updating status for|Started syncing deployment)' | head -5
I1026 19:41:10.935036       1 replica_set.go:668] "Too few replicas" replicaSet="default/hello-85854d9fd8" need=4 creating=1
I1026 19:41:11.106686       1 controller_utils.go:613] "Controller created pod" controller="hello-85854d9fd8" pod="default/hello-85854d9fd8-2rzmr"
I1026 19:41:11.106840       1 replica_set_utils.go:68] "Updating status for : default/hello-85854d9fd8, replicas 4->3 (need 4), fullyLabeledReplicas 4->3, readyReplicas 4->3, availableReplicas 4->3, terminatingReplicas 0->1, sequence No: 22->22"
I1026 19:41:11.168476       1 deployment_controller.go:598] "Started syncing deployment" deployment="default/hello" startTime="2026-10-26 19:41:11.168470276 +0000 UTC m=+41.308722235"
I1026 19:41:11.168857       1 replica_set_utils.go:68] "Updating status for : default/hello-85854d9fd8, replicas 3->4 (need 4), fullyLabeledReplicas 3->4, readyReplicas 3->3, availableReplicas 3->3, terminatingReplicas 1->1, sequence No: 22->22"

Журнал controller-manager идёт в UTC (19:41 — это 22:41 по времени стенда). Повышенную подробность держат только на время разбора и возвращают обратно: на --v=4 controller-manager пишет сотни строк в минуту даже в маленьком кластере.


Deployment закрывает почти всё, что нужно приложению без состояния: нужное число подов, восстановление, выкатку без простоя и откат. Но все его поды одинаковы и взаимозаменяемы: у них случайные имена, общий шаблон тома и никакого порядка. Базе данных или брокеру сообщений этого мало, а агенту, который должен работать на каждом узле, — не то.

Источники


Дальше — StatefulSet и DaemonSet: устойчивые имена и тома для подов с состоянием, упорядоченный запуск и обновление, и по поду на каждый узел для агентов.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.