Kubernetes Deployments: Rolling Updates, Rollbacks and Scaling
Run a Kubernetes Deployment: scale it, roll out a new version, watch a broken rollout stop safely, roll back, and run Jobs, CronJobs and DaemonSets.
TLDR: A Deployment keeps a number of identical pods running and replaces them gradually when you change the pod template. It does this through ReplicaSets: one per version of the template. kubectl rollout status, history and undo follow and reverse a rollout, and maxSurge, maxUnavailable and readiness probes decide how safe it is. For work that finishes, use a Job or CronJob; for one pod per node, a DaemonSet.
In this part you run three copies of podinfo, delete one to watch it come back, scale it, update it to a new version, push a broken version and watch Kubernetes refuse to take the app down, and roll back. Then you run a Job with a native sidecar and a CronJob.
Prerequisites
- The kind cluster from Part 2 with the
demonamespace selected. - The pods from Part 3 deleted (the last command of that part).
Deployments, ReplicaSets and Pods
You create the Deployment. The Deployment creates a ReplicaSet for the current pod template, and the ReplicaSet creates the pods. When you change the template, for example the image tag, the Deployment creates a second ReplicaSet and moves pods from the old one to the new one step by step.
You almost never touch ReplicaSets yourself. They matter because they are what kubectl rollout undo goes back to, and because they appear in pod names: podinfo-77cfdf847c-9t6dz is a pod of ReplicaSet podinfo-77cfdf847c, where 77cfdf847c is a hash of the pod template.
Create the Deployment
The Deployment runs three replicas of podinfo 6.14.1, the previous release, so that you can update it in a moment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: podinfo
labels:
app: podinfo
spec:
replicas: 3
# Keep five old ReplicaSets for rollbacks
revisionHistoryLimit: 5
# Mark the rollout as failed if it makes no progress for 2 minutes
progressDeadlineSeconds: 120
# A new pod must stay ready for 5 seconds before it counts as available
minReadySeconds: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # at most one pod above the desired count
maxUnavailable: 0 # never go below the desired count of available pods
selector:
matchLabels:
app: podinfo
template:
metadata:
labels:
app: podinfo
spec:
containers:
- name: podinfo
image: ghcr.io/stefanprodan/podinfo:6.14.1
ports:
- name: http
containerPort: 9898
readinessProbe:
httpGet:
path: /readyz
port: http
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
The important fields:
selector.matchLabelsmust match the labels intemplate.metadata.labels. This is how the Deployment finds its pods, and you cannot change it later.strategy.rollingUpdatewithmaxSurge: 1andmaxUnavailable: 0means: during an update, the controller creates at most one pod above the desired count and removes an old pod only when a new one is available. A new version that never becomes ready therefore cannot reduce the number of available pods. Old pods that are shutting down still count against node resources for their grace period, and a version that passes its readiness probe and fails later is not caught; that is what monitoring and rollbacks are for.minReadySeconds: 5makes a new pod count as available only after it has been ready for 5 seconds, which catches pods that crash right after starting.progressDeadlineSeconds: 120marks a rollout as failed if it makes no progress for two minutes.resourcesreserves CPU and memory for each pod and caps its memory (Part 8 explains these numbers).
In kubectl get deployment, READY is ready pods out of desired, UP-TO-DATE counts pods that run the current template, and AVAILABLE counts pods that have been ready for minReadySeconds. The NODE column shows where the scheduler put each pod. It tends to spread copies of an app over nodes but does not promise to; Part 8 shows how to ask for it.
Self-Healing and Scaling
Delete one pod and the ReplicaSet notices that it has two of three, and creates a replacement:
The new pod has a new name and a younger AGE. This is also why a Deployment's pods are not the place for anything you want to keep.
Scaling changes the replica count of the current ReplicaSet:
kubectl scale is handy, but the YAML file still says replicas: 3, and the next kubectl apply of the file sets it back. For a lasting change, edit the file. When an autoscaler manages the count (Part 8), remove replicas from the file so the two do not fight.
Roll Out a New Version
Update the image to 6.15.0, then record why in the kubernetes.io/change-cause annotation, which kubectl rollout history shows. (The old --record flag that set it automatically is deprecated.) Then follow the rollout:
The new ReplicaSet grew one pod at a time (maxSurge: 1), and the old one shrank only after each new pod had stayed ready for 5 seconds and become available. The old ReplicaSet is still there with 0 replicas; revisionHistoryLimit: 5 keeps five of them for rollbacks.
A Broken Rollout
Now push a version that does not exist, as a typo in a tag would:
This is the rolling update doing its job. The one new pod cannot pull its image, so it never becomes ready, and because maxUnavailable is 0 the Deployment does not remove a single old pod: all five replicas of 6.15.0 still serve traffic. Without a readiness probe, or with a larger maxUnavailable, a broken version can replace working pods.
After progressDeadlineSeconds without progress, the Deployment marks the rollout as failed. It does not roll back by itself, but the failure is visible to kubectl rollout status, to your CI pipeline and to monitoring:
Roll Back
kubectl rollout undo switches the Deployment back to the previous ReplicaSet's template:
Revision 2 became revision 4: a rollback reuses the old ReplicaSet and gives it a new revision number. To go back further, use --to-revision=<n>, and kubectl rollout history deployment/podinfo --revision=4 shows the full template of one revision. Set the replica count back to the file's value:
kubectl scale deployment podinfo --replicas=3
deployment.apps/podinfo scaled
Read the warning that rollout undo printed. The cluster now runs 6.15.0, but 04-deployments/deployment.yaml still says 6.14.1, so the next kubectl apply of that file would quietly downgrade the app. kubectl diff shows the drift before you apply:
kubectl diff -f 04-deployments/deployment.yaml | grep '^[-+] '
- generation: 9
+ generation: 10
- - image: ghcr.io/stefanprodan/podinfo:6.15.0
+ - image: ghcr.io/stefanprodan/podinfo:6.14.1
That is why teams treat set image, scale and rollout undo as emergency tools and make the lasting change in Git. The later parts of this guide use their own manifests with 6.15.0.
Restart Pods Without a Change
kubectl rollout restart replaces all pods with the same rolling strategy, for example to pick up a changed ConfigMap (Part 6) or a rotated credential:
It works by adding a timestamp annotation to the pod template, which counts as a change.
Update Strategies
- RollingUpdate (the default) replaces pods gradually, within the limits of
maxSurgeandmaxUnavailable. Both accept a number or a percentage ofreplicas. - Recreate deletes all old pods before it creates new ones. There is downtime, but two versions never run at the same time, which some apps with schema changes or exclusive locks need.
Blue-green and canary releases are built from the same pieces: two Deployments with different labels, and a Service or route that decides which one gets traffic. Part 5 sends 10 percent of requests to a canary with Gateway API. Tools such as Argo Rollouts and Flagger automate those steps and roll back on bad metrics. The deployment strategies guide and the deployment strategies simulator compare the options.
Other Workload Controllers
Deployments are for stateless, long-running apps. The other controllers follow the same pattern for other kinds of work:
| Controller | Use it for |
|---|---|
| StatefulSet | Apps that need a stable name and their own volume per pod, such as databases (Part 7) |
| DaemonSet | One pod per node: log collectors, monitoring agents, network plugins |
| Job | Work that runs to completion, with retries |
| CronJob | A Job on a schedule |
The cluster already runs two DaemonSets, the network plugin and kube-proxy:
kubectl get daemonsets -n kube-system
NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE
kindnet 3 3 3 3 3 kubernetes.io/os=linux 4m47s
kube-proxy 3 3 3 3 3 kubernetes.io/os=linux 4m48s
A Job with a Native Sidecar
This Job processes three batches and writes a line for each to a log file. A native sidecar ships that file, here by printing it to its own output. Before sidecars were a Kubernetes feature, a sidecar like this kept running after the main container finished and the Job never completed.
apiVersion: batch/v1
kind: Job
metadata:
name: report
spec:
backoffLimit: 2 # retry a failed pod at most twice
ttlSecondsAfterFinished: 600 # delete the Job and its pod 10 minutes after it ends
template:
spec:
restartPolicy: Never
volumes:
- name: logs
emptyDir: {}
initContainers:
# A native sidecar: it does not keep the Job from completing
- name: log-shipper
image: busybox:1.38.0
restartPolicy: Always
command: ['sh', '-c', 'touch /logs/report.log && tail -f /logs/report.log']
volumeMounts:
- name: logs
mountPath: /logs
containers:
- name: report
image: busybox:1.38.0
command:
- sh
- -c
- for i in 1 2 3; do echo "processed batch $i" >> /logs/report.log; sleep 2; done
volumeMounts:
- name: logs
mountPath: /logs
The Job completed, and Kubernetes stopped the sidecar after the main container exited. backoffLimit caps the retries of a failing Job, and ttlSecondsAfterFinished deletes the finished Job and its pod after 10 minutes so they do not pile up.
A CronJob
A CronJob creates a Job on a schedule. timeZone makes the schedule independent of the control plane's clock, and concurrencyPolicy: Forbid skips a run while the previous one is still running:
apiVersion: batch/v1
kind: CronJob
metadata:
name: cleanup
spec:
schedule: '0 3 * * *' # 03:00 every day
timeZone: Etc/UTC
concurrencyPolicy: Forbid # skip a run while the previous one is still running
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
jobTemplate:
spec:
backoffLimit: 1
template:
spec:
restartPolicy: OnFailure
containers:
- name: cleanup
image: busybox:1.38.0
command: ['sh', '-c', 'echo "cleanup ran at $(date -u)"']
To test it without waiting until 03:00, create a Job from its template:
Clean up. The manual Job belongs to the CronJob through an owner reference, so it goes with it:
Keep the podinfo Deployment: the next part puts a Service in front of it.
Common Mistakes
- No readiness probe. Kubernetes then considers a pod ready as soon as its container starts, and a rolling update can replace working pods with broken ones.
- Changing the selector.
spec.selectoris immutable. Plan your labels before the first apply. - Editing live objects and forgetting the file.
set image,scaleandeditdrift from Git;kubectl diffshows the difference. - Expecting an automatic rollback. A failed rollout stops and reports
ProgressDeadlineExceeded; your pipeline or a tool such as Argo Rollouts decides what to do next. - Jobs that never get cleaned up. Set
ttlSecondsAfterFinishedand the CronJob history limits.
Summary
- A Deployment manages ReplicaSets, one per version of the pod template, and the ReplicaSets manage the pods.
maxSurge,maxUnavailable, readiness probes andminReadySecondsdecide how safe a rolling update is.kubectl rollout status,history,undoandrestartfollow, list, reverse and restart rollouts.- Jobs run to completion, CronJobs run Jobs on a schedule, DaemonSets run one pod per node, and native sidecars do not block Jobs.
Keep Learning
- Kubernetes Deployments vs StatefulSets
- How to make a Deployment update its image
- How to trigger a CronJob manually
- Why a pod comes back after you delete it
Next, in Part 5, you give these pods one stable address and expose them outside the cluster.
Tags
Found an issue?