Kubernetes ReplicaSet
Rewritten for this edition in October 2026 for Kubernetes 1.37. Commands checked against the official documentation. Example image: nginx 1.30 (stable), checked on Docker Hub.
A Kubernetes ReplicaSet is an apps/v1 object that keeps a fixed number of identical Pods running. You give it three things: a replica count, a label selector that says which Pods count as its own, and a Pod template it copies whenever it needs a new one. Delete a Pod and it creates a replacement. Start an extra Pod with matching labels and it removes one. You'll rarely write a ReplicaSet by hand in real work, because a Deployment creates and manages ReplicaSets for you and adds rolling updates and rollback on top. This chapter builds one directly anyway, since every Deployment you run later is a ReplicaSet underneath.
What a ReplicaSet guarantees, and what it leaves alone#
The promise is narrow: at any moment, the number of Pods matching the selector should equal .spec.replicas. If a Pod is deleted, evicted, or lost with its node during a failure or a kernel upgrade, the controller notices the shortfall and creates another from the template. That's why the official docs suggest a ReplicaSet even for an app that needs only one Pod. A bare Pod that dies stays dead.
Three things fall outside it.
- Container restarts. When the container inside a Pod crashes, the kubelet on that node restarts it while the Pod object stays put, so the ReplicaSet sees no gap and does nothing at all. A Pod stuck in that loop is the subject of the CrashLoopBackOff chapter.
- Updates. The template is read only when a new Pod has to be created, which means that if you change the image, every Pod already running keeps the old one. No rolling update.
- An exact count during shutdown. Pods that are terminating after a delete or a scale-down can take a while to go, so the total can briefly sit above
.spec.replicas. Since Kubernetes 1.35 the ReplicaSet reports those Pods in.status.terminatingReplicas(a beta feature, on by default).
Before you start#
You need a running cluster and kubectl pointed at it. A local one from the KinD Cluster or K3s and K3sup chapter is plenty. You should also be comfortable with labels on a Pod; Kubernetes Pods covers them.
About the image. Old ReplicaSet tutorials often use images like mongo:3.3, a tag last pushed in November 2016 and built for amd64 only, so it won't start on an arm64 laptop such as an Apple silicon Mac. The examples below use the official nginx:1.30-alpine, which today points at the same image as nginx's stable-alpine tag (1.30.6) and is published for amd64, arm64 and several other platforms. Later on you'll also use nginx:1.31-alpine, the mainline branch, to show what an image change does.
Write the manifest#
Save this as web-rs.yaml:
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: web
labels:
app: web
spec:
replicas: 3
selector:
matchLabels:
app: web
tier: frontend
template:
metadata:
labels:
app: web
tier: frontend
spec:
containers:
- name: nginx
image: nginx:1.30-alpine
ports:
- containerPort: 80
Most of the file is a Pod spec you've seen before, nested under template. The parts that belong to the ReplicaSet itself are worth reading line by line:
selector.matchLabelsandtemplate.metadata.labelshave to agree. If the template's labels don't satisfy the selector, the API server rejects the object, because the ReplicaSet would create Pods it could never count.replicasdefaults to 1 when you leave it out.- The selector can also take
matchExpressions, with the operatorsIn,NotIn,ExistsandDoesNotExist. That set-based form is the one feature that separates a ReplicaSet from the older ReplicationController. template.spec.restartPolicymay only beAlways, which is also the default, so you can omit it.- The name
webbecomes the prefix of every Pod name. It must be a valid DNS subdomain, and the docs advise the stricter DNS label rules so Pod hostnames come out clean.
Apply it and look around#
Create the ReplicaSet, then list it with its short name, rs:
kubectl apply -f web-rs.yaml
kubectl get rs
The output looks like this:
NAME DESIRED CURRENT READY AGE
web 3 3 3 12s
DESIRED is your replicas value. CURRENT is how many Pods the controller has. READY counts the ones passing their checks. When the three numbers disagree for more than a few seconds, something is stopping Pods from starting, and describe is where the reason shows up:
kubectl describe rs/web
kubectl get pods --show-labels
In the describe output, check the Selector line, the Replicas: 3 current / 3 desired line, and the Events at the bottom: one SuccessfulCreate event from replicaset-controller per Pod. The Pod listing should look roughly like this, with random suffixes on the names:
NAME READY STATUS RESTARTS AGE LABELS
web-4xk2p 1/1 Running 0 40s app=web,tier=frontend
web-9rtzq 1/1 Running 0 40s app=web,tier=frontend
web-hm7lc 1/1 Running 0 40s app=web,tier=frontend
Self-healing: delete a Pod and watch it come back#
Open a second terminal and start a watch on the Pods that carry the app label:
kubectl get pods -l app=web -w
Back in the first terminal, delete one of them by name (use a name from your own listing):
kubectl delete pod web-4xk2p
The watch shows the old Pod going to Terminating while a new one with a fresh suffix appears and moves to Running. Run kubectl describe rs/web again and there's a fourth SuccessfulCreate event. You didn't ask for that Pod; the gap between desired and current did.
There's a gentler way to take a Pod out of the set, useful when you want to debug one without it receiving traffic. Change a label so it no longer matches the selector:
kubectl label pod web-9rtzq tier=debug --overwrite
Now the ReplicaSet counts two matching Pods and creates a third. The relabelled Pod keeps running on its own, outside the set, until you delete it yourself. Do that now so it doesn't confuse the next steps.
Scaling up and down#
Scaling is a change to one field. The quick, imperative way:
kubectl scale --replicas=5 rs/web
If you'd like the change to fail when someone else has already resized it, add a precondition: kubectl scale --current-replicas=5 --replicas=2 rs/web only goes through while the count is still 5. You can also open the live object with kubectl edit rs/web, or change replicas in web-rs.yaml and run kubectl apply -f web-rs.yaml again. Pick one habit. apply sends whatever number the file holds, so a file that still says 3 quietly undoes an earlier kubectl scale.
Scaling down raises a question the scale-up never does: which Pods go? The controller ranks them, roughly in this order:
- Pending Pods, including ones that can't be scheduled, are removed first.
- Next comes the
controller.kubernetes.io/pod-deletion-costannotation, if set: lower cost goes first, and Pods without it count as 0. - Pods on nodes that hold more replicas of the set go before Pods on emptier nodes.
- Newer Pods before older ones (creation times are compared in coarse buckets).
- Anything still tied is picked at random.
The deletion-cost annotation is honoured on a best-effort basis only. Set it once before a planned scale-down; updating it constantly floods the API server with Pod updates.
Owner references: how a ReplicaSet knows its Pods#
The selector tells the controller which Pods it may take. Ownership itself is written onto each Pod, in metadata.ownerReferences. Print it next to each Pod name:
kubectl get pods -l app=web -o custom-columns='NAME:.metadata.name,OWNER:.metadata.ownerReferences[0].name'
Or ask for one Pod's full YAML with kubectl get pod <name> -o yaml. The relevant block looks like this (your uid will differ):
ownerReferences:
- apiVersion: apps/v1
blockOwnerDeletion: true
controller: true
kind: ReplicaSet
name: web
uid: 3f6c1d2e-...
controller: true marks the ReplicaSet as the one controller managing this Pod. The same link is what the garbage collector follows when you delete the ReplicaSet later, and what keeps two controllers from fighting over a Pod they both match.
Adopting bare Pods with matching labels#
Here's the part that surprises people. A ReplicaSet doesn't only own Pods it created. Any Pod that matches its selector and has no controller owner gets acquired immediately. Try it with a hand-made Pod that runs a different image:
apiVersion: v1
kind: Pod
metadata:
name: stray
labels:
app: web
tier: frontend
spec:
containers:
- name: nginx
image: nginx:1.31-alpine
Save it as stray.yaml and apply it while web already has its full count. The ReplicaSet adopts stray, finds itself one over, and terminates a Pod; according to the docs it's the new arrival that goes. Reverse the order (delete the ReplicaSet, create stray first, then apply web-rs.yaml) and the ReplicaSet adopts stray and creates only the missing two. Your set now runs two images at once, which is legal, because nothing checks that adopted Pods match the template. The practical rule: never give a bare Pod labels that a ReplicaSet's selector would match.
What happens when you change the image#
Edit web-rs.yaml so the container uses nginx:1.31-alpine, apply it, and list each Pod with its image:
kubectl apply -f web-rs.yaml
kubectl get pods -l app=web -o custom-columns='NAME:.metadata.name,IMAGE:.spec.containers[0].image'
Every Pod still reports nginx:1.30-alpine. The ReplicaSet object changed; the Pods didn't. Delete one of them and its replacement comes up on 1.31, so you end up with a mix of versions that depends on which Pods happened to die. Getting from old to new safely, a few Pods at a time with the option to go back, takes a second ReplicaSet and someone moving the counts between the two. That someone is the Deployment.
Deleting a ReplicaSet, with or without its Pods#
A plain delete removes the ReplicaSet and its Pods:
kubectl delete rs/web
That's background cascading deletion, the default for kubectl delete (the flag is --cascade=background). The API server removes the ReplicaSet at once and the garbage collector cleans up every Pod whose owner reference points at it. With --cascade=foreground the order flips. The ReplicaSet stays visible with a deletionTimestamp and a foregroundDeletion finalizer until its dependents are gone, and only then disappears.
The third option keeps the Pods running:
kubectl delete rs/web --cascade=orphan
kubectl get pods -l app=web
The Pods are now orphans with no owner. Apply a ReplicaSet with the same selector and it adopts them on the spot. It won't, however, touch them to fit a new template, so orphan-and-recreate is no way to roll out a change.
ReplicaSet vs Deployment#
A Deployment is a controller one level up: it owns ReplicaSets the way a ReplicaSet owns Pods. When its Pod template changes, it creates a new ReplicaSet and shifts replicas across from the old one. The old ReplicaSet is kept, scaled to zero, as the record of that revision.
| ReplicaSet | Deployment | |
|---|---|---|
| Manages | Pods, directly | ReplicaSets, which manage the Pods |
| Template change | Affects only Pods created afterwards | Starts a rollout to a new ReplicaSet |
| Update strategy | None | RollingUpdate by default (25% max unavailable, 25% max surge), or Recreate |
| History and rollback | None | Old ReplicaSets kept (revisionHistoryLimit, default 10); kubectl rollout undo |
| Pod names | web-4xk2p | web-75675f5897-7ci7o, the middle part matching the pod-template-hash label |
| Use it directly when | You need custom update orchestration, or the Pods never change | Almost always otherwise |
The Kubernetes docs put it plainly: unless you need custom update orchestration or no updates at all, use a Deployment. Scaling a Deployment doesn't create a revision; only a change to its Pod template does, so autoscaling and history don't get in each other's way.
Once you run Deployments, kubectl get rs fills up with names like web-75675f5897. Those belong to their Deployment and the docs say not to manage them yourself. Scale, edit and roll back through the Deployment. The next chapter, Kubernetes Deployment, takes the manifest from this page, changes two lines, and does the version change you just saw go wrong.
ReplicationController, the predecessor#
Before ReplicaSets there was the ReplicationController, still served at apiVersion: v1, kind: ReplicationController. It does the same job with one limitation: its selector is a flat map of equality-based labels, with no matchLabels wrapper and no matchExpressions. The docs now list it as a legacy API, superseded by Deployment and ReplicaSet. You'll meet it in old tutorials and old clusters. Don't write new ones.
Common mistakes#
- A selector and template labels that don't match, so the API rejects the manifest. Read the error, then compare the two blocks key by key.
- Two controllers, or a controller and a stray Pod, with overlapping labels. Kubernetes doesn't stop the overlap, and the results are hard to read.
- Waiting for a new image to roll out from a ReplicaSet. It won't; that's the Deployment's job.
- Scaling or editing a ReplicaSet that a Deployment owns. Check the
ownerReferenceson the ReplicaSet first. - Using a ReplicaSet for work that should finish and stop. Batch work belongs in a Job, and one Pod per node (log or monitoring agents) belongs in a DaemonSet.
Clean up#
Remove the ReplicaSet with its Pods, plus anything you created by hand along the way:
kubectl delete rs/web
kubectl delete pod stray --ignore-not-found
kubectl get pods -l app=web
If you orphaned the Pods earlier and never re-adopted them, the last command still lists them; delete them with kubectl delete pods -l app=web.
Where this fits in the book#
Pods came first, in Run Pods. This chapter added a controller that keeps a count of them. Kubernetes Deployment adds versions and rollbacks, and Kubernetes Service gives the whole set one stable address, picked out by the same labels you used here. For the reference material behind this page, see the official ReplicaSet, Garbage Collection and Deployments pages, plus the kubectl delete reference for the --cascade values.
Further watching#
- Video by Anais Urlichs (the original 100 Days of Kubernetes series)