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:
- Через watch он получает изменения ReplicaSet-ов и подов в свой локальный кеш (informer) и ставит изменившийся ReplicaSet в очередь.
- Для ReplicaSet из очереди он находит поды, подходящие под селектор: свои (с ownerReference на этот ReplicaSet) и бесхозные. Бесхозный под с подходящими метками ReplicaSet усыновляет — дописывает ему ownerReference. Свой под, переставший подходить под селектор (кто-то поменял ему метку), он отпускает.
- Считает активные поды (не завершённые и не удаляемые) и сравнивает с
replicas. Не хватает — создаёт поды по шаблону с именами<имя ReplicaSet>-<5 случайных символов>. Лишние — удаляет. - Записывает в
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 закрывает почти всё, что нужно приложению без состояния: нужное число подов, восстановление, выкатку без простоя и откат. Но все его поды одинаковы и взаимозаменяемы: у них случайные имена, общий шаблон тома и никакого порядка. Базе данных или брокеру сообщений этого мало, а агенту, который должен работать на каждом узле, — не то.
Источники
- Kubernetes: Deployment (стратегии, пропорциональное масштабирование,
progressDeadlineSeconds, условия статуса, завершающиеся поды), ReplicaSet (порядок удаления подов,pod-deletion-cost), ReplicationController, контроллеры и цикл согласования. - Владельцы и зависимые объекты и сборка мусора (
ownerReferences, каскадное удаление,--cascade=orphan). - Справочник API Deployment, аннотация
controller.kubernetes.io/pod-deletion-cost, feature gates (DeploymentReplicaSetTerminatingReplicas). - kubectl:
rollout,diff,scale; декларативное управление черезkubectl apply(какapplyсчитает различия и какие поля удаляет). - Параметры kube-controller-manager (
--v), переменные среды выполнения Go (GOMAXPROCS,GOMEMLIMIT).
Дальше — StatefulSet и DaemonSet: устойчивые имена и тома для подов с состоянием, упорядоченный запуск и обновление, и по поду на каждый узел для агентов.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.