In the previous post on Pods we saw that a Pod you create by hand, once it is deleted or its node fails. Nothing brings it back. That's fine for a quick test, but not for a real application.
This is where ReplicaSets and Deployments come in. A ReplicaSet keeps a fixed number of Pods running. A Deployment manages ReplicaSets for you and adds rolling updates and rollbacks on top. In this post we'll build both from scratch, break things on purpose, and see how Kubernetes fixes them.
The big picture
The three objects stack on top of each other:
Deployment → manages → ReplicaSet → manages → Pods
- A Pod runs your containers.
- A ReplicaSet makes sure N copies of a Pod are always running.
- A Deployment owns ReplicaSets and handles changing from one version of your app to the next.
In day-to-day work you create Deployments and let them create everything else. But it is easier to understand a Deployment once you've seen a ReplicaSet on its own, so let's start there.
What is a ReplicaSet?
A ReplicaSet has one job: keep the desired number of Pod replicas running at all times. It runs a simple loop:
- Count the Pods that match my label selector.
- Fewer than desired? Create more from my Pod template.
- More than desired? Delete the extras.
Here is replicaset.yaml:
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: web-rs
spec:
replicas: 3
selector:
matchLabels:
app: web-rs
template:
metadata:
labels:
app: web-rs
spec:
containers:
- name: nginx
image: nginx:1.26
ports:
- containerPort: 80
The spec has three parts:
replicas— how many Pods you want.selector— how the ReplicaSet finds its Pods. It matches on labels.template— the Pod to create when more are needed. This is the same Pod spec you wrote in the Pods post, just nested inside.
Important: the labels in template.metadata.labels must match selector.matchLabels. If they don't, Kubernetes rejects the manifest, because the ReplicaSet would create Pods it can never find.
Apply it and look at what was created:
$ kubectl apply -f replicaset.yaml
replicaset.apps/web-rs created
$ kubectl get rs
NAME DESIRED CURRENT READY AGE
web-rs 3 3 3 10s
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
web-rs-4kx9p 1/1 Running 0 10s
web-rs-m2tqz 1/1 Running 0 10s
web-rs-zr8vd 1/1 Running 0 10s
One manifest, three Pods. Each Pod name is the ReplicaSet name plus a random suffix.
Self-healing in action
Now delete one of the Pods and watch what happens:
$ kubectl delete pod web-rs-4kx9p
pod "web-rs-4kx9p" deleted
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
web-rs-m2tqz 1/1 Running 0 2m
web-rs-x7lwc 1/1 Running 0 3s
web-rs-zr8vd 1/1 Running 0 2m
The ReplicaSet noticed it had only two Pods and immediately created web-rs-x7lwc to get back to three. The same happens when a node dies: the Pods on it are replaced on healthy nodes.
kubectl describe shows this in the Events:
$ kubectl describe rs web-rs
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal SuccessfulCreate 2m replicaset-controller Created pod: web-rs-4kx9p
Normal SuccessfulCreate 2m replicaset-controller Created pod: web-rs-m2tqz
Normal SuccessfulCreate 2m replicaset-controller Created pod: web-rs-zr8vd
Normal SuccessfulCreate 3s replicaset-controller Created pod: web-rs-x7lwc
Scaling a ReplicaSet
Change the replica count and the ReplicaSet adds or removes Pods to match:
$ kubectl scale rs web-rs --replicas=5
replicaset.apps/web-rs scaled
$ kubectl get rs web-rs
NAME DESIRED CURRENT READY AGE
web-rs 5 5 5 4m
The problem with ReplicaSets
Let's say we want to upgrade nginx from 1.26 to 1.27. Edit the image in replicaset.yaml and apply it again:
$ kubectl apply -f replicaset.yaml
replicaset.apps/web-rs configured
Now check which image the Pods are running:
$ kubectl get pods -l app=web-rs -o jsonpath='{.items[*].spec.containers[0].image}'
nginx:1.26 nginx:1.26 nginx:1.26 nginx:1.26 nginx:1.26
Still 1.26. A ReplicaSet only uses its template when it creates a Pod. It already has enough Pods, so it does nothing. To get the new version you'd have to delete the old Pods yourself, one at a time, and hope nothing breaks in between. There's also no history and no easy way to go back.
That's exactly the gap a Deployment fills. Clean up before moving on:
$ kubectl delete rs web-rs
replicaset.apps "web-rs" deleted
Deleting a ReplicaSet also deletes the Pods it owns.
What is a Deployment?
A Deployment describes the desired state of your app: which image, how many replicas, and how to move from one version to the next. When you change the Pod template, the Deployment creates a new ReplicaSet for the new version and gradually shifts Pods from the old one to the new one. This is a rolling update, and it means no downtime.
Here is deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:1.26
ports:
- containerPort: 80
Look familiar? Apart from kind: Deployment, it is the same as the ReplicaSet manifest. That's the nice thing: if you understand ReplicaSets, you already know how to write a Deployment.
Tip: Let kubectl generate the manifest for you:
$ kubectl create deployment web --image=nginx:1.26 --replicas=3 --dry-run=client -o yaml > deployment.yaml
Apply it:
$ kubectl apply -f deployment.yaml
deployment.apps/web created
$ kubectl get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
web 3/3 3 3 15s
The columns mean:
READY— Pods ready out of the desired count.UP-TO-DATE— Pods running the latest Pod template.AVAILABLE— Pods available to serve traffic.
Deployment, ReplicaSet and Pods together
You only created a Deployment, but look at what exists now:
$ kubectl get deploy,rs,pods
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/web 3/3 3 3 1m
NAME DESIRED CURRENT READY AGE
replicaset.apps/web-6d8f7b9c4d 3 3 3 1m
NAME READY STATUS RESTARTS AGE
pod/web-6d8f7b9c4d-2hjxk 1/1 Running 0 1m
pod/web-6d8f7b9c4d-9qmnv 1/1 Running 0 1m
pod/web-6d8f7b9c4d-tw4rz 1/1 Running 0 1m
The naming tells the whole story:
- Deployment:
web - ReplicaSet:
web-6d8f7b9c4d(Deployment name + a hash of the Pod template) - Pods:
web-6d8f7b9c4d-2hjxk(ReplicaSet name + random suffix)
That hash is stored as a pod-template-hash label on the ReplicaSet and its Pods. Every time the Pod template changes, the hash changes, and that's how the Deployment tells old Pods from new ones.
$ kubectl get pods --show-labels
NAME READY STATUS RESTARTS AGE LABELS
web-6d8f7b9c4d-2hjxk 1/1 Running 0 2m app=web,pod-template-hash=6d8f7b9c4d
...
Self-healing still works because the Deployment's ReplicaSet is doing the same job as before. Delete a Pod and a new one appears.
Rolling update to a new version
Now let's do the upgrade that the ReplicaSet couldn't. You can change the image in deployment.yaml and run kubectl apply, or use kubectl set image for a quick change:
$ kubectl set image deployment/web nginx=nginx:1.27
deployment.apps/web image updated
Here nginx= is the container name from the manifest, not the image name.
Watch the rollout as it happens:
$ kubectl rollout status deployment/web
Waiting for deployment "web" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "web" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "web" rollout to finish: 1 old replicas are pending termination...
deployment "web" successfully rolled out
Now check the ReplicaSets:
$ kubectl get rs
NAME DESIRED CURRENT READY AGE
web-6d8f7b9c4d 0 0 0 5m
web-7f9c6b8d5e 3 3 3 30s
There are two. The Deployment created a new ReplicaSet web-7f9c6b8d5e for nginx:1.27, scaled it up step by step, and scaled the old one down to zero. The old ReplicaSet is kept around on purpose, so you can roll back to it.
kubectl describe deployment web shows each step in the Events:
$ kubectl describe deployment web
...
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal ScalingReplicaSet 5m deployment-controller Scaled up replica set web-6d8f7b9c4d to 3
Normal ScalingReplicaSet 40s deployment-controller Scaled up replica set web-7f9c6b8d5e to 1
Normal ScalingReplicaSet 35s deployment-controller Scaled down replica set web-6d8f7b9c4d to 2 from 3
Normal ScalingReplicaSet 35s deployment-controller Scaled up replica set web-7f9c6b8d5e to 2 from 1
Normal ScalingReplicaSet 30s deployment-controller Scaled down replica set web-6d8f7b9c4d to 1 from 2
Normal ScalingReplicaSet 30s deployment-controller Scaled up replica set web-7f9c6b8d5e to 3 from 2
Normal ScalingReplicaSet 25s deployment-controller Scaled down replica set web-6d8f7b9c4d to 0 from 1
At every step some Pods were serving traffic. That's a zero-downtime update with one command.
Controlling the rollout with a strategy
By default a Deployment uses the RollingUpdate strategy. Two settings control how fast it goes:
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
maxSurge— how many extra Pods can exist abovereplicasduring the update.maxUnavailable— how many Pods can be missing belowreplicasduring the update.
Both default to 25%. The setting above (maxSurge: 1, maxUnavailable: 0) is a safe choice: Kubernetes starts one new Pod, waits for it to be ready, and only then removes an old one. You never drop below full capacity.
The other strategy is Recreate, which deletes all old Pods before creating new ones. It causes downtime, but it is useful when two versions of your app must never run at the same time, for example during a database schema change.
Rollout history and rollback
Every change to the Pod template creates a new revision. List them with:
$ kubectl rollout history deployment/web
deployment.apps/web
REVISION CHANGE-CAUSE
1 <none>
2 <none>
<none> isn't very helpful. Add a note to the current revision with the kubernetes.io/change-cause annotation:
$ kubectl annotate deployment/web kubernetes.io/change-cause="upgrade nginx to 1.27"
deployment.apps/web annotated
$ kubectl rollout history deployment/web
deployment.apps/web
REVISION CHANGE-CAUSE
1 <none>
2 upgrade nginx to 1.27
To see exactly what a revision contained:
$ kubectl rollout history deployment/web --revision=1
Now say version 1.27 has a bug in production. Rolling back is a single command:
$ kubectl rollout undo deployment/web
deployment.apps/web rolled back
This scales the old ReplicaSet back up and the new one down, using the same rolling update. To go back to a specific revision instead of the previous one:
$ kubectl rollout undo deployment/web --to-revision=1
This is why the Deployment keeps old ReplicaSets around. By default it keeps the last 10, controlled by spec.revisionHistoryLimit.
What happens when a rollout fails?
Let's push an image tag that doesn't exist:
$ kubectl set image deployment/web nginx=nginx:9.99
deployment.apps/web image updated
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
web-6d8f7b9c4d-5bnqw 1/1 Running 0 3m
web-6d8f7b9c4d-kd7sm 1/1 Running 0 3m
web-6d8f7b9c4d-wl2xp 1/1 Running 0 3m
web-85b4c7d9f6-rj6tn 0/1 ImagePullBackOff 0 20s
The new Pod can't start, so the rolling update stops there. Your three old Pods are still running and still serving traffic. A broken release doesn't take your app down. Run kubectl describe pod on the failing Pod to see why, then fix it with kubectl rollout undo deployment/web.
Scaling a Deployment
Scaling works the same way as with a ReplicaSet, but you scale the Deployment, not the ReplicaSet underneath:
$ kubectl scale deployment web --replicas=5
deployment.apps/web scaled
$ kubectl get deployment web
NAME READY UP-TO-DATE AVAILABLE AGE
web 5/5 5 5 10m
Scaling doesn't change the Pod template, so it doesn't create a new revision.
Note: If you manage your Deployment from a YAML file, update replicas in the file too. Otherwise the next kubectl apply will reset the count back to what the file says.
Pause and resume
If you want to make several changes and roll them out together as one revision, pause the Deployment first:
$ kubectl rollout pause deployment/web
$ kubectl set image deployment/web nginx=nginx:1.27
$ kubectl set resources deployment/web -c nginx --limits=cpu=250m,memory=128Mi
$ kubectl rollout resume deployment/web
Nothing rolls out while the Deployment is paused. When you resume, both changes go out in a single rolling update.
Restart all Pods without changing anything
Sometimes you just need fresh Pods, for example to pick up an updated ConfigMap or Secret. Instead of deleting Pods by hand, use:
$ kubectl rollout restart deployment/web
deployment.apps/web restarted
This replaces every Pod using a rolling update, so there's no downtime.
Clean up
$ kubectl delete deployment web
deployment.apps "web" deleted
This deletes the Deployment, all its ReplicaSets, and all their Pods.
Quick reference
| Task | Command |
|---|---|
| Create from manifest | kubectl apply -f deployment.yaml |
| List Deployments, ReplicaSets, Pods | kubectl get deploy,rs,pods |
| Update image | kubectl set image deployment/web nginx=nginx:1.27 |
| Watch a rollout | kubectl rollout status deployment/web |
| See history | kubectl rollout history deployment/web |
| Roll back | kubectl rollout undo deployment/web |
| Scale | kubectl scale deployment web --replicas=5 |
| Restart Pods | kubectl rollout restart deployment/web |
| Inspect | kubectl describe deployment web |
Should you ever use a ReplicaSet directly?
Almost never. A Deployment gives you everything a ReplicaSet does, plus rolling updates, history, and rollbacks, and the manifest is practically the same. The Kubernetes docs themselves recommend using Deployments instead of creating ReplicaSets directly.
It is still worth knowing how ReplicaSets work, because they are what's running under every Deployment. When you run kubectl get rs during a rollout and see two ReplicaSets, or when a Pod name has that extra hash in it, you now know exactly why.
Your Deployment runs inside a namespace. If you haven't read it yet, see Kubernetes Namespace Explained with Examples.