← Kubernetes← Kubernetes
KubernetesKubernetes19 Th7, 2026Jul 19, 202616 phút đọc14 min read

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: nodeSelectornode 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):

  1. 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ại Pending với event FailedScheduling.
  2. 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ủa NodeAffinity, 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:

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:

Đ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 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
NoSchedulePod mới không có toleration không được schedule ở đây (Pod hiện có ở lại)
PreferNoScheduleBản mềm — scheduler cố tránh, nhưng có thể đặt nếu cần
NoExecuteKhô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:NoExecuteunreachable: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-criticalsystem-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:

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

  1. Đặ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ò.
  2. Hiểu rằng requests schedule và limits cưỡng chế — chẩn đoán Pending là “requests quá cao / node quá đầy” và OOMKilled là “memory limit quá thấp hoặc heap cấu hình sai”; lẫn lộn chúng phí hàng giờ.
  3. Ư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ự.
  4. 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.
  5. Ưu tiên affinity preferred (mềm) trừ khi thật sự cần required — ràng buộc cứng để Pod Pending khi không thỏa được; ưu tiên mềm suy giảm nhẹ nhàng. Dành requiredDuringScheduling cho phụ thuộc cứng thực sự (GPU, licensing, data locality).
  6. 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.
  7. Dành taint NoExecute cho ý định evictionNoExecute evict 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à đặt tolerationSeconds trên Pod có thể tạm tolerate một node not-ready.
  8. 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 globalDefault cẩn thận, và nhớ add-on system-critical đã có class riêng.
  9. Cấp cho DaemonSet quan trọng priority system-node-critical và 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.
  10. 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.
  11. 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.
  12. 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.
  13. 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.
  14. 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ế.
  15. Đừ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ó.
  16. 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-typekubernetes.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

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):

  1. 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 stays Pending with a FailedScheduling event.
  2. 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), NodeAffinity preferred 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:

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:

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:

EffectMeaning
NoScheduleNew Pods without the toleration are not scheduled here (existing Pods stay)
PreferNoScheduleSoft version — the scheduler tries to avoid, but may place if needed
NoExecuteNot 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:

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

  1. 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.
  2. Understand that requests schedule and limits enforce — diagnose Pending as “requests too high / node too full” and OOMKilled as “memory limit too low or heap misconfigured”; conflating them wastes hours.
  3. 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.
  4. Always spread replicas across failure domains — a service with all replicas on one node or zone dies with it; use topology.kubernetes.io/zone spread (plus per-node) so a single failure never takes the whole service down.
  5. Use preferred (soft) affinity unless you truly need required — hard constraints leave Pods Pending when unsatisfiable; soft preferences degrade gracefully. Reserve requiredDuringScheduling for genuine hard dependencies (GPU, licensing, data locality).
  6. 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.
  7. Reserve NoExecute taints for eviction intentNoExecute evicts running Pods, not just blocks new ones; use it deliberately (node draining, dedicating a live node) and set tolerationSeconds on Pods that may briefly tolerate a not-ready node.
  8. Use PriorityClasses sparingly and deliberately — too many priority tiers make preemption unpredictable; define a small set (critical, normal, batch), set globalDefault carefully, and remember system-critical add-ons already have their own classes.
  9. Give critical DaemonSets system-node-critical priority 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.
  10. 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.
  11. 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.
  12. 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.
  13. 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.
  14. Watch for IgnoredDuringExecution surprises — 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.
  15. 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.
  16. Label nodes consistently with well-known keys — use topology.kubernetes.io/zone, kubernetes.io/hostname, node.kubernetes.io/instance-type, and kubernetes.io/arch so affinity, spread, and cost tooling all work off the same, portable taxonomy.

References