Deployments and ReplicaSets
The idea in one sentence
Section titled “The idea in one sentence”A ReplicaSet keeps a fixed number of Pods matching a label selector running at all times, and a Deployment manages ReplicaSets for you to provide declarative, versioned rolling updates and rollback history on top.
ReplicaSets: a fixed count of matching Pods
Section titled “ReplicaSets: a fixed count of matching Pods”A ReplicaSet’s job is narrow: given a Pod template and a label selector, it makes sure exactly replicas Pods matching that selector exist. If a Pod matching the selector disappears, the ReplicaSet creates a replacement; if there are too many, it deletes the extras. A ReplicaSet will even adopt existing Pods it did not create, as long as their labels match its selector.
apiVersion: apps/v1kind: ReplicaSetmetadata: name: web-rsspec: replicas: 3 selector: matchLabels: app: web template: metadata: labels: app: web spec: containers: - name: web image: myregistry/web:1.0.0 ports: - containerPort: 8080In everyday use you do not write ReplicaSets like this yourself — you create a Deployment, and the Deployment creates and owns the ReplicaSet for you.
Deployments: rolling updates on top of ReplicaSets
Section titled “Deployments: rolling updates on top of ReplicaSets”A Deployment wraps a ReplicaSet with a rollout strategy, a revision history, and rollback support. When you change the Pod template in a Deployment (a new image tag, an updated env var), the Deployment creates a new ReplicaSet with that template and gradually shifts replicas from the old ReplicaSet to the new one.
apiVersion: apps/v1kind: Deploymentmetadata: name: webspec: replicas: 3 selector: matchLabels: app: web strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 1 template: metadata: labels: app: web spec: containers: - name: web image: myregistry/web:1.1.0 ports: - containerPort: 8080The ownership chain is always Deployment → ReplicaSet → Pod: the Deployment owns one or more ReplicaSets (the current one, plus old ones kept for rollback history), and each ReplicaSet owns its Pods.
flowchart LR dep["Deployment web"] --> rsOld["ReplicaSet web-abc123 (old): 3 -> 0"] dep --> rsNew["ReplicaSet web-def456 (new): 0 -> 3"] rsOld --> p1["Pod (terminating)"] rsNew --> p2["Pod (running)"] rsNew --> p3["Pod (running)"] rsNew --> p4["Pod (running)"]
Rollout strategy: RollingUpdate vs Recreate
Section titled “Rollout strategy: RollingUpdate vs Recreate”maxUnavailable caps how many of the desired replicas can be unavailable at once during the rollout, and maxSurge caps how many extra Pods above the desired count are allowed to exist temporarily while new ones come up. Together they tune the trade-off between rollout speed and available capacity — the values above allow one Pod down and one extra Pod at a time for a Deployment of 3 replicas.
The alternative strategy is Recreate, which terminates every old Pod before creating any new one:
spec: strategy: type: RecreateRecreate causes a brief outage, so it is reserved for cases where the old and new versions genuinely cannot run side by side — for example, an application that cannot tolerate two schema versions hitting the same database at once.
maxUnavailable: 0 and maxSurge: 0 together: a real API-server rejection
Section titled “maxUnavailable: 0 and maxSurge: 0 together: a real API-server rejection”The two knobs are not independent — together they must leave the rollout some way to make progress. Setting both to 0 means “never let a Pod be unavailable, and never create an extra one either,” which is not a strategy, it is a rollout that can never move a single replica from old to new. The API server rejects it outright, not at runtime — at apply time:
# k8s/deployments-and-replicasets/deployment-zero-zero.yaml — @expect-error maxUnavailable: 0 is invalid when maxSurge is also 0apiVersion: apps/v1kind: Deploymentmetadata: name: webspec: replicas: 3 selector: matchLabels: app: web strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 0 template: metadata: labels: app: web spec: containers: - name: web image: registry.k8s.io/pause:3.10$ kubectl apply --dry-run=server -f deployment-zero-zero.yamlThe Deployment "web" is invalid: spec.strategy.rollingUpdate.maxUnavailable: Invalid value: 0: may not be 0 when `maxSurge` is 0That is the exact, real validation error text from a live 1.37 API server — this is caught before anything is created, not discovered as a stuck rollout later.
minReadySeconds: available is not the same as ready
Section titled “minReadySeconds: available is not the same as ready”A Pod reaching Ready does not immediately count as available to the rollout — minReadySeconds is the extra time a Pod must stay continuously Ready before the Deployment controller considers it available and lets the rollout proceed to the next Pod. With minReadySeconds: 5 on a Deployment whose Pods become Ready almost instantly, a real rollout genuinely pauses on this exact message multiple times before completing:
apiVersion: apps/v1kind: Deploymentmetadata: name: webspec: replicas: 3 minReadySeconds: 5 progressDeadlineSeconds: 30 selector: matchLabels: app: web strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 1 template: metadata: labels: app: web spec: containers: - name: web image: registry.k8s.io/pause:3.10$ kubectl apply -f deployment-with-minreadyseconds.yaml && kubectl rollout status deployment/webWaiting for deployment "web" rollout to finish: 0 of 3 updated replicas are available...Waiting for deployment "web" rollout to finish: 0 of 3 updated replicas are available...Waiting for deployment "web" rollout to finish: 0 of 3 updated replicas are available...deployment "web" successfully rolled outThree consecutive “0 of 3 … available” lines for Pods that were already Ready within a second is minReadySeconds doing its job — the Deployment is deliberately holding each new Pod at “Ready but not yet available” for those extra 5 seconds before trusting it, which is exactly the guard you want against a Pod that passes its readiness probe immediately but isn’t actually warmed up.
progressDeadlineSeconds and ProgressDeadlineExceeded
Section titled “progressDeadlineSeconds and ProgressDeadlineExceeded”progressDeadlineSeconds bounds how long the Deployment controller will wait for a rollout to make any forward progress before giving up and marking it stalled — this is what catches a rollout to a broken image that will never pass its readiness probe, instead of leaving it “in progress” forever. Proof: a Deployment with progressDeadlineSeconds: 15 and a readiness probe that always fails:
apiVersion: apps/v1kind: Deploymentmetadata: name: web-baddeployspec: replicas: 2 progressDeadlineSeconds: 15 selector: matchLabels: app: web-baddeploy strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 maxSurge: 1 template: metadata: labels: app: web-baddeploy spec: containers: - name: web image: registry.k8s.io/pause:3.10 readinessProbe: exec: command: ['false'] periodSeconds: 2 failureThreshold: 1$ kubectl rollout status deployment/web-baddeploy --timeout=5serror: deployment "web-baddeploy" exceeded its progress deadline$ kubectl get deployment web-baddeploy -o jsonpath='{range .status.conditions[*]}{.type}{"="}{.status}{" reason="}{.reason}{" msg="}{.message}{"\n"}{end}'Available=False reason=MinimumReplicasUnavailable msg=Deployment does not have minimum availability.Progressing=False reason=ProgressDeadlineExceeded msg=ReplicaSet "web-baddeploy-56d9694579" has timed out progressing.Progressing=False with reason: ProgressDeadlineExceeded is the real, queryable signal — an alert or a CI gate should watch this condition, not just “is the Deployment done yet,” since a stuck rollout otherwise sits silently Progressing=True forever with no readiness ever coming.
selector is immutable
Section titled “selector is immutable”A Deployment’s spec.selector is set once and can never change afterward — not even to add a label — because changing it would silently orphan every existing Pod/ReplicaSet the old selector matched. Proof, changing an existing Deployment’s selector from {app: web} to {app: web, tier: frontend}:
$ kubectl apply -f deployment-selector-changed.yamlThe Deployment "web" is invalid: spec.selector: Invalid value: {"matchLabels":{"app":"web","tier":"frontend"}}: field is immutableIf you genuinely need a different selector, the only real path is deleting and recreating the Deployment (with --cascade=orphan if you want the existing Pods to survive it, then adopting them under the new selector) — there is no in-place migration.
pod-template-hash: how the ownership chain actually works
Section titled “pod-template-hash: how the ownership chain actually works”Every Pod a Deployment’s ReplicaSet creates gets an extra label, pod-template-hash, computed from (and unique to) that ReplicaSet’s Pod template — it is how a Deployment tells “old ReplicaSet’s Pods” apart from “new ReplicaSet’s Pods” without any other bookkeeping:
$ kubectl get pods -l app=web --show-labelsNAME READY STATUS RESTARTS AGE LABELSweb-795568db48-6c6tv 1/1 Running 0 5s app=web,pod-template-hash=795568db48web-795568db48-j4fh2 1/1 Running 0 5s app=web,pod-template-hash=795568db48web-795568db48-wll9g 1/1 Running 0 5s app=web,pod-template-hash=795568db48The ReplicaSet itself is named <deployment>-<pod-template-hash> (web-795568db48 above) — the same hash is both the ReplicaSet’s name suffix and every one of its Pods’ pod-template-hash label, which is also what a Service selector matching only app: web (not the hash) keeps routing to correctly across a rollout: it never needs to know which ReplicaSet is “current.”
Operating a rollout
Section titled “Operating a rollout”# Trigger a rollout by changing the Pod template (e.g. a new image), then watch itkubectl set image deployment/web web=myregistry/web:1.2.0kubectl annotate deployment/web kubernetes.io/change-cause="bump to 1.2.0" --overwritekubectl rollout status deployment/web
# See every revision this Deployment has keptkubectl rollout history deployment/web
# Roll back to the previous revisionkubectl rollout undo deployment/web
# Scale independently of a rolloutkubectl scale deployment/web --replicas=5Real output from that sequence, on a Deployment created with kubernetes.io/change-cause: "initial rollout: web:1.0.0", then updated to pause:3.9 with a new change-cause, then rolled back:
$ kubectl rollout history deployment/webdeployment.apps/webREVISION CHANGE-CAUSE1 initial rollout: web:1.0.02 bump pause 3.9 -> testing rollback
$ kubectl rollout history deployment/web --revision=2deployment.apps/web with revision #2Pod Template: Labels: app=web pod-template-hash=587c49f48d Annotations: kubernetes.io/change-cause: bump pause 3.9 -> testing rollback Containers: web: Image: registry.k8s.io/pause:3.9...
$ kubectl rollout undo deployment/webWarning: resource deployments/web 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/web rolled back
$ kubectl rollout history deployment/webdeployment.apps/webREVISION CHANGE-CAUSE2 bump pause 3.9 -> testing rollback3 initial rollout: web:1.0.0Two things worth noticing in real output: kubectl rollout history only shows a CHANGE-CAUSE when the Deployment carries the kubernetes.io/change-cause annotation (there’s no --record flag in kubectl 1.36 — it was removed; annotate explicitly instead) — and rollout undo doesn’t erase revision 1, it makes rolling back itself a new revision (3), copying revision 1’s template forward, and the real CLI warns you that apply-managed Deployments can drift their last-applied-configuration annotation after an undo.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing a generated Deployment, check for:
maxUnavailable: 0paired withmaxSurge: 0— proven above, this is a real, immediate API-server rejection, not a valid “extra safe” rollout setting.- A
selectorthat was changed on an existing Deployment (rather than set once at creation) — proven above,spec.selectoris immutable and the apply will fail withfield is immutable; the generated fix should create a new Deployment, not attempt an in-place selector edit. - No
progressDeadlineSeconds(or the 600s default left as-is) on a Deployment fronted by an SLA/alert — without it, a rollout to a broken image sitsProgressing=Trueforever with no automaticProgressDeadlineExceededsignal to page on, proven above. - A rollout change with no
kubernetes.io/change-causeannotation —kubectl rollout historythen shows a revision with a blankCHANGE-CAUSE, makingrollout undoa guess instead of an informed choice. minReadySecondsassumed to mean “how long to wait before starting the next Pod” when it actually means “how long a Pod must stay Ready before counting as available at all” — the distinction matters for readingkubectl rollout statusoutput, proven above with the repeated “0 of N replicas are available” lines.
🐛 Spot the bug
apiVersion: apps/v1kind: Deploymentmetadata:name: webspec:replicas: 6selector: matchLabels: app: webstrategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 0template: metadata: labels: app: web spec: containers: - name: web image: myregistry/web:2.0.0Show the answer
This is trying to express “zero-downtime, no extra capacity used” but it describes a rollout that can never move: with maxUnavailable: 0, no existing Pod may go down before its replacement is ready; with maxSurge: 0, no replacement Pod may even be created above the desired count. There is no way left to create a new Pod or free room for one. This is not a slow rollout, it’s a rejected one — proven above on a real 1.37 API server:
The Deployment "web" is invalid: spec.strategy.rollingUpdate.maxUnavailable: Invalid value: 0: may not be 0 when `maxSurge` is 0The fix is to pick one knob to be the “room to move”: maxSurge: 1 (create one extra Pod at a time, true zero-downtime) or maxUnavailable: 1 (accept one Pod briefly missing at a time, no extra capacity used) — never both 0.
Mental Model
Section titled “Mental Model”A Deployment is not “the thing that runs your Pods” — it is a generator of ReplicaSets, one per distinct Pod template it has ever been given, and the rollout is entirely a story about shifting the replica count between exactly two of them (old, new) under the constraints of maxUnavailable/maxSurge. Every other feature here is either a constraint on that shift (minReadySeconds slows how fast a new Pod counts, progressDeadlineSeconds bounds how long the shift may stall) or a fact about its bookkeeping (selector is the immutable question “which Pods are mine,” pod-template-hash is the immutable answer “which generation is this Pod from,” change-cause annotations are the human-readable trail through rollout history).
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
maxSurge: 1, maxUnavailable: 0 | True zero-downtime — capacity never drops | Briefly uses more resources than the steady-state replica count |
maxUnavailable: 1, maxSurge: 0 | Never exceeds the steady-state resource footprint | Capacity briefly drops by one replica during rollout |
maxUnavailable: 0, maxSurge: 0 | — none, proven above this is a real, immediate rejection | Cannot be applied at all |
minReadySeconds > 0 | Catches a Pod that passes readiness immediately but isn’t actually warmed up (proven above) | Slows every rollout by that many seconds per batch, always, whether or not it was needed |
progressDeadlineSeconds (explicit, not the 600s default) | A stuck rollout surfaces ProgressDeadlineExceeded fast enough to page on (proven above) | Too low a value can flag a rollout that was just legitimately slow (large images, cold caches) as failed |
kubernetes.io/change-cause annotation | rollout history/rollout undo become informed, auditable operations (proven above) | One more thing to remember to set on every change; nothing enforces it |
Common Misconceptions
Section titled “Common Misconceptions”- “Setting both maxUnavailable and maxSurge to 0 is the safest possible rollout.” Proven above: it is not applied at all, the API server rejects it with a real validation error.
- “minReadySeconds delays when the rollout starts.” Proven above: it delays when an already-Ready Pod counts as available, which is what gates the rollout’s next step — the Pod is genuinely serving traffic during that window, it just isn’t trusted as a rollout-progress signal yet.
- “A Deployment’s selector can be edited like any other field, as long as the new selector still matches the same Pods.” Proven above:
spec.selectoris immutable at the API level regardless of whether the new value would match the same or different Pods. - “kubectl rollout undo re-applies revision 1’s manifest and there’s nothing left to undo further back.” Proven above: undo creates a brand-new revision (3) with revision 1’s template — the history keeps growing, it isn’t a stack that empties.