QoS Classes Explained: How Kubernetes Decides Which Pod Gets Sacrificed

Every Kubernetes tutorial teaches you to write requests and limits. Almost none of them explain what those numbers actually buy you. They are not just scheduling hints for the kube-scheduler to balance nodes. Once your pod lands on a node under memory pressure, those numbers decide, with arithmetic precision, whether your process keeps running or gets killed. Kubernetes distills them into a Quality of Service (QoS) class — Guaranteed, Burstable, or BestEffort — and that class drives two independent kill paths: the kernel’s OOM killer and the kubelet’s eviction logic.

This post walks through how the class is computed, what it actually controls at the cgroup and kernel level, and where the sharp edges are. If you have ever lost a pod at 3 a.m. and wondered why that pod specifically, the answer is usually here.

How the Class Is Computed

The three classes follow from two simple rules, evaluated per pod:

  • Guaranteed: every container in the pod sets both a memory limit and a memory request that are identical, and both a CPU limit and a CPU request that are identical.
  • BestEffort: no container sets any requests or limits at all.
  • Burstable: everything in between — at least one container sets something, but the pod doesn’t satisfy the Guaranteed criteria.

You can check what a running pod got with a single jsonpath:

kubectl get pod my-pod -o jsonpath='{.status.qosClass}'

Here is the canonical Guaranteed shape — note that requests and limits are the same numbers:

apiVersion: v1
kind: Pod
metadata:
  name: guaranteed-pod
spec:
  containers:
  - name: app
    image: registry.example.com/app:1.4.2
    resources:
      requests:
        cpu: "1"
        memory: 1Gi
      limits:
        cpu: "1"
        memory: 1Gi

One subtlety that trips people up: CPU is compressible and memory is not. If you set a memory limit but leave the CPU request out, your pod is Burstable, not Guaranteed — the criteria are strict and apply to both resources, for every container, including sidecars.

Kill Path 1: The Kernel OOM Killer

When a node runs completely out of memory before the kubelet notices, the kernel invokes the OOM killer. The kernel scores each process by its share of memory and adds a tunable adjustment, oom_score_adj, and kills the process with the highest effective score. The kubelet sets that adjustment for every container it starts, purely as a function of QoS class:

  • Guaranteed: -997 (effectively immune unless it exceeds its own limits)
  • BestEffort: 1000 (killed first, always)
  • Burstable: a value between 2 and 999, computed as min(max(2, 1000 - (1000 × memoryRequestBytes) / machineMemoryCapacityBytes), 999) — the bigger your memory request relative to the node, the closer you get to protection

The Burstable formula is the part most people miss. A Burstable pod with a large memory request on a small node gets a low adjustment and survives; the same pod with a tiny request on a huge node gets pushed toward 999 and dies alongside the BestEffort pods. Containers in pods marked system-node-critical also get -997 regardless of class, which is how the cluster protects its own plumbing.

There is a second, quieter version of this path: if a single container exceeds its own memory limit, the kernel’s cgroup memory controller (via memory.max) kills just that container with an OOM event, without the node ever being under pressure. The pod restarts, the node stays healthy, and your logs show OOMKilled in the container’s last state. This is the most common memory failure in practice, and it happens regardless of QoS class — the limit itself is the enforcement.

Kill Path 2: Kubelet Node-Pressure Eviction

Most of the time, the kernel never fires. The kubelet watches eviction signals — notably memory.available, computed from capacity minus working-set usage — against configured thresholds. Hard thresholds (the default includes keeping at least 100 MiB of memory reclaimable) trigger immediate eviction; soft thresholds trigger it after a grace period.

When eviction starts, the kubelet ranks victim candidates like this:

  • BestEffort pods first, then Burstable pods that are using more than their requests
  • Pods using at or below their requests are not candidates for resource-pressure eviction
  • Guaranteed pods are only evicted if they exceed their own limits, or if nothing softer exists to kill

Notice what this means: your request is a protection floor, your limit is an execution ceiling. A Burstable pod whose actual usage stays under its request is as safe as a Guaranteed pod during pressure — the eviction logic only cares about overage. Meanwhile the limit decides where the cgroup hard-stops you. Sizing requests too low doesn’t just hurt scheduling; it moves you to the front of the eviction queue the moment you burst.

CPU Limits: The Throttling Trap

Memory limits are enforced by killing. CPU limits are enforced by waiting — the kernel’s CFS bandwidth controller caps how much CPU time the container may consume per period, and anything beyond the quota is throttled: the process simply doesn’t get scheduled until the next period. No crash, no log line, just latency.

This is why latency-sensitive services often ship without CPU limits. If your garbage collector needs to run, or a request fan-out briefly wants eight threads on a two-core request, a CPU limit of “2” forces it to wait, and your p99 absorbs the delay. The schedulable quantity is determined by the request anyway — limits only cap the burst. For batch and background workloads where throughput beats tail latency, CPU limits protect neighbors and are perfectly reasonable. Decide per workload, not by copy-pasting the same resource block everywhere.

Newer Knobs: Pod-Level Resources and MemoryQoS

Since Kubernetes v1.34, pod-level resources are in beta and enabled by default: you can set resources directly under spec and let all containers share one budget, which fits sidecar-heavy pods where per-container bookkeeping wastes headroom. Kubernetes v1.37 (“Garhwal”) extends this with pod-level resource management — NUMA-aligned CPU and memory pools for a whole pod — though that part stays disabled by default. Separately, the kubelet’s MemoryQoS behavior maps requests and limits onto cgroup primitives like memory.min and memory.high, giving the kernel earlier soft warnings before the hard kill. These features change the mechanics at the margin; the QoS class computation and both kill paths remain exactly as described above.

Practical Guidance

  • Always set memory requests and limits, based on working-set measurements rather than guesses. Memory is the resource that kills.
  • Set CPU requests accurately; treat CPU limits as optional and workload-dependent. Omit them for latency-sensitive services, keep them for batch.
  • Use LimitRange objects in shared namespaces to inject defaults, so an engineer who forgets the resource block doesn’t silently deploy a BestEffort pod.
  • If you genuinely need a pod that never yields CPU, Guaranteed status plus the static CPU manager policy pins whole cores to your containers.
  • Watch for OOMKilled restarts and CFS throttling metrics separately — they are different problems with different fixes.

The resource block in your manifest is a contract with the node about who dies first. Read the full details in the Kubernetes documentation on pod QoS classes and node-pressure eviction, then go check what class your production pods actually got — the answer is occasionally surprising.

Leave a Reply

Your email address will not be published. Required fields are marked *