DaemonSets
One pod per node, for agents and exporters.
You just added three worker nodes to the cluster. Nobody ran a deploy, nobody set a replica count, and within a minute a log shipper, a metrics agent, and the network plugin are all running on each new machine. That's a DaemonSet doing its one job. It keeps exactly one copy of a Pod (the smallest thing Kubernetes runs, one or more containers bundled together) on every node that qualifies, and it fixes the count on its own as nodes come and go.
Think of a building where every floor needs its own smoke detector. You don't decide the building gets twelve of them and scatter them around at random. You say one per floor, and when a new floor is built, it gets one too. A Deployment is the scatter approach: you pick a replica count and the scheduler drops those Pods wherever they fit. A DaemonSet is the one-per-floor rule. You never pick a number. The count is however many nodes you have, and it tracks them.
So the fit is anything that's about the node itself rather than about serving user traffic. A metrics exporter reading the node's CPU and disk. A log collector like Fluent Bit tailing every container's logs on that machine. A security agent like Falco watching system calls. The CNI plugin (Container Network Interface, the component that gives Pods their networking) has to sit on every node or Pods there never get an IP address. Run any of these as a Deployment and you'd get some arbitrary number of copies on random nodes, leaving most nodes uncovered. Blind spots, exactly where you wanted eyes.
One per node, and no replica field
Here's a real one: node-exporter, the Prometheus agent that reads hardware and kernel metrics off a node. Look for a replicas field. There isn't one. There's a label selector, a Pod template, and an update strategy, and that's the whole shape.
apiVersion: apps/v1kind: DaemonSetmetadata:name: node-exporternamespace: monitoringspec:selector:matchLabels:app: node-exporterupdateStrategy:type: RollingUpdaterollingUpdate:maxUnavailable: 1template:metadata:labels:app: node-exporterspec:hostNetwork: truetolerations:- operator: Existscontainers:- name: node-exporterimage: prom/node-exporter:v1.8.1ports:- containerPort: 9100hostPort: 9100
That tolerations block with operator: Exists means tolerate every taint, so no node repels this pod. Apply the manifest, then ask the API server (the cluster's control desk, the one thing every command talks to) what you got.
kubectl apply -f node-exporter.yamlkubectl get ds node-exporter -n monitoring
NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGEnode-exporter 4 4 4 4 4 <none> 38s
Read those columns like a checklist. DESIRED is how many nodes match (four here). CURRENT, READY, and AVAILABLE climb to meet it. UP-TO-DATE tells you how many run the current template, which is the number you watch during an upgrade. NODE SELECTOR is <none> because we didn't restrict it. Now confirm the placement is really one per node, not four on one node.
kubectl get pods -n monitoring -o wide -l app=node-exporter
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATESnode-exporter-4xk2p 1/1 Running 0 52s 10.0.0.10 cp-1 <none> <none>node-exporter-7dqzr 1/1 Running 0 52s 10.0.0.11 worker-1 <none> <none>node-exporter-b9m8s 1/1 Running 0 52s 10.0.0.12 worker-2 <none> <none>node-exporter-kf2wl 1/1 Running 0 52s 10.0.0.13 worker-3 <none> <none>
Four pods on four nodes, and the IP is the node's own address because we set hostNetwork. The control-plane node got covered too, and that only happened because of the tolerations. That's where the interesting behavior lives.
What actually pins a pod to each node
For years the line was that the DaemonSet controller schedules its own pods. That stopped being true back in v1.12, and the newer story is worth knowing cold for the exam. The controller creates one Pod object per eligible node and stamps each one with a node affinity rule that names that exact node. Then it steps back and lets the normal kube-scheduler bind the pod, the same way it binds anything else. You can see the injected rule on any daemon pod.
kubectl get pod node-exporter-7dqzr -n monitoring -o yaml | grep -A9 affinity
affinity:nodeAffinity:requiredDuringSchedulingIgnoredDuringExecution:nodeSelectorTerms:- matchFields:- key: metadata.nameoperator: Invalues:- worker-1
That matchFields on metadata.name is the fingerprint of a DaemonSet pod. Why does the change matter? Because the scheduler is the thing placing these pods now, they obey the same rules as everything else: resource requests, taints, priority, and preemption. If a node is genuinely out of room, the daemon pod sits Pending with an ordinary FailedScheduling event instead of getting force-fit onto a starved machine. It's more honest, and it surprises people who still expect daemon pods to muscle their way on.
The controller does hand you one convenience. It auto-adds tolerations for the taints Kubernetes stamps on a node itself: not-ready, unreachable when the control plane loses contact with the kubelet, low memory, low disk, low process IDs, and node.kubernetes.io/unschedulable, the taint kubectl cordon puts on a node. A pod using hostNetwork gets network-unavailable too. That's why a daemon pod keeps running on a node that's sick or cordoned, right when you most want its logs and metrics, and it's why cordoning a node does not stop a daemon pod from being placed there. What the controller does not do is tolerate taints you invented yourself. Hold that thought.
Rolling out a new version
A DaemonSet updates much like a Deployment, with one twist: there's one pod per node, so the rollout walks the cluster node by node. The default is RollingUpdate with maxUnavailable: 1, which means one node loses its daemon pod at a time while the replacement comes up. Bump the image and watch it march.
kubectl set image ds/node-exporter node-exporter=prom/node-exporter:v1.8.2 -n monitoringkubectl rollout status ds/node-exporter -n monitoring
daemonset.apps/node-exporter image updatedWaiting for daemon set "node-exporter" rollout to finish: 1 out of 4 new pods have been updated...Waiting for daemon set "node-exporter" rollout to finish: 2 out of 4 new pods have been updated...Waiting for daemon set "node-exporter" rollout to finish: 3 out of 4 new pods have been updated...daemon set "node-exporter" successfully rolled out
maxUnavailable sets the pace. Push it to 25% on a large cluster and you replace pods a quarter of the nodes at a time, much faster, at the cost of that fraction of nodes briefly running no agent. Kubernetes also lets you set maxSurge instead, which brings the new pod up before the old one goes down on the same node, for true zero-gap coverage. That only works if the pod can tolerate two copies overlapping for a moment. A hostPort or hostNetwork agent like this one can't, because the port is already taken, so maxSurge would just wedge the new pod in Pending. The other strategy is OnDelete: nothing changes until you delete a pod by hand, for when you want to babysit a risky change node by node. Update history lives in ControllerRevision objects, not ReplicaSets, so a rollback is kubectl rollout undo ds/node-exporter.
Only some nodes
Sometimes one per node is too many. A GPU (graphics processing unit) monitoring agent only belongs on GPU nodes. Add a nodeSelector to the template and the eligible set shrinks to nodes carrying that label. Label one node, patch the DaemonSet, and the controller re-evaluates instantly.
kubectl label node worker-3 gpu=truekubectl patch ds node-exporter -n monitoring --type merge \-p '{"spec":{"template":{"spec":{"nodeSelector":{"gpu":"true"}}}}}'
node/worker-3 labeleddaemonset.apps/node-exporter patched
kubectl get ds node-exporter -n monitoringNAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGEnode-exporter 1 1 1 1 1 gpu=true 6m
DESIRED fell from 4 to 1. The three nodes that no longer match had their daemon pods removed, and only worker-3 keeps one. Flip the requirement (label more nodes, or drop the selector) and the controller adds or deletes pods to match, without you ever touching a count.
Two habits keep a DaemonSet rollout boring. First, pace it on purpose. maxUnavailable is really a count of how many nodes are blind at once, so 25% of a 200-node cluster means fifty machines with no log shipper for as long as the new pod takes to go Ready. Second, write the coverage down. If you are deliberately skipping control-plane nodes, say so in a comment on the manifest, because the next person paged at 3am cannot tell an intentional gap from a forgotten toleration.
One more thing is worth guarding: how much of the node the pod can touch. Agents like this one usually want hostNetwork, a hostPath mount of /proc or /var/log, and sometimes a privileged container, which means whoever gets inside a daemon pod effectively gets the node. Mount those host paths read-only wherever the agent will accept it, and give the pod's ServiceAccount only the API calls it actually makes, using RBAC (role-based access control, the rules that decide which API calls an identity is allowed to make).
Try this
Recreate the coverage gap on purpose in a lab namespace. Apply node-exporter.yaml with the tolerations block deleted, then compare DESIRED against your node count: the control-plane node is missing. Patch the toleration back in and watch coverage return. Then cordon a worker and delete its daemon pod. A replacement appears anyway, because cordon adds node.kubernetes.io/unschedulable and the controller already tolerates that one.
$ kubectl apply -f node-exporter.yaml # tolerations block deleteddaemonset.apps/node-exporter created$ kubectl get nodes --no-headers | wc -l4$ kubectl get ds node-exporter -n monitoringNAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGEnode-exporter 3 3 3 3 3 <none> 25s$ kubectl patch ds node-exporter -n monitoring --type merge \-p '{"spec":{"template":{"spec":{"tolerations":[{"operator":"Exists"}]}}}}'daemonset.apps/node-exporter patched$ kubectl get ds node-exporter -n monitoringNAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGEnode-exporter 4 4 4 4 4 <none> 71s$ kubectl cordon worker-2node/worker-2 cordoned$ kubectl delete pod node-exporter-6c9fx -n monitoringpod "node-exporter-6c9fx" deleted$ kubectl get pods -n monitoring -o wide -l app=node-exporter --field-selector spec.nodeName=worker-2NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATESnode-exporter-tq7vn 1/1 Running 0 8s 10.0.0.12 worker-2 <none> <none>$ kubectl uncordon worker-2node/worker-2 uncordoned
Takeaway
DaemonSets run one pod per matching node for agents and exporters. They ignore ordinary replica counts and follow node lifecycle.