Scheduling and Resource ManagementScheduling and Resource Management
Thuộc bộ tài liệu kiến thức theo Kubernetes Roadmap.
Tổng quan
Khi bạn tạo một Pod, nó không chạy ngay — nó sinh ra ở trạng thái Pending chưa được gán node. kube-scheduler theo dõi những Pod chưa được schedule này và quyết định, cho từng cái, nó nên đáp xuống node nào. Quyết định đơn lẻ đó có hệ quả dây chuyền khổng lồ: nó quyết định Pod có được CPU và memory nó cần không, replica có sống sót khi mất một zone không, workload nhạy latency có ngồi cạnh hàng xóm ồn ào không, và cluster đóng gói hiệu quả hay lãng phí công suất. Scheduling là nơi ý định khai báo gặp thực tế vật lý.
Scheduler không phải một hộp đen bạn buộc phải chấp nhận. Kubernetes cho bạn một bộ từ vựng phong phú để lái vị trí đặt: nodeSelector và node affinity để hút Pod tới loại node, pod affinity/anti-affinity để đặt cùng chỗ hoặc tách các Pod, taint và toleration để dành riêng node, topology spread constraint để phân bổ qua các failure domain, và priority với preemption để quyết định ai thắng khi công suất khan hiếm. Đồng thời, requests điều khiển scheduling (cái scheduler đặt trước) và limits điều khiển cưỡng chế runtime (throttle và OOM-kill), còn node-pressure eviction quyết định ai bị gỡ khi node quá tải.
Trang này giải thích scheduler thực sự làm việc thế nào (filtering rồi scoring), từng cơ chế đặt vị trí kèm YAML hoàn chỉnh, cách requests/limits/QoS gắn với eviction và hành vi OOM, cách DaemonSet đi vòng qua luồng thông thường, và cách descheduler sửa drift theo thời gian. Mục tiêu là chuyển từ “scheduler đặt Pod của tôi đâu đó” sang “tôi bảo scheduler chính xác workload của tôi cần gì”.
Một mô hình tư duy
Scheduler trả lời một câu hỏi cho mỗi Pod: “Trong tất cả node, cái nào khả thi, và trong số đó cái nào tốt nhất?” Khả thi là yes/no cứng (filtering); tốt nhất là điểm số xếp hạng (scoring). Mọi thứ bạn cấu hình đều hoặc là ràng buộc cứng loại bỏ node không khả thi (requiredDuringScheduling…, taint không có toleration, requests không đủ) hoặc là ưu tiên mềm đẩy điểm số (preferredDuringScheduling…, trọng số spread). Một khi thấm “cứng = filter, mềm = score”, mọi núm affinity và constraint đều rơi vào đúng chỗ.
Kiến thức nền tảng
kube-scheduler: filtering và scoring
Cho mỗi Pod Pending, scheduler chạy một chu trình hai pha trên các node, hiện thực như một pipeline các plugin (scheduling framework):
- Filtering (predicate) — loại bỏ node không thể chạy Pod. Một node bị lọc ra nếu nó thiếu đủ CPU/memory allocatable cho requests của Pod, có taint mà Pod không tolerate, không qua
nodeSelector/required node affinity, không còn port trống Pod cần, không attach được volume yêu cầu (lệch zone), v.v. Những cái sống sót là node khả thi. Nếu không cái nào sống, Pod ở lạiPendingvới eventFailedScheduling. - Scoring (priority) — xếp hạng node khả thi 0–100 bằng các plugin có trọng số:
NodeResourcesFit(bin-packing vs spreading),InterPodAffinity,PodTopologySpread,TaintToleration,ImageLocality(ưu tiên node đã cache image), các term preferred củaNodeAffinity, và nhiều nữa. Trọng số được cộng lại; node điểm cao nhất thắng (hòa thì chọn ngẫu nhiên).
Rồi binding ghi node đã chọn vào spec.nodeName của Pod, và kubelet trên node đó tiếp quản. Điều then chốt, scheduler chỉ quyết định vị trí; kubelet mới chạy và cưỡng chế. Scheduling dựa trên requests, không bao giờ dựa trên usage thực — một node thực tế dùng 90% nhưng requests chưa đặt trước vẫn trông trống rỗng với scheduler.
Requests điều khiển scheduling, limits điều khiển cưỡng chế
Sự phân chia này là xương sống của resource management:
- Requests là một phần đặt trước. Scheduler cộng requests của mọi Pod trên node và chỉ đặt Pod mới nếu requests của nó vẫn vừa trong công suất allocatable. Requests không bao giờ đổi theo usage thực. Đặt quá thấp thì overcommit node (eviction, OOM); quá cao thì lãng phí công suất và không schedule được.
- Limits là trần runtime do kubelet/kernel cưỡng chế qua cgroup. CPU vượt limit → throttle (container chậm lại, không bị kill — CPU nén được). Memory vượt limit → OOM-kill và restart (memory không nén được). Limits không tham gia vào scheduling.
Một node do đó có thể được schedule đến 100% requests trong khi thực tế dùng ít hơn nhiều, hoặc bị overcommit (tổng limits > công suất) và mạo hiểm OOM khi tải cao. Hiểu núm nào làm gì ngăn được cả những Pod Pending bí ẩn (requests quá cao) lẫn những restart OOMKilled bí ẩn (limit quá thấp hoặc heap cấu hình sai).
Node-pressure eviction và OOM
Hai cơ chế khác nhau gỡ workload dưới áp lực memory, và người ta hay lẫn lộn chúng:
- Container OOM-kill (kernel): một container đơn lẻ vượt memory limit của nó → kernel OOM-killer chấm dứt container đó → kubelet restart nó (CrashLoopBackoff nếu lặp lại). Đây là per-container và do limits điều khiển.
- Node-pressure eviction (kubelet): node vượt một ngưỡng eviction (thấp
memory.available,nodefs,imagefs) → kubelet chủ động evict cả Pod để thu hồi tài nguyên, xếp hạng nạn nhân theo QoS class rồi theo usage vượt requests. BestEffort đi trước, rồi Burstable vượt-request nhiều nhất, Guaranteed cuối cùng. Pod bị evict bị xóa và schedule lại nơi khác.
Điểm rút ra: requests + QoS quyết định ai sống sót áp lực node; limits quyết định ai bị OOM-kill riêng lẻ. Pod Guaranteed (requests == limits cho CPU và memory) được bảo vệ nhất.
Khái niệm chính
nodeSelector — cách lái đơn giản nhất
Một yêu cầu cứng rằng node mang label cụ thể. Pod bị lọc chỉ lên node khớp:
spec:
nodeSelector:
disktype: ssd
kubernetes.io/arch: arm64
Đơn giản và hiệu quả, nhưng tất-cả-hoặc-không (không có ưu tiên mềm, không logic OR). Node affinity là hậu duệ diễn đạt tốt hơn của nó.
Node affinity và anti-affinity
Node affinity hút Pod tới node theo label, có cả biến thể cứng và mềm cùng các toán tử phong phú (In, NotIn, Exists, Gt, Lt):
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution: # CỨNG — filter
nodeSelectorTerms:
- matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values: ["us-east-1a", "us-east-1b"]
preferredDuringSchedulingIgnoredDuringExecution: # MỀM — score
- weight: 80
preference:
matchExpressions:
- key: node.kubernetes.io/instance-type
operator: In
values: ["m6i.large"]
Lưu ý IgnoredDuringExecution: affinity chỉ được đánh giá lúc scheduling. Nếu về sau label của node đổi khiến Pod không còn khớp, Pod đang chạy không bị evict (chưa có RequiredDuringExecution). “Node anti-affinity” được diễn đạt bằng toán tử NotIn/DoesNotExist — không có trường anti-affinity riêng cho node.
Pod affinity và anti-affinity
Chúng schedule Pod tương đối với các Pod khác, dùng một topologyKey định nghĩa miền co-location (cùng node, cùng zone…):
affinity:
podAffinity: # hút: chạy gần các Pod có label này
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels: { app: cache }
topologyKey: kubernetes.io/hostname # cùng node với cache
podAntiAffinity: # đẩy: tách các replica ra
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels: { app: web }
topologyKey: topology.kubernetes.io/zone # lý tưởng một web Pod mỗi zone
Pod affinity đặt cùng chỗ (tầng web cạnh cache của nó để giảm latency). Pod anti-affinity tách ra (spread replica qua node/zone để một lỗi không hạ tất cả). topologyKey là mấu chốt — kubernetes.io/hostname nghĩa là “mỗi node”, topology.kubernetes.io/zone nghĩa là “mỗi zone”. Lưu ý: required pod affinity/anti-affinity tốn kém tính toán (scheduler so với mọi Pod) và có thể làm chậm scheduling đáng kể trong cluster lớn — ưu tiên topology spread constraint cho trường hợp phổ biến “spread replica của tôi”.
Taint và toleration
Taint và toleration là nghịch đảo của affinity: thay vì Pod chọn node, một node đẩy Pod trừ khi chúng tolerate taint một cách tường minh. Dùng để dành riêng node cho workload cụ thể.
# Taint một node: đẩy mọi thứ không có toleration khớp
kubectl taint nodes gpu-node-1 dedicated=gpu:NoSchedule
# Một Pod tolerate nó (và do đó có thể được schedule ở đó):
tolerations:
- key: "dedicated"
operator: "Equal"
value: "gpu"
effect: "NoSchedule"
Ba effect:
| Effect | Ý nghĩa |
|---|---|
NoSchedule | Pod mới không có toleration không được schedule ở đây (Pod hiện có ở lại) |
PreferNoSchedule | Bản mềm — scheduler cố tránh, nhưng có thể đặt nếu cần |
NoExecute | Không chỉ chặn scheduling mà còn evict Pod đang chạy không tolerate (kèm tolerationSeconds ân hạn tùy chọn) |
Kubernetes dùng taint nội bộ: node control-plane mang node-role.kubernetes.io/control-plane:NoSchedule; node controller thêm node.kubernetes.io/not-ready:NoExecute và unreachable:NoExecute khi lỗi, đó là cách Pod bị evict khỏi node chết. Toleration chỉ cho phép schedule; nó không hút — ghép taint/toleration với node affinity nếu bạn cũng muốn ép Pod lên các node đó (nếu không, Pod tolerate có thể đáp lên bất kỳ node không taint nào).
Topology spread constraint
Cách hiện đại, được ưu tiên để phân bổ Pod đều qua các failure domain — sạch và rẻ hơn pod anti-affinity cho use case “spread replica của tôi”:
topologySpreadConstraints:
- maxSkew: 1 # mất cân bằng tối đa giữa các domain
topologyKey: topology.kubernetes.io/zone # failure domain
whenUnsatisfiable: DoNotSchedule # hoặc ScheduleAnyway (mềm)
labelSelector:
matchLabels: { app: web }
- maxSkew: 1
topologyKey: kubernetes.io/hostname # cũng spread theo node
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: web }
maxSkew là chênh lệch số Pod tối đa được phép giữa hai topology domain bất kỳ. whenUnsatisfiable: DoNotSchedule biến nó thành ràng buộc cứng (Pod ở lại Pending thay vì vi phạm skew); ScheduleAnyway biến nó thành ưu tiên scoring mềm. Kết hợp một constraint cấp zone và một cấp node để spread qua cả hai. Có thể đặt default cấp cluster trong scheduler config để mọi Pod spread mà không cần YAML per-workload.
Pod priority và preemption
Một PriorityClass gán một số nguyên độ ưu tiên cho Pod. Khi một Pod ưu tiên cao đang Pending và không node nào vừa, scheduler có thể preempt — evict các Pod ưu tiên thấp hơn để lấy chỗ:
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: { name: high-priority }
value: 1000000
globalDefault: false
preemptionPolicy: PreemptLowerPriority # hoặc Never (priority chỉ để xếp thứ tự)
description: "Critical user-facing services"
---
# Trong Pod spec:
spec:
priorityClassName: high-priority
value cao hơn = quan trọng hơn. Khi công suất eo hẹp, một Pod ưu tiên cao đang Pending trigger preemption: scheduler tìm node mà việc evict các Pod ưu tiên thấp sẽ cho nó vừa, xóa các nạn nhân đó (tôn trọng graceful termination của chúng và, khi có thể, PodDisruptionBudget), và schedule cái quan trọng. preemptionPolicy: Never cho một Pod nhảy hàng đợi scheduling mà không evict ai. Kubernetes có sẵn hai class cho add-on quan trọng: system-cluster-critical và system-node-critical. Cạm bẫy: priority tương tác với QoS lúc eviction — dưới áp lực node kubelet xét priority cùng QoS, nên một Pod BestEffort ưu tiên cao ít được bảo vệ hơn bạn tưởng; hãy đặt cả requests.
DaemonSet scheduling
Một DaemonSet chạy một Pod mỗi node (khớp) — log shipper, CNI agent, node exporter, CSI driver. Trước đây DaemonSet controller đặt Pod trực tiếp; Kubernetes hiện đại schedule Pod của DaemonSet qua default scheduler (NodeAffinity mà controller inject ghim mỗi Pod vào node đích của nó). Điểm khác với scheduling thông thường:
- Pod DaemonSet tôn trọng nodeSelector/affinity để nhắm một tập con node (ví dụ chỉ node GPU).
- Chúng phải tolerate taint của node để đáp lên node bị taint — đó là lý do các agent node-critical mang toleration rộng (bao gồm
NoExecute) để chạy trên cả node control-plane và node not-ready. - Chúng thường đặt
priorityClassName: system-node-criticalđể không bị preempt hay evict, vì node phụ thuộc vào chúng.
Descheduler
Scheduler ra quyết định tốt lúc đặt vị trí, nhưng cluster trôi dạt: node được thêm, Pod chết và schedule lại, utilization lệch, các rule affinity không thể tôn trọng lúc 3 giờ sáng giờ lại được. Scheduler không bao giờ xét lại một Pod đang chạy. Descheduler (một component cài riêng) định kỳ evict các Pod vi phạm chính sách để chúng được schedule lại lên node tốt hơn. Chiến lược phổ biến: LowNodeUtilization (cân bằng lại khỏi node nóng), RemoveDuplicates (spread replica cùng một set khỏi một node), RemovePodsViolatingInterPodAntiAffinity, RemovePodsViolatingTopologySpreadConstraint, và RemovePodsViolatingNodeTaints. Nó chỉ evict; scheduler sau đó đặt lại. Tôn trọng PodDisruptionBudget và ghép nó với cluster autoscaling để cân bằng thực sự.
Ví dụ hoàn chỉnh: affinity + taint/toleration + topology spread
Một Deployment web production: giữ nó khỏi node spot/GPU, spread qua zone và node, và ưu tiên ngồi gần cache của nó.
apiVersion: apps/v1
kind: Deployment
metadata: { name: web }
spec:
replicas: 6
selector: { matchLabels: { app: web } }
template:
metadata: { labels: { app: web } }
spec:
priorityClassName: high-priority
tolerations:
- key: "workload" # được phép lên node taint workload=web
operator: "Equal"
value: "web"
effect: "NoSchedule"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-type # chỉ node general-purpose
operator: In
values: ["general"]
podAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 50
podAffinityTerm:
labelSelector: { matchLabels: { app: cache } }
topologyKey: kubernetes.io/hostname # ưu tiên cùng node với cache
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule # cứng: cân bằng qua các zone
labelSelector: { matchLabels: { app: web } }
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway # mềm: cũng spread theo node
labelSelector: { matchLabels: { app: web } }
containers:
- name: web
image: ghcr.io/example/web:1.4.2
resources:
requests: { cpu: "250m", memory: "256Mi" } # điều khiển scheduling
limits: { cpu: "1", memory: "512Mi" } # điều khiển throttle/OOM
Đọc từ trên xuống: priorityClassName bảo vệ nó khi tranh chấp; toleration cho nó lên node dành riêng; node affinity giới hạn nó vào node general-purpose; pod affinity đẩy nó về phía node cache; hai spread constraint cân bằng nó qua zone (cứng) và node (mềm); và requests/limits đặt footprint scheduling và QoS của nó (Burstable ở đây).
Best Practices
- Đặt resource requests trên mọi Pod, dựa trên usage thực — requests là tín hiệu duy nhất scheduler dùng để đặt vị trí; thiếu chúng thì scheduling mù, packing gây eviction, và Pod rơi xuống BestEffort. Right-size bằng khuyến nghị VPA hoặc metrics, không đoán mò.
- Hiểu rằng requests schedule và limits cưỡng chế — chẩn đoán
Pendinglà “requests quá cao / node quá đầy” vàOOMKilledlà “memory limit quá thấp hoặc heap cấu hình sai”; lẫn lộn chúng phí hàng giờ. - Ưu tiên topology spread constraint hơn pod anti-affinity để spread — spread constraint rẻ hơn cho scheduler và diễn đạt trực tiếp “cân bằng replica của tôi qua zone/node”; dành required pod anti-affinity cho nhu cầu tách-cứng thật sự.
- Luôn spread replica qua các failure domain — một service có mọi replica trên một node hay zone sẽ chết cùng nó; dùng spread
topology.kubernetes.io/zone(cộng thêm theo node) để một lỗi đơn không bao giờ hạ cả service. - Ưu tiên affinity
preferred(mềm) trừ khi thật sự cầnrequired— ràng buộc cứng để PodPendingkhi không thỏa được; ưu tiên mềm suy giảm nhẹ nhàng. DànhrequiredDuringSchedulingcho phụ thuộc cứng thực sự (GPU, licensing, data locality). - Ghép taint với node affinity khi dành riêng node — taint chỉ đẩy kẻ khác; toleration chỉ cho phép, không hút. Để thật sự ghim workload lên node dành riêng, taint node VÀ thêm node affinity khớp, nếu không Pod tolerate có thể đáp nơi khác.
- Dành taint
NoExecutecho ý định eviction —NoExecuteevict Pod đang chạy, không chỉ chặn Pod mới; dùng nó có chủ đích (drain node, dành riêng node đang chạy) và đặttolerationSecondstrên Pod có thể tạm tolerate một node not-ready. - Dùng PriorityClass tiết chế và có chủ đích — quá nhiều bậc priority làm preemption khó lường; định nghĩa một tập nhỏ (critical, normal, batch), đặt
globalDefaultcẩn thận, và nhớ add-on system-critical đã có class riêng. - Cấp cho DaemonSet quan trọng priority
system-node-criticalvà toleration rộng — agent node (CNI, CSI, logging) phải chạy trên mọi node kể cả node taint và not-ready, và không bao giờ được preempt, nếu không chính node suy thoái. - Làm workload nhạy latency thành QoS Guaranteed — đặt requests == limits cho CPU và memory cho sự bảo vệ eviction mạnh nhất (evict cuối) và, với static CPU manager, core độc quyền; dùng cho workload bạn không thể để mất dưới áp lực.
- Tránh overcommit memory limit vô hạn — cộng limit vượt xa công suất node mời gọi OOM-kill khi tải; giữ memory requests thực tế và limit gần chúng cho workload quan trọng, vì memory không nén được.
- Kết hợp PodDisruptionBudget với priority, preemption và descheduler — một PDB ngăn preemption, drain node và descheduler hạ quá nhiều replica cùng lúc; không có nó, các disruption tự nguyện có thể đưa service về zero.
- Chạy descheduler để sửa drift — scheduler không bao giờ xét lại Pod đã đặt; một descheduler cân bằng lại khỏi node nóng và tôn trọng lại affinity/spread không thỏa được trước đó, đặc biệt sau các sự kiện autoscaling.
- Coi chừng bất ngờ
IgnoredDuringExecution— affinity và nodeSelector chỉ kiểm tra lúc scheduling; đổi label node không di chuyển Pod đang chạy, nên đừng dựa vào đổi label để schedule lại — cordon/drain hoặc dùng descheduler thay thế. - Đừng dựa vào default scheduler cho các trường hợp đặc biệt có thể diễn đạt khai báo — dùng affinity, taint, spread và priority để mã hóa ý định thay vì gán node thủ công (
nodeName), vốn đi vòng hoàn toàn qua scheduler và các kiểm tra an toàn của nó. - Gắn label node nhất quán bằng các key well-known — dùng
topology.kubernetes.io/zone,kubernetes.io/hostname,node.kubernetes.io/instance-typevàkubernetes.io/archđể affinity, spread và tooling chi phí đều làm việc trên cùng một taxonomy portable.
Tài liệu tham khảo
- Kubernetes Scheduler
- Scheduling Framework
- Assigning Pods to Nodes (nodeSelector & affinity)
- Taints and Tolerations
- Pod Topology Spread Constraints
- Pod Priority and Preemption
- Node-pressure Eviction
- Pod Quality of Service Classes
- DaemonSet
- Pod Disruption Budgets
- Descheduler for Kubernetes
- roadmap.sh — Kubernetes
Part of the Kubernetes Roadmap knowledge base.
Overview
When you create a Pod, it does not run immediately — it is born in the Pending state with no node assigned. The kube-scheduler watches for these unscheduled Pods and decides, for each one, which node it should land on. That single decision has enormous downstream consequences: it determines whether the Pod gets the CPU and memory it needs, whether replicas survive a zone failure, whether latency-sensitive workloads sit next to noisy neighbors, and whether the cluster packs efficiently or wastes capacity. Scheduling is where declarative intent meets physical reality.
The scheduler is not a black box you must accept. Kubernetes gives you a rich vocabulary to steer placement: nodeSelector and node affinity to attract Pods to node types, pod affinity/anti-affinity to co-locate or separate Pods, taints and tolerations to reserve nodes, topology spread constraints to distribute across failure domains, and priority and preemption to decide who wins when capacity is scarce. Meanwhile, requests drive scheduling (what the scheduler reserves) and limits drive runtime enforcement (throttling and OOM-kills), and node-pressure eviction decides who gets removed when a node runs hot.
This page explains how the scheduler actually works (filtering then scoring), each placement mechanism with worked YAML, how requests/limits/QoS tie into eviction and OOM behavior, how DaemonSets bypass the normal flow, and how the descheduler fixes drift over time. The goal is to move from “the scheduler put my Pod somewhere” to “I told the scheduler exactly what my workload needs.”
A mental model
The scheduler answers one question per Pod: “Given every node, which are feasible, and among those, which is best?” Feasibility is a hard yes/no (filtering); best is a ranked score (scoring). Everything you configure is either a hard constraint that removes infeasible nodes (requiredDuringScheduling…, taints without tolerations, insufficient requests) or a soft preference that nudges the score (preferredDuringScheduling…, spread weights). Once you internalize “hard = filter, soft = score,” every affinity and constraint knob falls into place.
Fundamentals
The kube-scheduler: filtering and scoring
For each Pending Pod, the scheduler runs a two-phase cycle over the nodes, implemented as a pipeline of plugins (the scheduling framework):
- Filtering (predicates) — eliminate nodes that cannot run the Pod. A node is filtered out if it lacks enough allocatable CPU/memory for the Pod’s requests, has a taint the Pod doesn’t tolerate, fails a
nodeSelector/required node affinity, has no free ports the Pod needs, can’t attach the required volume (zone mismatch), etc. The survivors are feasible nodes. If none survive, the Pod staysPendingwith aFailedSchedulingevent. - Scoring (priorities) — rank the feasible nodes 0–100 by weighted plugins:
NodeResourcesFit(bin-packing vs. spreading),InterPodAffinity,PodTopologySpread,TaintToleration,ImageLocality(prefer nodes that already cached the image),NodeAffinitypreferred terms, and more. Weights are summed; the highest-scoring node wins (ties broken randomly).
Then binding writes the chosen node into the Pod’s spec.nodeName, and the kubelet on that node takes over. Crucially, the scheduler only decides placement; the kubelet runs and enforces. Scheduling is based on requests, never on live usage — a node 90% actually utilized but with unreserved requests still looks empty to the scheduler.
Requests drive scheduling, limits drive enforcement
This division is the backbone of resource management:
- Requests are a reservation. The scheduler sums the requests of all Pods on a node and only places a new Pod if its requests still fit in allocatable capacity. Requests never change based on real usage. Set them too low and you overcommit the node (evictions, OOM); too high and you waste capacity and can’t schedule.
- Limits are a runtime ceiling enforced by the kubelet/kernel via cgroups. CPU over limit → throttled (the container is slowed, not killed — CPU is compressible). Memory over limit → OOM-killed and restarted (memory is incompressible). Limits play no part in scheduling.
A node can therefore be scheduled to 100% of requests while actually using far less, or be overcommitted (sum of limits > capacity) and risk OOM under load. Understanding which knob does what prevents both mystery Pending Pods (requests too high) and mystery OOMKilled restarts (limit too low or heap misconfigured).
Node-pressure eviction and OOM
Two different mechanisms remove workloads under memory pressure, and people conflate them:
- Container OOM-kill (kernel): a single container exceeds its memory limit → the kernel OOM-killer terminates that container → the kubelet restarts it (with CrashLoopBackoff if it repeats). This is per-container and driven by limits.
- Node-pressure eviction (kubelet): the node crosses an eviction threshold (low
memory.available,nodefs,imagefs) → the kubelet proactively evicts whole Pods to reclaim resources, ranking victims by QoS class then by usage over requests. BestEffort go first, then Burstable most-over-request, Guaranteed last. Evicted Pods are deleted and rescheduled elsewhere.
The takeaway: requests + QoS determine who survives node pressure; limits determine who gets OOM-killed individually. Guaranteed Pods (requests == limits for CPU and memory) are the most protected.
Key Concepts
nodeSelector — the simplest steering
A hard requirement that a node carry specific labels. The Pod is filtered onto matching nodes only:
spec:
nodeSelector:
disktype: ssd
kubernetes.io/arch: arm64
Simple and effective, but all-or-nothing (no soft preference, no OR logic). Node affinity is its more expressive successor.
Node affinity and anti-affinity
Node affinity attracts Pods to nodes by label, with hard and soft variants and richer operators (In, NotIn, Exists, Gt, Lt):
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution: # HARD — filter
nodeSelectorTerms:
- matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values: ["us-east-1a", "us-east-1b"]
preferredDuringSchedulingIgnoredDuringExecution: # SOFT — score
- weight: 80
preference:
matchExpressions:
- key: node.kubernetes.io/instance-type
operator: In
values: ["m6i.large"]
Note IgnoredDuringExecution: affinity is evaluated only at scheduling time. If a node’s labels later change so the Pod no longer matches, the running Pod is not evicted (there is no RequiredDuringExecution yet). “Node anti-affinity” is expressed with NotIn/DoesNotExist operators — there is no separate anti-affinity field for nodes.
Pod affinity and anti-affinity
These schedule Pods relative to other Pods, using a topologyKey that defines the domain of co-location (same node, same zone, …):
affinity:
podAffinity: # attract: run near Pods with these labels
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels: { app: cache }
topologyKey: kubernetes.io/hostname # same node as the cache
podAntiAffinity: # repel: spread replicas apart
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels: { app: web }
topologyKey: topology.kubernetes.io/zone # one web Pod per zone, ideally
Pod affinity co-locates (a web tier next to its cache to cut latency). Pod anti-affinity separates (spread replicas across nodes/zones so one failure doesn’t take them all). The topologyKey is the crux — kubernetes.io/hostname means “per node,” topology.kubernetes.io/zone means “per zone.” Caveat: required pod affinity/anti-affinity is computationally expensive (the scheduler compares against all Pods) and can significantly slow scheduling in large clusters — prefer topology spread constraints for the common “spread my replicas” case.
Taints and tolerations
Taints and tolerations are the inverse of affinity: instead of a Pod choosing nodes, a node repels Pods unless they explicitly tolerate the taint. Used to reserve nodes for specific workloads.
# Taint a node: repel everything without a matching toleration
kubectl taint nodes gpu-node-1 dedicated=gpu:NoSchedule
# A Pod that tolerates it (and thus may be scheduled there):
tolerations:
- key: "dedicated"
operator: "Equal"
value: "gpu"
effect: "NoSchedule"
The three effects:
| Effect | Meaning |
|---|---|
NoSchedule | New Pods without the toleration are not scheduled here (existing Pods stay) |
PreferNoSchedule | Soft version — the scheduler tries to avoid, but may place if needed |
NoExecute | Not only blocks scheduling but evicts already-running Pods that don’t tolerate it (with optional tolerationSeconds grace) |
Kubernetes uses taints internally: control-plane nodes carry node-role.kubernetes.io/control-plane:NoSchedule; the node controller adds node.kubernetes.io/not-ready:NoExecute and unreachable:NoExecute on failure, which is how Pods get evicted from a dead node. A toleration only permits scheduling; it does not attract — pair taints/tolerations with node affinity if you also want to force the Pod onto those nodes (otherwise a tolerating Pod may land on any untainted node).
Topology spread constraints
The modern, preferred way to distribute Pods evenly across failure domains — cleaner and cheaper than pod anti-affinity for the “spread my replicas” use case:
topologySpreadConstraints:
- maxSkew: 1 # max imbalance between domains
topologyKey: topology.kubernetes.io/zone # the failure domain
whenUnsatisfiable: DoNotSchedule # or ScheduleAnyway (soft)
labelSelector:
matchLabels: { app: web }
- maxSkew: 1
topologyKey: kubernetes.io/hostname # also spread per node
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: web }
maxSkew is the maximum allowed difference in Pod count between any two topology domains. whenUnsatisfiable: DoNotSchedule makes it a hard constraint (Pod stays Pending rather than violate the skew); ScheduleAnyway makes it a soft scoring preference. Combine a zone-level and a node-level constraint to spread across both. Cluster-level defaults can be set in the scheduler config so every Pod spreads without per-workload YAML.
Pod priority and preemption
A PriorityClass assigns an integer priority to Pods. When a high-priority Pod is Pending and no node fits, the scheduler may preempt — evict lower-priority Pods to make room:
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: { name: high-priority }
value: 1000000
globalDefault: false
preemptionPolicy: PreemptLowerPriority # or Never (priority for ordering only)
description: "Critical user-facing services"
---
# In the Pod spec:
spec:
priorityClassName: high-priority
Higher value = more important. When capacity is tight, a Pending high-priority Pod triggers preemption: the scheduler finds a node where evicting lower-priority Pods would let it fit, deletes those victims (respecting their graceful termination and, where possible, PodDisruptionBudgets), and schedules the important one. preemptionPolicy: Never lets a Pod jump the scheduling queue without evicting anyone. Kubernetes ships two built-in classes for critical add-ons: system-cluster-critical and system-node-critical. Pitfall: priority interacts with QoS at eviction time — under node pressure the kubelet considers priority alongside QoS, so a high-priority BestEffort Pod is less protected than you might assume; set requests too.
DaemonSet scheduling
A DaemonSet runs one Pod per (matching) node — log shippers, CNI agents, node exporters, CSI drivers. Historically the DaemonSet controller placed Pods directly; modern Kubernetes schedules DaemonSet Pods through the default scheduler (the NodeAffinity the controller injects pins each Pod to its target node). Key differences from normal scheduling:
- DaemonSet Pods respect nodeSelector/affinity to target a subset of nodes (e.g., only GPU nodes).
- They must tolerate node taints to land on tainted nodes — that’s why node-critical agents carry broad tolerations (including
NoExecute) so they run on control-plane and not-ready nodes too. - They typically set
priorityClassName: system-node-criticalso they aren’t preempted or evicted, since the node depends on them.
The descheduler
The scheduler makes a good decision at placement time, but clusters drift: nodes are added, Pods die and reschedule, utilization skews, affinity rules that couldn’t be honored at 3 a.m. now can. The scheduler never revisits a running Pod. The descheduler (a separately installed component) periodically evicts Pods that violate policies so they get rescheduled onto better nodes. Common strategies: LowNodeUtilization (rebalance off hot nodes), RemoveDuplicates (spread replicas of the same set off one node), RemovePodsViolatingInterPodAntiAffinity, RemovePodsViolatingTopologySpreadConstraint, and RemovePodsViolatingNodeTaints. It only evicts; the scheduler then re-places. Respect PodDisruptionBudgets and pair it with cluster autoscaling for real rebalancing.
Worked example: affinity + taints/tolerations + topology spread
A production web Deployment: keep it off spot/GPU nodes, spread across zones and nodes, and prefer to sit near its cache.
apiVersion: apps/v1
kind: Deployment
metadata: { name: web }
spec:
replicas: 6
selector: { matchLabels: { app: web } }
template:
metadata: { labels: { app: web } }
spec:
priorityClassName: high-priority
tolerations:
- key: "workload" # allowed onto nodes tainted workload=web
operator: "Equal"
value: "web"
effect: "NoSchedule"
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-type # only general-purpose nodes
operator: In
values: ["general"]
podAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 50
podAffinityTerm:
labelSelector: { matchLabels: { app: cache } }
topologyKey: kubernetes.io/hostname # prefer same node as cache
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule # hard: balance across zones
labelSelector: { matchLabels: { app: web } }
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway # soft: also spread per node
labelSelector: { matchLabels: { app: web } }
containers:
- name: web
image: ghcr.io/example/web:1.4.2
resources:
requests: { cpu: "250m", memory: "256Mi" } # drives scheduling
limits: { cpu: "1", memory: "512Mi" } # drives throttle/OOM
Reading it top to bottom: priorityClassName protects it under contention; the toleration lets it onto reserved nodes; node affinity restricts it to general-purpose nodes; pod affinity nudges it toward cache nodes; the two spread constraints balance it across zones (hard) and nodes (soft); and requests/limits set its scheduling footprint and QoS (Burstable here).
Best Practices
- Set resource requests on every Pod, based on real usage — requests are the only signal the scheduler uses for placement; without them scheduling is blind, packing causes evictions, and the Pod drops to BestEffort. Right-size with VPA recommendations or metrics, not guesses.
- Understand that requests schedule and limits enforce — diagnose
Pendingas “requests too high / node too full” andOOMKilledas “memory limit too low or heap misconfigured”; conflating them wastes hours. - Prefer topology spread constraints over pod anti-affinity for spreading — spread constraints are cheaper for the scheduler and express “balance my replicas across zones/nodes” directly; reserve required pod anti-affinity for true hard-separation needs.
- Always spread replicas across failure domains — a service with all replicas on one node or zone dies with it; use
topology.kubernetes.io/zonespread (plus per-node) so a single failure never takes the whole service down. - Use
preferred(soft) affinity unless you truly needrequired— hard constraints leave PodsPendingwhen unsatisfiable; soft preferences degrade gracefully. ReserverequiredDuringSchedulingfor genuine hard dependencies (GPU, licensing, data locality). - Pair taints with node affinity when dedicating nodes — a taint only repels others; a toleration only permits, it doesn’t attract. To truly pin a workload onto reserved nodes, taint the nodes AND add matching node affinity, or the tolerating Pod may land elsewhere.
- Reserve
NoExecutetaints for eviction intent —NoExecuteevicts running Pods, not just blocks new ones; use it deliberately (node draining, dedicating a live node) and settolerationSecondson Pods that may briefly tolerate a not-ready node. - Use PriorityClasses sparingly and deliberately — too many priority tiers make preemption unpredictable; define a small set (critical, normal, batch), set
globalDefaultcarefully, and remember system-critical add-ons already have their own classes. - Give critical DaemonSets
system-node-criticalpriority and broad tolerations — node agents (CNI, CSI, logging) must run on every node including tainted and not-ready ones, and must never be preempted, or the node itself degrades. - Make latency-critical workloads Guaranteed QoS — setting requests == limits for CPU and memory gives the strongest eviction protection (evicted last) and, with the static CPU manager, exclusive cores; use it for the workloads you cannot afford to lose under pressure.
- Avoid unbounded overcommit of memory limits — summing limits far above node capacity invites OOM-kills under load; keep memory requests realistic and limits close to them for important workloads, since memory is incompressible.
- Combine PodDisruptionBudgets with priority, preemption, and the descheduler — a PDB stops preemption, node drains, and the descheduler from taking too many replicas down at once; without it, voluntary disruptions can drop a service to zero.
- Run a descheduler to correct drift — the scheduler never revisits placed Pods; a descheduler rebalances off hot nodes and re-honors affinity/spread that couldn’t be satisfied earlier, especially after autoscaling events.
- Watch for
IgnoredDuringExecutionsurprises — affinity and nodeSelector are only checked at scheduling time; relabeling a node does not move already-running Pods, so don’t rely on label changes to reschedule — cordon/drain or use the descheduler instead. - Don’t rely on the default scheduler for special cases you can express declaratively — use affinity, taints, spread, and priority to encode intent rather than manual node assignment (
nodeName), which bypasses the scheduler and its safety checks entirely. - Label nodes consistently with well-known keys — use
topology.kubernetes.io/zone,kubernetes.io/hostname,node.kubernetes.io/instance-type, andkubernetes.io/archso affinity, spread, and cost tooling all work off the same, portable taxonomy.
References
- Kubernetes Scheduler
- Scheduling Framework
- Assigning Pods to Nodes (nodeSelector & affinity)
- Taints and Tolerations
- Pod Topology Spread Constraints
- Pod Priority and Preemption
- Node-pressure Eviction
- Pod Quality of Service Classes
- DaemonSet
- Pod Disruption Budgets
- Descheduler for Kubernetes
- roadmap.sh — Kubernetes