| Tested on | Rocky Linux 10.2 (Red Quartz) workstation |
|---|---|
| Package | kubectl 1.36.3 |
| Applies to | Kubernetes clusters where Node objects can be listed and tainted; control-plane example requires a visible kubeadm control-plane node |
| Cert prep | CKA · CKAD |
| Lab environment | Multi-node kubeadm cluster with containerd — install Kubernetes with kubeadm |
| Privilege | Normal user (no sudo required on the workstation) |
| Scope | Node taints, Pod tolerations, taint effects, kubectl taint, scheduling failures, and DaemonSet control-plane tolerations. Does not cover node affinity, Pod topology spread, or full scheduler configuration. |
| Related guides | Kubernetes pods Kubernetes API resources Deployment versus DaemonSet |
A node taint marks a scheduling restriction. A Pod toleration is the matching exception. The scheduler evaluates both on every placement attempt. Labels and nodeSelector can narrow placement to a node, but an untolerated NoSchedule or NoExecute taint blocks normal scheduler placement. An untolerated PreferNoSchedule taint only makes the scheduler prefer other nodes. This walkthrough uses the taints-lab namespace so you can see each effect with real kubectl output.
This lab uses k8s-cp as the control-plane node and worker01 as the worker. Run kubectl get nodes and replace these names if your cluster uses different hostnames.
How Taints and Tolerations Work
How the scheduler filters nodes
Placement is not a single decision. The scheduler builds a candidate list, then removes nodes that fail each check:
Pod created
→ find nodes that satisfy resource and scheduling requirements
→ apply nodeSelector and node-affinity rules
→ ignore node taints that the Pod tolerates
→ remaining NoSchedule or NoExecute taint excludes the node
→ remaining PreferNoSchedule taint lowers the node's preference
→ an untolerated NoExecute taint can also evict an existing Pod| Mechanism | Lives on | Question it answers |
|---|---|---|
| Label + selector | Node label; Pod selector | Is this node a candidate for this Pod? |
| Taint | Node | Should this node reject Pods that lack permission? |
| Toleration | Pod | Does this Pod have permission to ignore a specific taint? |
The scheduler evaluates taints when placing a new Pod. For a Pod already running on a node, the taint-eviction-controller enforces an untolerated NoExecute taint.
A Pod can match nodeSelector and still stay Pending when the only eligible node carries a taint the Pod does not tolerate. The control-plane lab in the next section demonstrates exactly that sequence.
Labels, selectors, taints, and tolerations
Labels and selectors answer "which objects belong together?" Taints answer "which nodes should reject Pods that are not explicitly allowed?" Selectors and affinity attract Pods to labelled nodes; taints repel Pods, and tolerations permit exceptions.
| Mechanism | Direction | Who sets it | Typical use |
|---|---|---|---|
| Label + selector | Attracts | Admin on nodes or objects | Group Pods; Service endpoints; nodeSelector |
| Taint + toleration | Repels (unless tolerated) | Admin on nodes; workload author on Pods | Dedicated nodes; control-plane isolation; maintenance |
A node can have labels and taints at the same time. Meeting a nodeSelector is not enough when an untolerated taint is present.
A taint is a field on Node.spec.taints. Each entry has:
key— name of the restriction (for examplededicatedornode-role.kubernetes.io/control-plane)value— optional string paired with the key (for examplelogging)effect— what happens when a Pod lacks a matching toleration
Syntax on the command line:
key=value:EffectWhen value is empty, omit it:
node-role.kubernetes.io/control-plane:NoScheduleKubeadm applies that control-plane taint automatically so normal application Pods stay off the control-plane node unless they tolerate it.
A toleration is an entry under spec.tolerations on a Pod (or on a controller template such as a Deployment or DaemonSet). It does not force scheduling onto a node — it only removes the taint as a blocker.
Common fields:
key— taint key to matchoperator— normallyEqualorExists; defaults toEqualvalue— value compared byEqual; leave it empty when usingExistseffect— optional effect to match; when omitted, it matches any effecttolerationSeconds— optional grace period forNoExecute. When omitted from a matchingNoExecutetoleration, the Pod tolerates that taint indefinitely;0causes immediate eviction
Example toleration for the control-plane taint:
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoScheduleoperator: Exists matches the taint whether or not a value is set — useful for well-known keys such as node-role.kubernetes.io/control-plane.
NoSchedule, PreferNoSchedule, and NoExecute
| Effect | New Pods without a matching toleration | Pods already running on the node |
|---|---|---|
NoSchedule |
Not scheduled | Keep running |
PreferNoSchedule |
Scheduler tries other nodes first; may still place the Pod here | Keep running |
NoExecute |
Not scheduled | Evicted (respecting tolerationSeconds when set) |
NoSchedule is the default mental model for dedicated nodes. NoExecute is what you use when maintenance must clear existing workloads, not only block new ones.
Inspect Taints in the Lab Cluster
Create an isolated namespace for the examples:
kubectl create namespace taints-labSample output:
namespace/taints-lab createdList taints on every node:
kubectl get nodes -o custom-columns=NAME:.metadata.name,TAINTS:.spec.taintsSample output:
NAME TAINTS
k8s-cp [map[effect:NoSchedule key:node-role.kubernetes.io/control-plane]]
worker01 <none>The control-plane node carries node-role.kubernetes.io/control-plane:NoSchedule. The worker starts with no custom taints.
For more detail on one node:
kubectl describe node k8s-cp | grep -A2 '^Taints'Sample output:
Taints: node-role.kubernetes.io/control-plane:NoSchedule
Unschedulable: falseUnschedulable: false means the node is schedulable in principle — the taint still repels Pods without a toleration.
Test the Control-Plane NoSchedule Taint
Create a Pod without a toleration
Ask the scheduler to place a Pod on the control-plane node using nodeSelector, but do not add a toleration yet:
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: cp-scheduled
namespace: taints-lab
spec:
nodeSelector:
node-role.kubernetes.io/control-plane: ""
containers:
- name: app
image: nginx:1.27-alpine
EOFSample output:
pod/cp-scheduled createdWait until Kubernetes records the scheduling failure:
kubectl wait --for=condition=PodScheduled=false pod/cp-scheduled -n taints-lab --timeout=30sSample output:
pod/cp-scheduled condition metCheck status:
kubectl get pod cp-scheduled -n taints-labSample output:
NAME READY STATUS RESTARTS AGE
cp-scheduled 0/1 Pending 0 5sRead the scheduling event:
kubectl describe pod cp-scheduled -n taints-lab | tail -6Sample output:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 5s default-scheduler 0/2 nodes are available: 1 node(s) didn't match Pod's node affinity/selector, 1 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/2 nodes are available: 2 Preemption is not helpful for scheduling.The message had untolerated taint(s) means the Pod has no toleration for the control-plane taint. The worker did not match the nodeSelector, so only the control-plane node was considered — and the taint blocked placement.
spec.nodeName binds a Pod directly to a node and bypasses the scheduler. This means NoSchedule and PreferNoSchedule taints do not block the binding. A NoExecute taint is enforced after binding and can still evict the Pod when no matching toleration exists.
Add the matching toleration
Patch the manifest with a matching toleration and re-apply:
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: cp-scheduled
namespace: taints-lab
spec:
nodeSelector:
node-role.kubernetes.io/control-plane: ""
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: app
image: nginx:1.27-alpine
EOFSample output:
pod/cp-scheduled configuredWait until the Pod is Ready:
kubectl wait --for=condition=Ready pod/cp-scheduled -n taints-lab --timeout=60sSample output:
pod/cp-scheduled condition metVerify placement:
kubectl get pod cp-scheduled -n taints-lab -o wideSample output:
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
cp-scheduled 1/1 Running 0 15s 192.168.62.137 k8s-cp <none> <none>The Pod now runs on k8s-cp because the toleration satisfies the control-plane taint. This is the same toleration pattern DaemonSets use when a node agent must reach every node, including control-plane members.
Test a Custom NoSchedule Taint
Block a regular Pod
Reserve worker01 for logging workloads by tainting it:
kubectl taint nodes worker01 dedicated=logging:NoScheduleSample output:
node/worker01 taintedConfirm both nodes:
kubectl get nodes -o custom-columns=NAME:.metadata.name,TAINTS:.spec.taintsSample output:
NAME TAINTS
k8s-cp [map[effect:NoSchedule key:node-role.kubernetes.io/control-plane]]
worker01 [map[effect:NoSchedule key:dedicated value:logging]]Deploy a normal Pod with no tolerations:
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: regular-app
namespace: taints-lab
spec:
containers:
- name: app
image: nginx:1.27-alpine
EOFSample output:
pod/regular-app createdWait until Kubernetes records the scheduling failure:
kubectl wait --for=condition=PodScheduled=false pod/regular-app -n taints-lab --timeout=30sSample output:
pod/regular-app condition metRead the scheduling event:
kubectl describe pod regular-app -n taints-lab | tail -5Sample output:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 3s default-scheduler 0/2 nodes are available: 2 node(s) had untolerated taint(s). no new claims to deallocate, preemption: 0/2 nodes are available: 2 Preemption is not helpful for scheduling.Both nodes now repel this Pod: the control-plane taint on k8s-cp and dedicated=logging:NoSchedule on worker01.
Schedule a tolerated Pod
Keep the logging taint on worker01, then create a Pod that targets the node and carries the matching toleration:
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: logging-agent
namespace: taints-lab
spec:
nodeSelector:
kubernetes.io/hostname: worker01
tolerations:
- key: dedicated
operator: Equal
value: logging
effect: NoSchedule
containers:
- name: app
image: nginx:1.27-alpine
EOFSample output:
pod/logging-agent createdWait until the Pod is Ready:
kubectl wait --for=condition=Ready pod/logging-agent -n taints-lab --timeout=60sSample output:
pod/logging-agent condition metkubectl get pod logging-agent -n taints-lab -o wideSample output:
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
logging-agent 1/1 Running 0 2s 192.168.5.9 worker01 <none> <none>operator: Equal with value: logging matches the taint dedicated=logging:NoSchedule exactly. The nodeSelector narrows placement to worker01; the toleration satisfies the taint so the scheduler can place the Pod there.
Test NoExecute Eviction
NoSchedule only blocks new Pods. NoExecute also removes Pods that are already running.
Clear the earlier lab Pods so worker-app is the only workload from this walkthrough on worker01:
kubectl delete pod regular-app logging-agent -n taints-lab --ignore-not-foundSample output:
pod "regular-app" deleted
pod "logging-agent" deletedRemove the dedicated taint so worker-app can schedule without a custom toleration:
kubectl taint nodes worker01 dedicated-Sample output:
node/worker01 untaintedCreate a Pod on worker01:
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: worker-app
namespace: taints-lab
spec:
nodeSelector:
kubernetes.io/hostname: worker01
containers:
- name: app
image: nginx:1.27-alpine
EOFWait until the Pod is Ready:
kubectl wait --for=condition=Ready pod/worker-app -n taints-lab --timeout=60sSample output:
pod/worker-app condition metReview what is already running on the worker before you add the maintenance taint:
kubectl get pods -A --field-selector spec.nodeName=worker01 -o wideSample output:
NAMESPACE NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
calico-system calico-node-hc7ph 1/1 Running 1 (28h ago) 47h 192.168.56.109 worker01 <none> <none>
calico-system csi-node-driver-6c9pk 2/2 Running 2 (28h ago) 47h 192.168.5.3 worker01 <none> <none>
kube-system kube-proxy-4m4l7 1/1 Running 1 (28h ago) 47h 192.168.56.109 worker01 <none> <none>
local-path-storage local-path-provisioner-5f584dbd7d-vtvcr 1/1 Running 0 23m 192.168.5.12 worker01 <none> <none>Review this list before continuing. The maintenance=down:NoExecute taint can evict other Pods on worker01 when they lack a matching toleration, regardless of namespace. Run this test only on the disposable lab cluster.
NoExecute applies to both newly placed and already-running Pods that do not tolerate the taint.
Add a NoExecute taint:
kubectl taint nodes worker01 maintenance=down:NoExecuteSample output:
node/worker01 taintedWait for the Pod to be deleted:
kubectl wait --for=delete pod/worker-app -n taints-lab --timeout=60sSample output:
pod/worker-app condition metConfirm it is gone:
kubectl get pod worker-app -n taints-labSample output:
Error from server (NotFound): pods "worker-app" not foundThe taint-eviction-controller marked and deleted the Pod because it lacked a matching NoExecute toleration. The event reason remains TaintManagerEviction:
kubectl get events -n taints-lab --field-selector involvedObject.name=worker-app,reason=TaintManagerEvictionSample output:
LAST SEEN TYPE REASON OBJECT MESSAGE
8s Normal TaintManagerEviction pod/worker-app Marking for deletion Pod taints-lab/worker-appTo keep a Pod during maintenance, add a toleration for maintenance=down:NoExecute, or remove the taint when work finishes:
kubectl taint nodes worker01 maintenance-Test PreferNoSchedule
Apply a soft PreferNoSchedule taint:
kubectl taint nodes worker01 dedicated=logging:PreferNoScheduleDeploy a Pod with no tolerations:
kubectl delete pod regular-app -n taints-lab --ignore-not-foundkubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: prefer-demo
namespace: taints-lab
spec:
containers:
- name: app
image: nginx:1.27-alpine
EOFWait until the Pod is Ready:
kubectl wait --for=condition=Ready pod/prefer-demo -n taints-lab --timeout=60sSample output:
pod/prefer-demo condition metkubectl get pod prefer-demo -n taints-lab -o wideSample output:
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
prefer-demo 1/1 Running 0 2s 192.168.5.11 worker01 <none> <none>The scheduler placed the Pod on worker01 even without a toleration because PreferNoSchedule is a hint, not a hard block. The control-plane NoSchedule taint still kept k8s-cp unavailable, so the worker was the only viable choice. On a larger cluster with many untainted workers, this Pod would likely avoid the PreferNoSchedule node.
Default NotReady and Unreachable Tolerations
By default, Kubernetes adds NotReady and Unreachable tolerations to Pods that do not already define tolerations for those taints. Inspect prefer-demo, which has no custom tolerations:
kubectl get pod prefer-demo -n taints-lab -o jsonpath='{range .spec.tolerations[*]}{.key}{" "}{.effect}{" seconds="}{.tolerationSeconds}{"\n"}{end}'Sample output:
node.kubernetes.io/not-ready NoExecute seconds=300
node.kubernetes.io/unreachable NoExecute seconds=300The defaults come from the DefaultTolerationSeconds admission controller and normally use 300 seconds, although a cluster administrator can change those values.
nodeSelector vs taints and tolerations
These mechanisms answer different questions and are often confused because both affect where a Pod lands.
| Mechanism | Set on | Direction | Question it answers |
|---|---|---|---|
nodeSelector |
Pod | Attracts / requires | Which labelled nodes may this Pod use? |
| Taint | Node | Repels | Which Pods should stay off this node? |
| Toleration | Pod | Permits exception | May this Pod ignore a specific taint? |
nodeSelector is a Pod-side requirement. The scheduler only considers nodes whose labels match. It does not grant permission to run on a tainted node.
A taint is a node-side restriction. The cluster admin marks a node as reserved, maintenance-bound, or control-plane-only. Most Pods are turned away unless they carry a matching toleration.
A toleration alone does not schedule a Pod onto a node. It only stops a taint from blocking placement. The logging-agent lab used both: nodeSelector picked worker01, and the toleration satisfied dedicated=logging:NoSchedule.
| Goal | Use |
|---|---|
| Run this Pod only on GPU nodes | Label GPU nodes; set nodeSelector or affinity on the Pod |
| Keep most Pods off the control plane | Taint control-plane nodes; let system components add tolerations |
| Reserve a worker for logging agents | Taint the worker; add matching toleration only on logging Pods |
| Run a DaemonSet on every node including control plane | DaemonSet tolerations for control-plane taints — no nodeSelector required for that part |
| Drain a node and evict existing Pods | NoExecute taint — nodeSelector cannot do this |
When a Pod stays Pending, read the event carefully. didn't match node affinity/selector means the Pod's placement rule failed. had untolerated taint(s) means the node was a candidate but the Pod lacks permission. Both can appear in one message, as you saw with cp-scheduled.
For label and selector syntax detail, see Kubernetes labels and selectors. Node affinity and topology spread add softer or spread-based placement rules; this article stays with nodeSelector and taints.
Taints with DaemonSets
DaemonSet node agents often need tolerations for control-plane or dedicated taints. Add them under spec.template.spec.tolerations — the same location as a standalone Pod. Without a control-plane toleration, DESIRED stays below total node count because tainted nodes are excluded.
The previous section explains when to pair tolerations with nodeSelector; DaemonSets are the common case where tolerations alone let a per-node agent reach tainted control-plane nodes.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Pod stays Pending; events mention untolerated taint |
Pod lacks toleration for a node taint | Add matching spec.tolerations or remove the taint |
Pod stays Pending; only didn't match node affinity/selector |
nodeSelector or affinity does not match any node |
Fix labels on nodes or relax selector rules |
| Pod runs on a tainted node although its original YAML has no toleration | Pod existed before a NoSchedule taint, used nodeName, or received an automatic/injected toleration |
Inspect the live Pod with kubectl get pod <name> -o yaml; compare its creation time with the taint and check spec.tolerations |
DaemonSet DESIRED less than node count |
Tainted nodes excluded (common on control-plane) | Add toleration on DaemonSet template |
| Pod evicted shortly after node taint added | NoExecute effect |
Add NoExecute toleration or remove taint after maintenance |
kubectl taint reports taint already exists |
Duplicate key/effect on node | Use --overwrite or remove with key- first |
NoSchedule does not evict Pods already running, and DaemonSet Pods automatically receive tolerations for several node-condition taints.
What's Next
- Kubernetes Scheduling, nodeSelector and Affinity
- Kubernetes Horizontal Pod Autoscaler with Examples
- Kubernetes PodDisruptionBudget with Drain Examples
References
- Taints and Tolerations
- Toleration API
- Assigning Pods to Nodes
- kubectl taint
- DefaultTolerationSeconds Admission Controller
Summary
Taints are repulsion rules on nodes; tolerations are matching exceptions on Pods. The scheduler checks both before it places a workload — selectors and nodeSelector do not override an untolerated NoSchedule or NoExecute taint. Kubernetes treats PreferNoSchedule as a soft preference rather than a scheduling prohibition. In the lab you saw a Pod stay Pending on the control-plane node until a toleration matched node-role.kubernetes.io/control-plane:NoSchedule, a custom dedicated=logging:NoSchedule taint reserve a worker for specialised Pods, and NoExecute evict a running Pod through TaintManagerEviction.
Remember the one-line rule: selectors attract, taints repel, tolerations permit. NoSchedule blocks new Pods, PreferNoSchedule nudges the scheduler away unless the cluster is tight, and NoExecute can evict Pods that are already running. For node-level agents that must reach every node including the control plane, carry tolerations on the DaemonSet Pod template — the DaemonSet walkthrough applies the control-plane toleration after you understand the model here.

