So far we have decided what runs and how each pod is composed, but never where. The kube-scheduler has been placing our pods on whichever nodes it fancied and we have not complained, because in a homogeneous test cluster any spot will do.
In production not any spot will do. bookings-postgres needs the node with the SSD, because on a spinning disk the occupancy queries take five times as long. The six replicas of bookings-api should not all be on the same node: if that node reboots, the whole API disappears just as somebody is paying for a ticket. And heavy analytics workloads should not compete for CPU with ticket sales on a bank-holiday weekend.
All of that is expressed with three mechanisms we will study here: affinity (the pod picks a node), anti-affinity (the pod avoids company) and taints with tolerations (the node turns pods away). By the end we will have the complete Rutas Norte scheduling plan and we will know how to diagnose the pod that stays Pending forever.
Contents
- How the
kube-schedulerdecides: filtering and scoring nodeNameandnodeSelector: the basic mechanisms- Node affinity:
requiredversuspreferred - Affinity and anti-affinity between pods:
topologyKey - Taints and tolerations: the node turns pods away
- Kubernetes' automatic taints
PriorityClassand preemptiontopologySpreadConstraints: a mention and a forward reference- The Rutas Norte scheduling plan
- Diagnosing the eternally
Pendingpod
- How the
kube-scheduler decides: filtering and scoring
kube-scheduler decides: filtering and scoringIn lesson 01-02 we said the scheduler "assigns pods to nodes". Now we see how.
When a pod is created, its spec.nodeName field is empty. The scheduler watches those unassigned pods and, for each one, runs a two-phase cycle.
graph LR P[Pod with no nodeName] --> F[Phase 1: FILTERING<br/>which nodes are viable?] F -->|8 nodes → 3 viable| S[Phase 2: SCORING<br/>which is the best?] S -->|node-5: 78 points| B[Binding:<br/>write nodeName] F -->|0 viable| PE[Pod in Pending<br/>+ FailedScheduling event]
Phase 1: filtering
Nodes that cannot host the pod are discarded. Each check is an absolute veto: one is enough to eliminate the node.
| Filter | What it checks |
|---|---|
NodeResourcesFit |
Is there enough free CPU and memory for the pod's requests? |
NodeAffinity |
Does the node satisfy the pod's required affinity? |
NodeName |
If the pod set nodeName, is this that node? |
TaintToleration |
Does the pod tolerate the node's NoSchedule taints? |
NodePorts |
If the pod asks for a hostPort, is it free on the node? |
VolumeBinding |
Can the node reach the pod's volumes (zone, topology)? |
PodTopologySpread |
Does it respect the topology spread constraints? |
InterPodAffinity |
Does it satisfy the required affinity and anti-affinity between pods? |
A detail that connects with module 5: the VolumeBinding filter is what makes volumeBindingMode: WaitForFirstConsumer work. A PVC already bound to a volume in zone eu-west-1a removes every node in the other zones from the filtering.
And another that connects with module 3: NodeResourcesFit looks at requests, not actual usage. A node with 8 CPUs where the pods have reserved 7.8 CPUs but only use 0.5 counts as full. That is why inflated requests waste cluster capacity.
Phase 2: scoring
Among the viable nodes, each scoring plugin awards between 0 and 100 points, and a weighted average is taken.
| Plugin | Rewards |
|---|---|
NodeResourcesBalancedAllocation |
Nodes where CPU and memory usage would end up balanced |
NodeResourcesFit (LeastAllocated) |
Nodes with more free resources: it spreads the load |
ImageLocality |
Nodes that already have the image downloaded: faster start-up |
InterPodAffinity |
Nodes satisfying the preferred affinity between pods |
NodeAffinity |
Nodes satisfying the preferred node affinity, with its weight |
TaintToleration |
Nodes without PreferNoSchedule taints |
The highest score wins; on a tie, one of the tied nodes is picked at random. The scheduler then writes the nodeName and that node's kubelet picks the pod up.
With this the essential difference between the two kinds of rule we will see is already clear: required rules act in the filtering (if they are not met, the pod does not go), and preferred rules act in the scoring (if they are not met, the pod goes anyway, but to a lower-scoring node).
nodeName and nodeSelector: the basic mechanisms
nodeName and nodeSelector: the basic mechanismsnodeName: brute force
It skips the scheduler entirely: that node's kubelet sees a pod with its name on it and starts it, no questions asked.
Never use it in production. If the node does not exist, is full or is down, the pod hangs with no useful event; if the node disappears, there is no rescheduling. Its only legitimate use is one-off debugging: forcing a pod onto a particular node to investigate a local problem.
nodeSelector: the simple filter
The pod is only scheduled on nodes carrying all of those labels with those exact values. It is a conjunction of equalities, nothing more.
Labels are put on nodes just as on any other object:
kubectl label node rutas-norte-m02 disk=ssd
kubectl label node rutas-norte-m03 disk=hdd
kubectl get nodes --show-labels | head -2Kubernetes also adds a number of standard labels that are worth knowing:
| Label | Contents |
|---|---|
kubernetes.io/hostname |
The node's name |
kubernetes.io/os |
linux, windows |
kubernetes.io/arch |
amd64, arm64 |
topology.kubernetes.io/zone |
Availability zone |
topology.kubernetes.io/region |
Region |
node.kubernetes.io/instance-type |
The provider's instance type |
nodeSelector has three limits, and they motivate everything that follows:
- Exact equality only. You cannot say "SSD or NVMe", or "any node that is not an analytics node".
- It is mandatory. If no node matches, the pod stays
Pendingforever. There is no way to express a preference. - It says nothing about other pods. It cannot express "do not put me with my siblings".
- Node affinity:
required versus preferred
required versus preferredNode affinity solves the first two limits: an expressive syntax and the ability to prefer rather than demand.
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: disk
operator: In
values: ["ssd", "nvme"]
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values: ["eu-west-1a"]
- weight: 20
preference:
matchExpressions:
- key: tier
operator: In
values: ["high"]The long names, decoded
They read in two halves:
requiredDuringScheduling/preferredDuringScheduling: whether the rule is mandatory (filtering) or desirable (scoring).IgnoredDuringExecution: what happens if the node's label changes while the pod is already running. The answer is: nothing. The pod stays where it is.
That IgnoredDuringExecution matters more than it looks. If you schedule bookings-postgres on a node with disk=ssd and somebody changes that label to disk=hdd, the pod does not move. Affinity is evaluated at scheduling time and never again. A RequiredDuringExecution was planned that would evict pods once the rule stopped holding, but it was never implemented. For eviction based on node conditions, the mechanism is the NoExecute effect of taints (section 5).
Structure and operators
nodeSelectorTermsis a list with OR semantics: one term being satisfied is enough.matchExpressionsinside a term is a list with AND semantics: all of them must hold.
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
# Term A: a node with an SSD AND at least 16 cores
- matchExpressions:
- key: disk
operator: In
values: ["ssd"]
- key: cores
operator: Gt
values: ["15"]
# ...OR ELSE Term B: any NVMe node
- matchExpressions:
- key: disk
operator: In
values: ["nvme"]Available operators:
| Operator | Meaning | Example |
|---|---|---|
In |
The value is in the list | disk In [ssd, nvme] |
NotIn |
The value is not in the list | tier NotIn [budget] |
Exists |
The label exists, whatever its value | disk Exists |
DoesNotExist |
The label does not exist | node-role.kubernetes.io/analytics DoesNotExist |
Gt |
Greater than (an integer value, in values with a single element) |
cores Gt 15 |
Lt |
Less than | average-load Lt 50 |
NotIn and DoesNotExist give you node anti-affinity: there is no separate object for it, it is expressed by negation.
A detail about Gt and Lt: they compare as integers, the values list must have exactly one element, and the node's label value must parse as an integer. cores Gt "15" means strictly greater than 15, that is, 16 or more.
The weights of preferred
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80 # from 1 to 100
preference:
matchExpressions: [...]Every preferred rule that is satisfied adds its weight to the node's score. With two rules of weight 80 and 20, a node satisfying both scores 100 in this plugin; one satisfying only the first scores 80. The weights are relative to each other: what matters is the ratio, not the absolute value.
A typical and highly recommended combination: required for what is a genuine requirement (CPU architecture, the presence of a local disk) and preferred for what is an optimisation (a particular zone, a machine tier). Putting into required what was really a preference is the leading cause of needless Pending pods.
- Affinity and anti-affinity between pods:
topologyKey
topologyKeyNode affinity looks at node labels. Affinity between pods looks at which other pods are already on that node or in that zone. It is what lets you say "I want to be near the cache" or "I do not want to be with my sisters".
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: bookings-api
environment: pro
topologyKey: kubernetes.io/hostnameRead it as: "do not place me on a node (kubernetes.io/hostname) where there is already a pod with app=bookings-api and environment=pro".
topologyKey: the central piece
topologyKey is a node label that defines the scope in which the rule applies. The scheduler groups nodes by the value of that label, and each group is a "topology domain".
topologyKey |
Domain | Effect of an anti-affinity |
|---|---|---|
kubernetes.io/hostname |
One node | At most one pod from the group per node |
topology.kubernetes.io/zone |
One availability zone | At most one per zone |
topology.kubernetes.io/region |
One region | At most one per region |
A label of your own, e.g. rack |
One physical rack | At most one per rack |
Choosing the topologyKey well is choosing which failure you protect yourself against:
hostnameprotects against a node rebooting or going down.zoneprotects against a whole data centre going down.regionprotects against a regional disaster, at the cost of latency between replicas.
Careful with required and hostname: if you demand at most one replica per node and you have 3 nodes, you will not be able to go beyond 3 replicas. The fourth will stay Pending indefinitely. This mistake often shows up combined with autoscaling that tries to go up to 10.
The usual solution is to use preferred, which spreads but does not block:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: bookings-api
environment: pro
topologyKey: kubernetes.io/hostnameWith weight: 100, the scheduler heavily penalises nodes that already host a replica, so it spreads them out while it can; when there is no alternative left, it places two together instead of leaving the pod Pending. For bookings-api, whose availability matters more than perfect spreading, it is the right choice.
Note the change of syntax between the two variants: in required the list contains the terms directly; in preferred each element is an object with weight and podAffinityTerm.
podAffinity: attracting instead of repelling
The symmetric case: notifications-worker queries redis-cache a lot and benefits from being on the same node, where the traffic does not cross the physical network.
podAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 50
podAffinityTerm:
labelSelector:
matchLabels:
app: redis-cache
environment: pro
topologyKey: kubernetes.io/hostnameUse podAffinity sparingly. Concentrating related pods on the same node improves latency and worsens availability: that node becomes a single point of failure for two components at once. In Rutas Norte we keep it as preferred with a medium weight and do not apply it to anything critical.
The computational cost
This is a practical warning that the official documentation underlines. To evaluate affinity between pods, the scheduler must, for every candidate node, examine the pods of every node in its topology domain. The complexity grows with the product of nodes and pods, not linearly.
On a 10-node cluster it is imperceptible. On one with several hundred, with inter-pod affinity rules on many workloads, the decision time per pod can go from milliseconds to seconds, and in a mass deployment that shows. The documentation expressly advises against these rules on clusters of more than a few hundred nodes.
Mitigations:
- Use
topologyKey: kubernetes.io/hostname(small domains) rather thanzone(large domains). - Narrow the scope with
namespaceSelectorso that pods from other namespaces are not evaluated. - For the specific case of "spread replicas evenly",
topologySpreadConstraintsis more efficient; we look at it in section 8.
- Taints and tolerations: the node turns pods away
Affinity and anti-affinity are mechanisms of the pod: the pod expresses where it wants to go. Taints are the inverse mechanism, belonging to the node: the node declares that it turns pods away unless they are expressly authorised.
The difference matters because it solves a problem affinity cannot: reserving a group of nodes. With affinity, to make sure only analytics workloads go to the analytics nodes, you would have to add a "do not go there" rule to every other pod in the cluster, including those somebody deploys tomorrow. With a taint, the node protects itself.
# Add a taint
kubectl taint nodes rutas-norte-m04 dedicated=analytics:NoSchedule
# Remove it (note the trailing hyphen)
kubectl taint nodes rutas-norte-m04 dedicated=analytics:NoSchedule-
# Inspect
kubectl describe node rutas-norte-m04 | grep -A3 TaintsA taint has the form key=value:effect.
The three effects
| Effect | When scheduling | For pods already on the node |
|---|---|---|
NoSchedule |
Turns away pods that do not tolerate it | Leaves them alone |
PreferNoSchedule |
Avoids them if it can, but accepts them if there is no alternative | Leaves them alone |
NoExecute |
Turns away pods that do not tolerate it | Evicts them |
NoExecute is the only one of the three that acts on running pods. It is the mechanism that does what RequiredDuringExecution would have done in affinity.
The syntax of a toleration
spec:
tolerations:
# Form 1: with operator Equal (the default): exact key, value and effect
- key: dedicated
operator: Equal
value: analytics
effect: NoSchedule
# Form 2: with operator Exists: the key exists, the value does not matter
- key: dedicated
operator: Exists
effect: NoSchedule
# Form 3: tolerate EVERYTHING (use with great care)
- operator: ExistsSyntax rules:
operator: Equal(the default value) requireskey,valueandeffectto match.operator: Existsrequireskeyandeffectto match; it must not carry avalue.- Omitting
effectmeans "any effect for that key". - Omitting
keywithoperator: Existsmeans "all taints". It is what the CNI DaemonSets that must run no matter what use (06-02).
tolerationSeconds
It only makes sense with NoExecute, and it expresses how long the pod stays on the node before being evicted:
"If the node becomes unreachable, stay for 5 minutes in case it comes back; if not, evict me." It is exactly what Kubernetes adds by default to every pod, as we will see now.
The fundamental warning
A toleration does not attract the pod: it merely stops it from being turned away.
A pod with the dedicated=analytics toleration may go to the analytics nodes, but it may also go to any other node in the cluster, and it will probably end up on another one. To reserve nodes properly you need both pieces:
spec:
# 1. The node's taint keeps everyone else out
tolerations:
- key: dedicated
operator: Equal
value: analytics
effect: NoSchedule
# 2. The affinity makes this pod actually go there
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: dedicated
operator: In
values: ["analytics"]Taint + toleration = exclusivity. Taint + toleration + affinity = dedication.
- Kubernetes' automatic taints
Kubernetes applies taints of its own accord when a node is in trouble. Knowing them explains a lot of behaviour that otherwise looks like magic.
| Taint | Effect | When the system applies it |
|---|---|---|
node.kubernetes.io/not-ready |
NoExecute |
The node is not Ready |
node.kubernetes.io/unreachable |
NoExecute |
The node controller lost contact with the kubelet |
node.kubernetes.io/memory-pressure |
NoSchedule |
The node is low on memory |
node.kubernetes.io/disk-pressure |
NoSchedule |
The node is low on disk |
node.kubernetes.io/pid-pressure |
NoSchedule |
The node is low on PIDs |
node.kubernetes.io/network-unavailable |
NoSchedule |
The node's network is not configured |
node.kubernetes.io/unschedulable |
NoSchedule |
Somebody ran kubectl cordon |
node.kubernetes.io/out-of-service |
NoExecute |
An administrator marks it as out of service |
And the best known one, which is not automatic but applied by kubeadm when the cluster is created:
It is the one that forced the log DaemonSet of 06-02 to carry an explicit toleration.
Eviction when a node goes down
Here is the real mechanism behind "if a node goes down, its pods are recreated somewhere else":
- The kubelet stops sending heartbeats.
- After 40 seconds, the node controller marks the node
NotReadyand adds the taintnode.kubernetes.io/unreachable:NoExecuteto it. - The node's pods are not evicted immediately, because Kubernetes automatically added this toleration when it created them:
tolerations:
- key: node.kubernetes.io/not-ready
operator: Exists
effect: NoExecute
tolerationSeconds: 300
- key: node.kubernetes.io/unreachable
operator: Exists
effect: NoExecute
tolerationSeconds: 300- After 300 seconds (5 minutes) the tolerance runs out and the pods are marked for deletion. Their controllers create replacements on other nodes.
That explains the roughly five-minute delay between a node going down and its pods reappearing somewhere else. It can be tuned per pod:
# bookings-api is sensitive to recovery latency: 30 s instead of 300
tolerations:
- key: node.kubernetes.io/unreachable
operator: Exists
effect: NoExecute
tolerationSeconds: 30
- key: node.kubernetes.io/not-ready
operator: Exists
effect: NoExecute
tolerationSeconds: 30Lowering it too far has its risks: a transient 40-second network glitch would cause a mass eviction and a needless recreation. For bookings-postgres the opposite applies: a high value, because recreating the database on another node means unmounting and remounting a volume, and it is better to wait for the node to come back.
A warning: DaemonSet pods do not get these automatic tolerations with tolerationSeconds; instead they tolerate those taints indefinitely, so that the agents keep working on a node in trouble.
PriorityClass and preemption
PriorityClass and preemptionWhen the cluster is full, what happens if an important pod arrives? Without priorities, it stays Pending like any other. With priorities, it can evict less important pods to make room for itself.
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: rutasnorte-critical
value: 100000
globalDefault: false
preemptionPolicy: PreemptLowerPriority
description: "Components without which Rutas Norte cannot sell tickets"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: rutasnorte-normal
value: 10000
globalDefault: true
description: "Default priority for Rutas Norte workloads"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: rutasnorte-batch
value: 100
globalDefault: false
preemptionPolicy: Never
description: "Batch jobs that can wait"Use in a pod:
PriorityClasses are cluster-scoped objects, not namespaced. Kubernetes ships two predefined ones for system components, system-cluster-critical (2000000000) and system-node-critical (2000001000); the latter is the one we used in the log DaemonSet of 06-02.
What priority does
It acts at two moments:
- In the scheduler's queue: pending pods are ordered by descending priority. A critical pod is evaluated before a batch one, even if it has been waiting less time.
- In preemption: if a high-priority pod does not fit on any node, the scheduler looks for a node where, by evicting lower-priority pods, it would fit. If it finds one, it marks those pods for deletion (with their
SIGTERMand their grace period from 02-01) and schedules the important pod in their place.
preemptionPolicy: Never makes a pod benefit from priority in the queue but never evict anyone. It is the right thing for batch jobs: let them be scheduled early if there is room, but let them knock nothing down.
The danger of overusing priorities
It is a mechanism that degrades on its own if used badly:
- Priority inflation. Every team marks its workload as "critical". In the end everything is critical and priority stops discriminating. Define few classes (three are enough) and document what justifies each one.
- Cascading preemption. Pod A evicts B; B's controller recreates it on another node; there it evicts C… A cluster at its limit with badly distributed priorities can spend minutes shuffling pods without settling.
- Evictions do not respect
PodDisruptionBudgetabsolutely. The scheduler tries to respect them, but if it finds no other option, it evicts anyway. PDBs are the subject of 09-05. - Stateful workloads suffer a lot. Evicting
bookings-postgresmeans closing connections, unmounting the volume and mounting it again on another node. Give it a high priority and do not give itpreemptionPolicy: PreemptLowerPriorityexpecting it to reschedule itself neatly: what you want is for nobody to touch it.
Practical rule: use priorities to protect what is important from being evicted, not to make what is important evict aggressively.
topologySpreadConstraints: a mention and a forward reference
topologySpreadConstraints: a mention and a forward referenceThere is a third mechanism, more modern and more efficient than anti-affinity for the specific case of spreading replicas evenly:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: bookings-api
environment: proIn one sentence: "the difference between the zone with the most bookings-api replicas and the one with the fewest must not exceed 1; if that cannot be met, schedule it anyway".
Advantages over podAntiAffinity:
podAntiAffinity |
topologySpreadConstraints |
|
|---|---|---|
| Expresses | "none next to another" | "spread with a maximum skew of N" |
| Granularity | Binary | Numeric, through maxSkew |
| Behaviour if it does not fit | Pending (with required) |
ScheduleAnyway or DoNotSchedule, your choice |
| Computational cost | High on large clusters | Lower |
Its full application to high availability — how to combine it with PodDisruptionBudgets to survive the loss of a zone — is content for lesson 09-05. Here it is enough to know that it exists and that, for the "spread my replicas" case, it is usually a better tool than anti-affinity.
- The Rutas Norte scheduling plan
Let us put it all together into a coherent plan for rutas-norte-pro.
Labelling and tainting the nodes
# SSD nodes for the database
kubectl label node rutas-norte-m02 disk=ssd tier=high
kubectl label node rutas-norte-m03 disk=ssd tier=high
# Standard nodes for the web and the API
kubectl label node rutas-norte-m04 disk=hdd tier=standard
kubectl label node rutas-norte-m05 disk=hdd tier=standard
# Node dedicated to analytics workloads, protected with a taint
kubectl label node rutas-norte-m06 dedicated=analytics
kubectl taint node rutas-norte-m06 dedicated=analytics:NoSchedulegraph TB
subgraph SSD[Nodes disk=ssd]
M2[m02] --> PG[bookings-postgres-0]
M3[m03]
end
subgraph EST[Nodes tier=standard]
M4[m04] --> A1[bookings-api]
M4 --> T1[web-store]
M5[m05] --> A2[bookings-api]
M5 --> T2[web-store]
end
subgraph ANA[Node dedicated=analytics<br/>taint NoSchedule]
M6[m06] --> IO[occupancy-reports]
end
DS[DaemonSet log-collector<br/>tolerates everything] -.-> M2
DS -.-> M4
DS -.-> M6
bookings-postgres: pinned to SSD nodes
# k8s/environments/pro/bookings-postgres-scheduling.yaml (StatefulSet fragment)
spec:
template:
spec:
priorityClassName: rutasnorte-critical
affinity:
nodeAffinity:
# A real requirement: without an SSD the occupancy queries miss their SLA
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: disk
operator: In
values: ["ssd", "nvme"]
# A preference: better in the zone where the rest of the platform lives
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 60
preference:
matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values: ["eu-west-1a"]
# Never two PostgreSQL replicas on the same node
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: bookings-postgres
environment: pro
topologyKey: kubernetes.io/hostname
# Faced with an unreachable node, wait longer than usual before evicting:
# remounting the volume on another node is expensive
tolerations:
- key: node.kubernetes.io/unreachable
operator: Exists
effect: NoExecute
tolerationSeconds: 600Here required in the anti-affinity is the correct choice: two PostgreSQL instances on the same node add nothing and do introduce disk contention. And since the StatefulSet has one replica, there is no risk of blocking.
bookings-api and web-store: spread across nodes
# k8s/environments/pro/bookings-api-scheduling.yaml (Deployment fragment)
spec:
replicas: 6
template:
spec:
priorityClassName: rutasnorte-critical
affinity:
nodeAffinity:
# Away from the analytics nodes and the database ones
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: dedicated
operator: DoesNotExist
- key: tier
operator: In
values: ["standard", "high"]
podAntiAffinity:
# preferred, not required: with 6 replicas and few nodes, required would block
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: bookings-api
environment: pro
topologyKey: kubernetes.io/hostname
- weight: 50
podAffinityTerm:
labelSelector:
matchLabels:
app: bookings-api
environment: pro
topologyKey: topology.kubernetes.io/zone
tolerations:
- key: node.kubernetes.io/unreachable
operator: Exists
effect: NoExecute
tolerationSeconds: 60Two rules with different weights: spreading across nodes matters twice as much as spreading across zones. The result in a typical deployment:
kubectl get pods -n rutas-norte-pro -l app=bookings-api \
-o custom-columns=POD:.metadata.name,NODE:.spec.nodeName --sort-by=.spec.nodeNamePOD NODE
bookings-api-6c8f9d4b7-2m5kx rutas-norte-m02
bookings-api-6c8f9d4b7-jm2xq rutas-norte-m03
bookings-api-6c8f9d4b7-p8t4v rutas-norte-m04
bookings-api-6c8f9d4b7-q1w7z rutas-norte-m04
bookings-api-6c8f9d4b7-r9k3b rutas-norte-m05
bookings-api-6c8f9d4b7-x4n6c rutas-norte-m05Six replicas over four nodes, as spread out as possible. With required the sixth would have stayed Pending. web-store carries an equivalent configuration.
occupancy-reports: onto the analytics node
The CronJob from 06-03 runs heavy aggregate queries. Having it run on the dedicated node stops it from competing with ticket sales:
# k8s/environments/pro/occupancy-reports-scheduling.yaml (jobTemplate fragment)
spec:
priorityClassName: rutasnorte-batch # preemptionPolicy: Never
tolerations:
- key: dedicated
operator: Equal
value: analytics
effect: NoSchedule
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: dedicated
operator: In
values: ["analytics"]The two pieces together, as we explained in section 5: the toleration lets it in where nobody else can, and the affinity forces it to go there. The rutasnorte-batch priority with preemptionPolicy: Never guarantees that a nightly report will never evict anything.
The log DaemonSet
We already wrote it in 06-02, and now it makes complete sense:
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
- key: dedicated
operator: Equal
value: analytics
effect: NoSchedule # on the analytics node tooWithout the second toleration, node m06 would have no collector and the logs of the nightly reports would go nowhere: exactly the logs you would want to consult when a report fails at three in the morning.
Summary of the plan
| Component | Node affinity | Anti-affinity | Tolerations | Priority |
|---|---|---|---|---|
bookings-postgres |
required: disk in [ssd,nvme] |
required by hostname |
unreachable 600 s |
critical |
bookings-api |
required: not analytics |
preferred by hostname and zone |
unreachable 60 s |
critical |
web-store |
required: not analytics |
preferred by hostname |
default | normal |
redis-cache |
none | preferred by hostname |
default | normal |
notifications-worker |
required: not analytics |
preferred by hostname |
default | normal |
occupancy-reports |
required: analytics |
none | dedicated=analytics |
batch (no preemption) |
log-collector (DS) |
none | not applicable | control-plane + analytics | system-node-critical |
- Diagnosing the eternally
Pending pod
Pending podA Pending pod has found no node. The scheduler tells you exactly why, and learning to read that message is the most profitable skill in this lesson.
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 6m12s default-scheduler 0/6 nodes are available:
1 node(s) had untolerated taint {dedicated: analytics},
2 node(s) didn't match Pod's node affinity/selector,
3 Insufficient cpu.
preemption: 0/6 nodes are available:
1 Preemption is not helpful for scheduling,
5 No preemption victims found for incoming pod.How that message is read
The first line gives the count: out of 6 nodes, 0 available. Then the breakdown by reason, and the numbers must add up to 6:
1 node(s) had untolerated taint {dedicated: analytics}→ nodem06turned the pod away because it does not carry the toleration. If the pod wasoccupancy-reports, the toleration is missing.2 node(s) didn't match Pod's node affinity/selector→ two nodes do not satisfy therequiredaffinity.3 Insufficient cpu→ three nodes do not have enough free CPU for therequests.
The preemption: block explains why priority did not help either: No preemption victims found means there are no lower-priority pods that, if evicted, would make room.
Table of reasons and what to do
| Message | Cause | Fix |
|---|---|---|
Insufficient cpu / Insufficient memory |
No free resources for the requests |
Lower the requests, add nodes, scale the cluster (09-03) |
had untolerated taint {key: value} |
The node has a taint the pod does not tolerate | Add the toleration or remove the taint |
didn't match Pod's node affinity/selector |
The required affinity or the nodeSelector do not match |
Check the node labels with kubectl get nodes --show-labels |
didn't match pod anti-affinity rules |
There is already a pod from the group in every domain | Switch to preferred, widen the topologyKey or add nodes |
node(s) had volume node affinity conflict |
The PV is in a different zone from the node | Review the StorageClass's volumeBindingMode (05-04) |
node(s) were unschedulable |
A cordoned node | kubectl uncordon <node> |
node(s) had no available disk |
Disk pressure | Free up space on the node |
exceeded quota (in FailedCreate, not Pending) |
The namespace's ResourceQuota is exhausted | Adjust the quota (03-04) |
Diagnosis routine
# 1. The scheduler's event: 90 % of the time this is all you need
kubectl describe pod <pod> -n <ns> | grep -A15 Events
# 2. How much does the pod ask for?
kubectl get pod <pod> -n <ns> \
-o jsonpath='{.spec.containers[*].resources.requests}{"\n"}'
# 3. How much is free on each node? (look at "Allocated resources")
kubectl describe nodes | grep -A6 "Allocated resources"
# 4. What labels do the nodes carry?
kubectl get nodes --show-labels
# 5. What taints do they carry?
kubectl get nodes -o custom-columns=NODE:.metadata.name,TAINTS:.spec.taints
# 6. Recent namespace events, sorted
kubectl get events -n <ns> --sort-by=.lastTimestamp | tail -20Methodological tip: always start with the scheduler's event. It is the only place where the system tells you, with a count per reason, what ruled each node out. Eyeballing nodes before reading it is a waste of time.
Common Mistakes and Tips
Confusing a toleration with affinity. It is the most widespread misunderstanding on the topic. A toleration only prevents rejection; it takes the pod nowhere. To dedicate nodes you need both things.
required where preferred would have done. Every required rule is an absolute filter that can leave a pod Pending forever. Before writing it, ask yourself: "if this is not met, would I rather the pod did not run?". If the answer is no, it belongs in preferred.
required anti-affinity with topologyKey: hostname and many replicas. It caps the replicas at the number of nodes. Combined with an HPA trying to go up to 15 during a bank-holiday peak, it produces Pending pods exactly when they are needed most.
Expecting a label change to move pods. IgnoredDuringExecution means exactly what it says. To move a pod you have to delete it, or use a NoExecute taint.
Using nodeName in production manifests. It skips the scheduler and with it all the resilience. If the node goes down, the pod is not rescheduled.
Setting operator: Exists with a value. The API rejects it. With Exists no value is specified.
Forgetting the hyphen when removing a taint. kubectl taint node m06 dedicated=analytics:NoSchedule adds it; with the trailing hyphen it removes it. Without the hyphen you end up with duplicate taints.
PriorityClass inflation. If everything is critical, nothing is. Three well-defined and documented classes are enough for almost any platform.
Preemption over stateful workloads. Evicting bookings-postgres means unmounting and remounting a volume on another node. Protect it with a high priority, but do not count on preemption rescheduling it cleanly.
Tip: kubectl get pods -o wide --sort-by=.spec.nodeName is the command for checking at a glance that the spread is what you expected.
Tip: test a rule before applying it in production. Apply the manifest in rutas-norte-dev, scale to the figure you would use in production and check that no replica ends up Pending. A badly calibrated required anti-affinity only shows up when the number of replicas exceeds the number of nodes.
Tip: document why each rule exists. A podAntiAffinity with no comment is indistinguishable from a copy-and-paste, and nobody will dare touch it a year from now. One line of comment with the reason saves a great deal.
Exercises
Exercise 1: required and preferred node affinity
On your minikube (profile rutas-norte, several nodes), label one node with disk=ssd and another with disk=hdd. In rutas-norte-dev create a Deployment db-demo with 2 replicas of nginx:1.27.2-alpine that:
- Requires (
required) running on nodes withdiskin[ssd, nvme]. - Prefers (
preferred, weight 70) nodes withtier=high.
Check where they are placed. Then scale to 4 replicas and see what happens.
Exercise 2: required anti-affinity and its limit
Create a Deployment api-demo with 2 replicas with required anti-affinity by kubernetes.io/hostname over its own app label. Verify that the two replicas land on different nodes. Scale to a number larger than the available nodes and diagnose the Pending pod by reading the scheduler's event. Fix the problem by switching to preferred.
Exercise 3: a dedicated node with a taint, a toleration and affinity
Pick a node, label it dedicated=analytics and give it the taint dedicated=analytics:NoSchedule. Then:
- Check that a normal pod is not placed there.
- Create a Job
analytics-demothat does run there, with a toleration and affinity. - Show that the toleration alone is not enough: create a pod with the toleration but no affinity and see where it ends up.
- Clean up the taint and the labels.
Solutions
Solution 1
kubectl get nodes
kubectl label node rutas-norte-m02 disk=ssd tier=high --overwrite
kubectl label node rutas-norte-m03 disk=hdd tier=standard --overwrite# /tmp/db-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: db-demo
namespace: rutas-norte-dev
labels:
app: db-demo
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
replicas: 2
selector:
matchLabels:
app: db-demo
environment: dev
template:
metadata:
labels:
app: db-demo
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
automountServiceAccountToken: false
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: disk
operator: In
values: ["ssd", "nvme"]
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 70
preference:
matchExpressions:
- key: tier
operator: In
values: ["high"]
containers:
- name: nginx
image: nginx:1.27.2-alpine
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128MiNAME READY STATUS RESTARTS AGE NODE
db-demo-7f9c4d8b6-h2m4x 1/1 Running 0 18s rutas-norte-m02
db-demo-7f9c4d8b6-t5k7p 1/1 Running 0 18s rutas-norte-m02Both replicas go to m02, the only node with disk=ssd. The fact that it also has tier=high gives it 70 extra points, but that was irrelevant: the filtering had already left a single candidate.
kubectl scale deployment db-demo -n rutas-norte-dev --replicas=4
kubectl get pods -n rutas-norte-dev -l app=db-demo -o wideNAME READY STATUS RESTARTS AGE NODE
db-demo-7f9c4d8b6-h2m4x 1/1 Running 0 2m rutas-norte-m02
db-demo-7f9c4d8b6-t5k7p 1/1 Running 0 2m rutas-norte-m02
db-demo-7f9c4d8b6-v8n3q 1/1 Running 0 12s rutas-norte-m02
db-demo-7f9c4d8b6-w1x6z 1/1 Running 0 12s rutas-norte-m02All four on the same node. Node affinity says where a pod may go, but says nothing about spreading. For that you need anti-affinity, which is exercise 2. If m02 reboots, the whole of db-demo goes down.
Solution 2
# /tmp/api-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-demo
namespace: rutas-norte-dev
labels:
app: api-demo
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
replicas: 2
selector:
matchLabels:
app: api-demo
environment: dev
template:
metadata:
labels:
app: api-demo
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
automountServiceAccountToken: false
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: api-demo
environment: dev
topologyKey: kubernetes.io/hostname
containers:
- name: nginx
image: nginx:1.27.2-alpine
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128MiNAME READY STATUS RESTARTS AGE NODE
api-demo-8b5d7c9f4-k3m2x 1/1 Running 0 15s rutas-norte-m02
api-demo-8b5d7c9f4-r7t9p 1/1 Running 0 15s rutas-norte-m03One per node, as requested.
kubectl scale deployment api-demo -n rutas-norte-dev --replicas=4
kubectl get pods -n rutas-norte-dev -l app=api-demo -o wideNAME READY STATUS RESTARTS AGE NODE
api-demo-8b5d7c9f4-k3m2x 1/1 Running 0 2m rutas-norte-m02
api-demo-8b5d7c9f4-r7t9p 1/1 Running 0 2m rutas-norte-m03
api-demo-8b5d7c9f4-w4n8q 0/1 Pending 0 20s <none>
api-demo-8b5d7c9f4-z2v5c 0/1 Pending 0 20s <none>kubectl describe pod -n rutas-norte-dev \
$(kubectl get pod -n rutas-norte-dev -l app=api-demo \
--field-selector=status.phase=Pending -o jsonpath='{.items[0].metadata.name}') | tail -5Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 25s default-scheduler 0/3 nodes are available:
1 node(s) had untolerated taint {node-role.kubernetes.io/control-plane: },
2 node(s) didn't match pod anti-affinity rules.The message is explicit: two nodes already have a replica and the required rule forbids a second one; the third is the control plane, with its taint. With required anti-affinity by hostname, the maximum number of replicas is the number of eligible nodes.
The fix:
kubectl patch deployment api-demo -n rutas-norte-dev --type=json -p='[
{"op": "remove", "path": "/spec/template/spec/affinity/podAntiAffinity/requiredDuringSchedulingIgnoredDuringExecution"},
{"op": "add", "path": "/spec/template/spec/affinity/podAntiAffinity/preferredDuringSchedulingIgnoredDuringExecution",
"value": [{"weight": 100, "podAffinityTerm": {
"labelSelector": {"matchLabels": {"app": "api-demo", "environment": "dev"}},
"topologyKey": "kubernetes.io/hostname"}}]}
]'
kubectl rollout status deployment/api-demo -n rutas-norte-dev
kubectl get pods -n rutas-norte-dev -l app=api-demo -o wideNAME READY STATUS RESTARTS AGE NODE
api-demo-6d4f8a7c2-b1n5k 1/1 Running 0 40s rutas-norte-m02
api-demo-6d4f8a7c2-h9m3t 1/1 Running 0 38s rutas-norte-m03
api-demo-6d4f8a7c2-p6x2w 1/1 Running 0 35s rutas-norte-m02
api-demo-6d4f8a7c2-y8k4r 1/1 Running 0 33s rutas-norte-m03Four replicas spread two and two: the preference spread them as best it could and, once the alternatives ran out, allowed doubling up instead of blocking.
Solution 3
kubectl label node rutas-norte-m03 dedicated=analytics --overwrite
kubectl taint node rutas-norte-m03 dedicated=analytics:NoSchedule
kubectl get nodes -o custom-columns=NODE:.metadata.name,TAINTS:.spec.taintsNODE TAINTS
rutas-norte [map[effect:NoSchedule key:node-role.kubernetes.io/control-plane]]
rutas-norte-m02 <none>
rutas-norte-m03 [map[effect:NoSchedule key:dedicated value:analytics]]1. A normal pod does not go there:
kubectl run normal-demo -n rutas-norte-dev --image=nginx:1.27.2-alpine --restart=Never
kubectl wait --for=condition=ready pod/normal-demo -n rutas-norte-dev --timeout=60s
kubectl get pod normal-demo -n rutas-norte-dev -o wideIt ended up on m02, the only one with no taint to turn it away.
2. A Job with a toleration and affinity:
# /tmp/analytics-demo.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: analytics-demo
namespace: rutas-norte-dev
labels:
app: occupancy-reports
environment: dev
spec:
backoffLimit: 1
ttlSecondsAfterFinished: 3600
template:
metadata:
labels:
app: occupancy-reports
environment: dev
spec:
restartPolicy: Never
automountServiceAccountToken: false
tolerations:
- key: dedicated
operator: Equal
value: analytics
effect: NoSchedule
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: dedicated
operator: In
values: ["analytics"]
containers:
- name: calculation
image: busybox:1.36
command: ["sh", "-c", "echo 'calculating occupancy on the dedicated node'; sleep 10"]
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mikubectl apply -f /tmp/analytics-demo.yaml
kubectl get pods -n rutas-norte-dev -l job-name=analytics-demo -o wide3. The toleration alone is not enough:
# /tmp/toleration-only.yaml
apiVersion: v1
kind: Pod
metadata:
name: toleration-only
namespace: rutas-norte-dev
labels:
app: toleration-only
environment: dev
spec:
automountServiceAccountToken: false
tolerations:
- key: dedicated
operator: Equal
value: analytics
effect: NoSchedule
containers:
- name: nginx
image: nginx:1.27.2-alpine
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mikubectl apply -f /tmp/toleration-only.yaml
kubectl wait --for=condition=ready pod/toleration-only -n rutas-norte-dev --timeout=60s
kubectl get pod toleration-only -n rutas-norte-dev -o wideIt could have gone to m03 and it did not: the toleration permits but does not attract. The scheduler picked m02 on its score. This confirms the warning from section 5: to dedicate nodes you need taint + toleration + affinity.
4. Clean-up:
kubectl taint node rutas-norte-m03 dedicated=analytics:NoSchedule-
kubectl label node rutas-norte-m03 dedicated-
kubectl label node rutas-norte-m02 disk- tier- --overwrite
kubectl label node rutas-norte-m03 disk- tier- --overwrite
kubectl delete -f /tmp/analytics-demo.yaml -f /tmp/toleration-only.yaml \
-f /tmp/api-demo.yaml -f /tmp/db-demo.yaml
kubectl delete pod normal-demo -n rutas-norte-devConclusion
The kube-scheduler decides in two phases: it filters out the unviable nodes and scores the ones that remain. That structure explains the whole lesson: required rules act in the filtering and can leave a pod Pending; preferred rules act in the scoring and only influence the choice.
We have worked through the mechanisms from least to most expressive: nodeName (brute force, for debugging only), nodeSelector (simple equality), node affinity with its nodeSelectorTerms in OR, its matchExpressions in AND and its In, NotIn, Exists, DoesNotExist, Gt and Lt operators. The IgnoredDuringExecution in their long names means that a change to a node's labels will never move an already-scheduled pod.
Affinity and anti-affinity between pods look at who else is in the domain defined by topologyKey: node, zone or region, depending on which failure you want to protect yourself against. Their computational cost is real on large clusters, and for the specific case of spreading replicas it is worth looking at topologySpreadConstraints, whose use for high availability is content for 09-05.
Taints invert the relationship: it is the node that turns pods away, with three effects (NoSchedule, PreferNoSchedule and NoExecute, the only one that evicts running pods). Kubernetes uses them of its own accord when a node fails, and the automatic tolerations with tolerationSeconds: 300 explain the five-minute delay before the pods of a downed node reappear elsewhere. The rule to burn in: a toleration permits, it does not attract; to dedicate nodes you need a taint, a toleration and affinity.
PriorityClasses order the queue and enable preemption, with the risk of priority inflation and cascading evictions. And the FailedScheduling event, with its count of nodes ruled out for each reason, is the first and almost always the only stop in diagnosing a Pending pod.
With that, the Rutas Norte scheduling plan is complete: bookings-postgres on the SSD nodes, bookings-api and web-store spread across nodes, an analytics node reserved for occupancy-reports and a log DaemonSet that reaches everywhere.
Up to here we have used the objects Kubernetes ships with: Pods, Deployments, StatefulSets, Services, PVCs, Jobs. But in module 4 we installed cert-manager and started creating objects of type Certificate and Issuer, and in module 5 we created VolumeSnapshot. None of those types is part of Kubernetes: somebody added them to the API, and the cluster treats them exactly like the native ones — kubectl get, describe, explain, validation, RBAC. How that is done, and how we would define our own RutaProgramada type for Rutas Norte, is the subject of the next lesson: Custom Resource Definitions.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
