DaemonSet vs Deployment vs StatefulSet: Per Node, Anywhere, or by Name
9 min readBytePatterns
DaemonSet vs Deployment vs StatefulSet: one Pod per node that follows nodes in and out, N interchangeable Pods anywhere, or N ordered Pods with stable names.
DaemonSet vs Deployment vs StatefulSet in one line: a DaemonSet runs one Pod on every eligible node and follows nodes as they join and leave, a Deployment runs a chosen number of interchangeable Pods wherever the scheduler puts them, and a StatefulSet runs a chosen number of Pods that each keep a stable name, a stable network identity and their own storage. The question that picks between them is not "is this app stateful" but two smaller ones: is the count tied to the nodes or chosen by you, and does it matter which Pod is which? Facts come from the Kubernetes documentation listed at the end, as of October 2026.
| Controller | How many Pods | Is a replacement the same Pod? |
|---|---|---|
| DaemonSet | one per eligible node; no replicas field | yes in role: the Pod for that node |
| Deployment | replicas, anywhere | no: new name, new UID, any node |
| StatefulSet | replicas, ordinals 0 to N-1 | yes: same name, same claim, same DNS entry |
The problem it solves
Three workloads in the same cluster want three different things. A log shipper must run on every node, including the node that joins at 03:00 under autoscaling, and must not run twice on any of them. An API wants six copies and does not care where they are or what they are called. A database wants three members that know each other by name, start in order, and come back as themselves after a failure. Each is a Pod template; the controller around it is what encodes the placement and the identity.
The intuition
A DaemonSet counts nodes, not replicas. The documentation says it ensures that all, or some, nodes run a copy of a Pod: as nodes are added to the cluster, Pods are added to them, and as nodes are removed, those Pods are garbage collected. There is no replicas field, because the count is the node count. "Some" nodes comes from a nodeSelector or node affinity in the template, which is also how a DaemonSet stays off nodes it should not touch. Its Pods are given tolerations for several node conditions automatically, so an agent keeps running on a node under memory or disk pressure, and a hostNetwork DaemonSet tolerates the network-unavailable taint it may be there to fix. The uses are the ones the documentation lists: a cluster storage daemon, a log collector, a node monitoring agent, and the network plugin itself. How selectors, taints and tolerations steer that placement is in taints and tolerations vs node affinity.
A Deployment counts replicas and forgets names. It wants replicas Pods matching its selector, through a ReplicaSet, and leaves the node choice to the scheduler. A Pod that dies is not moved: a new Pod is created, with a new generated name and a new UID, on whatever node fits. Two replicas may land on the same node unless you say otherwise. Rollouts create a second ReplicaSet and shift the count across, with maxSurge and maxUnavailable bounding how many Pods exist at once. How those objects divide the work is in Pod vs ReplicaSet vs Deployment.
A StatefulSet counts replicas and keeps names. Each Pod gets an ordinal, db-0 to db-N-1, and a stable hostname. Through a headless Service named in serviceName, each one has its own DNS entry, db-0.db, that points at the current Pod with that name. With the default OrderedReady policy the controller creates db-0 and waits until it is Running and Ready before creating db-1, and scales down in reverse; Parallel turns that ordering off. A volumeClaimTemplates entry gives every ordinal its own PersistentVolumeClaim, data-db-1 for db-1, and a replacement db-1 mounts the same claim again. The claims, the reclaim policy and what scale-down leaves behind are the subject of StatefulSet vs Deployment: PVs, PVCs and stable storage; here the point is the identity those claims hang from.
Updates differ too. A DaemonSet has two strategies: RollingUpdate, the default, replaces Pods node by node within maxUnavailable, and OnDelete replaces a Pod only when you delete it. A StatefulSet has the same two, and its RollingUpdate adds a partition: only Pods with an ordinal at or above the partition are updated, so you can roll db-2 alone, watch it, then lower the partition. A Deployment's rolling update has no such ordering because its Pods have none.
So the decision reads:
- Must it run on every node, or every node of a kind, and move with the nodes: DaemonSet.
- A number you choose, Pods that are interchangeable: Deployment.
- A number you choose, Pods that must be addressable individually, start in order, or keep their own disk: StatefulSet.
A Pod that should finish rather than run forever is none of these; that choice is in Job vs CronJob vs Deployment.
Watch it run
The lesson animation starts with three replicas that are not interchangeable. The StatefulSet creates db-0 first, with a claim from its template, data-db-0, and only once db-0 is Running and Ready does db-1 start, then db-2. Through the headless Service, db-0 is reachable as db-0.db. Then db-1's node fails: the Pod is gone, its claim and volume are not, and the replacement comes back as db-1 and mounts data-db-1 again. Hold that frame against the other two controllers: a Deployment would have brought back a Pod with a new name, and a DaemonSet would have brought back nothing, because the node it belonged to is gone.
Storage: PV, PVC & StatefulSet
Step 1 of 11
A database's replicas are not interchangeable: each needs its own name and its own data.
The same interactive animation as the lesson — step through it with the controls.
The code
The three manifests, reduced to the fields that differ. The container is the same in all three:
# illustrative: the same container under three controllers
apiVersion: apps/v1
kind: DaemonSet
metadata: { name: log-agent }
spec:
selector: { matchLabels: { app: log-agent } } # no replicas: one Pod per eligible node
updateStrategy: { type: RollingUpdate, rollingUpdate: { maxUnavailable: 1 } }
template:
metadata: { labels: { app: log-agent } }
spec:
nodeSelector: { kubernetes.io/os: linux } # "some nodes"
tolerations: [{ key: node-role.kubernetes.io/control-plane, operator: Exists, effect: NoSchedule }]
containers: [{ name: agent, image: registry.example.com/log-agent:2.1 }]
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: api }
spec:
replicas: 3 # N, anywhere
selector: { matchLabels: { app: api } }
template:
metadata: { labels: { app: api } }
spec:
containers: [{ name: api, image: registry.example.com/api:1.4.2 }]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: db }
spec:
serviceName: db # a headless Service: db-0.db, db-1.db, db-2.db
replicas: 3 # N, with ordinals 0..2
selector: { matchLabels: { app: db } }
updateStrategy: { type: RollingUpdate, rollingUpdate: { partition: 2 } } # roll db-2 only, for now
template:
metadata: { labels: { app: db } }
spec:
containers: [{ name: db, image: registry.example.com/db:1.0, volumeMounts: [{ name: data, mountPath: /data }] }]
volumeClaimTemplates:
- metadata: { name: data }
spec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 10Gi } } }
A toy model of the three controllers over a cluster of nodes, not the Kubernetes controllers or the scheduler: no readiness, no API server, and node choice by a simple formula. DaemonSet Pods are named by their node here for readability; real ones get a generated suffix like Deployment Pods do. Each controller's reconcile is observe, compare, act:
import itertools
class Cluster:
"""Toy model of nodes and Pods, not the scheduler or the controllers."""
def __init__(self, nodes):
self.nodes, self.pods, self.uids = list(nodes), {}, itertools.count(1)
def add_pod(self, name, node):
self.pods[name] = node
def fail_node(self, node): # its Pods are gone, not moved
self.nodes.remove(node)
for name in [n for n, where in self.pods.items() if where == node]:
del self.pods[name]
class DaemonSet:
"""One Pod per eligible node; no replicas field."""
def __init__(self, c, name, eligible=lambda node: True):
self.c, self.name, self.eligible = c, name, eligible
def reconcile(self):
for node in self.c.nodes:
if self.eligible(node) and f"{self.name}-{node}" not in self.c.pods:
self.c.add_pod(f"{self.name}-{node}", node)
def pods(self):
return sorted(n for n in self.c.pods if n.startswith(self.name + "-"))
class Deployment:
"""N interchangeable Pods; a replacement gets a new random-looking name."""
def __init__(self, c, name, replicas):
self.c, self.name, self.replicas = c, name, replicas
def reconcile(self):
while len(self.pods()) < self.replicas:
uid = next(self.c.uids)
self.c.add_pod(f"{self.name}-7c9d4-{uid:05x}", self.c.nodes[uid % len(self.c.nodes)])
def pods(self):
return sorted(n for n in self.c.pods if n.startswith(self.name + "-"))
class StatefulSet:
"""N ordered Pods with stable ordinal names; a replacement reuses the name."""
def __init__(self, c, name, replicas):
self.c, self.name, self.replicas = c, name, replicas
def reconcile(self):
for i in range(self.replicas): # ordinal 0 first, then 1, ...
if f"{self.name}-{i}" not in self.c.pods:
self.c.add_pod(f"{self.name}-{i}", self.c.nodes[i % len(self.c.nodes)])
return # the next ordinal waits until this one is Ready
def pods(self):
return sorted(n for n in self.c.pods if n.startswith(self.name + "-"))
c = Cluster(["node-1", "node-2", "node-3"])
ds, dep, sts = DaemonSet(c, "log-agent"), Deployment(c, "api", 3), StatefulSet(c, "db", 3)
def settle():
for _ in range(5): # a few reconcile rounds
ds.reconcile(); dep.reconcile(); sts.reconcile()
settle()
for ctrl in (ds, dep, sts):
print(f"{ctrl.name:10}", ctrl.pods())
# log-agent ['log-agent-node-1', 'log-agent-node-2', 'log-agent-node-3']
# api ['api-7c9d4-00001', 'api-7c9d4-00002', 'api-7c9d4-00003']
# db ['db-0', 'db-1', 'db-2']
Three nodes, so three agents; three replicas of api with generated names; three database members with ordinals, created one at a time because reconcile returns after each. Now the two events that separate the controllers: a node joins, and a node is lost with everything on it:
print()
c.nodes.append("node-4") # the cluster autoscaler adds a node
settle()
print("after adding node-4")
for ctrl in (ds, dep, sts):
print(f" {ctrl.name:10} {len(ctrl.pods())} pods", ctrl.pods() if ctrl is ds else "")
print()
lost = {name: node for name, node in c.pods.items() if node == "node-2"}
c.fail_node("node-2") # node-2 dies with everything on it
print("node-2 lost:", sorted(lost))
settle()
print("after reconciling")
for ctrl in (ds, dep, sts):
print(f" {ctrl.name:10} {len(ctrl.pods())} pods", ctrl.pods())
#
# after adding node-4
# log-agent 4 pods ['log-agent-node-1', 'log-agent-node-2', 'log-agent-node-3', 'log-agent-node-4']
# api 3 pods
# db 3 pods
#
# node-2 lost: ['api-7c9d4-00001', 'db-1', 'log-agent-node-2']
# after reconciling
# log-agent 3 pods ['log-agent-node-1', 'log-agent-node-3', 'log-agent-node-4']
# api 3 pods ['api-7c9d4-00002', 'api-7c9d4-00003', 'api-7c9d4-00004']
# db 3 pods ['db-0', 'db-1', 'db-2']
The new node changed exactly one controller: the DaemonSet gained an agent, the other two kept their counts. The lost node took one Pod from each, and the three repairs differ. The DaemonSet made nothing, because the node its Pod belonged to no longer exists: three nodes, three agents. The Deployment made a Pod with a new name, 00004, and does not remember 00001. The StatefulSet made db-1 again, the same name the headless Service and the claim data-db-1 were pointing at all along.
The complexity
The costs are in Pod count, in startup time and in blast radius:
- DaemonSet: Pod count equals eligible node count, so the cost scales with the cluster, not with traffic; a heavy agent on 500 nodes is 500 copies of its request. A rolling update touches
maxUnavailablenodes at a time. - Deployment: a constant
replicas, with up tomaxSurgeextra during a rollout, and a replacement that is ready as soon as a new Pod starts, on any node with room. - StatefulSet: the same count, but
OrderedReadymakes creation and scale-up sequential, so a three-member set takes three readiness waits to come up, and a stuckdb-1blocksdb-2. A replacement must also wait for its volume to attach where the Pod lands, which, as the storage article covers, limits where it can land.
Where it goes wrong
- A Deployment with
replicasequal to the node count as a fake DaemonSet. Nothing stops two copies on one node and none on another, and the count does not follow autoscaling. - A DaemonSet for something that needs a fixed count. Every new node brings another copy; a queue consumer sized for three Pods now runs on twelve.
- A Deployment for members that address each other. Pod names change on every replacement, so peer lists written at startup rot. Use a StatefulSet and its per-Pod DNS.
- A stuck ordinal. With
OrderedReady, adb-1that never becomes Ready holdsdb-2back indefinitely;Parallelis the escape when members do not depend on order. - DaemonSet Pods missing from tainted nodes. The automatic tolerations cover node conditions, not your own taints; a
NoScheduletaint you added needs a matching toleration in the template.
When it shows up in interviews
As the follow-up to any Kubernetes design: "what runs the log collector, the API and the database, and why three different kinds?" The answer the interviewer wants is the two questions, count tied to nodes or chosen, and identity that matters or not, with DNS and ordered startup as the StatefulSet's reasons to exist. The storage side, claims that outlive Pods and follow the ordinal, is in the storage, PV, PVC and StatefulSet lesson.
How to say it in an interview
"A DaemonSet runs one Pod per eligible node and follows the nodes: add a node and it gets an agent, lose a node and that Pod is gone with it, no replicas field. A Deployment runs replicas interchangeable Pods wherever the scheduler puts them, and a lost Pod is replaced by a new one with a new name. A StatefulSet runs replicas Pods with ordinals, stable names and per-Pod DNS through a headless Service, creates them in order, and gives each its own volume claim, so a replacement db-1 is db-1 again with the same disk. Agents and node-level plumbing go in a DaemonSet, stateless services in a Deployment, and anything whose members must find each other by name or keep their own data in a StatefulSet."
Frequently asked questions
What is the difference between a DaemonSet and a Deployment?
A DaemonSet runs exactly one Pod on every eligible node, adding a Pod when a node joins and losing it when the node leaves; it has no replicas field. A Deployment runs a fixed number of interchangeable Pods wherever the scheduler places them, possibly several on one node, and replaces a lost Pod with a new one anywhere.
When should I use a StatefulSet instead of a Deployment?
When the Pods are not interchangeable: they need stable names and per-Pod DNS so members can find each other, ordered startup and shutdown, or a persistent volume that belongs to one replica and follows it after a restart. Databases, message brokers and consensus systems are the usual cases. A stateless API gains nothing from it.
Does a DaemonSet run on the control plane nodes?
Only if its Pods tolerate the control plane taint. A DaemonSet gets automatic tolerations for node conditions such as not-ready and memory pressure, but not for the node-role.kubernetes.io/control-plane NoSchedule taint many clusters apply; add that toleration to the template, or exclude those nodes with a nodeSelector.
Sources
- DaemonSet — Kubernetes Documentation
- Deployments — Kubernetes Documentation
- StatefulSets — Kubernetes Documentation
- Perform a Rolling Update on a DaemonSet — Kubernetes Documentation
- Service: headless Services — Kubernetes Documentation
- Taints and Tolerations — Kubernetes Documentation