DeepConcepts

Kubernetes / resource management / eviction / scheduling

QoS Classes: Guaranteed, Burstable, BestEffort

The misconception

That QoS is a setting, and that Guaranteed is a promise the kubelet keeps. Neither is true. You cannot set qosClass — it is derived by ComputePodQOS from the numbers you already wrote, so a sidecar injected without resources silently downgrades a carefully-Guaranteed pod to Burstable. And the kubelet's eviction ranking never looks at the class at all: rankMemoryPressure sorts on whether usage exceeds requests, then Pod Priority, then usage above requests. The familiar ordering BestEffort, Burstable, Guaranteed is a consequence of those three comparisons, not a rule, and it breaks whenever Priority disagrees — a Guaranteed pod at priority 0 is evicted before a Burstable pod at priority 1000.

13 min

There is no field called qosClass in a pod spec. You cannot set it, and the API server will not let you try. The class is computed from the requests and limits you already wrote, by a function called ComputePodQOS, and it is written into status.qosClass after the fact. That is the first surprise. The second is that the kubelet's eviction ranking does not read it.

The Kubernetes documentation states both halves and they read like a contradiction. The QoS page says pods are evicted "BestEffort, Burstable, Guaranteed", in that order. The node-pressure eviction page says "the kubelet does not use the pod's QoS class to determine the eviction order" and that you can use it "to estimate the most likely pod eviction order". Both are correct. The ordering is a consequence of three comparisons that never mention the class, and the interesting question is when the consequence fails to hold.

Below is one node with 6 GiB of allocatable memory and six pods on it. The ranking is the kubelet's real one, ported from rankMemoryPressure: sort by whether the pod's memory usage exceeds its memory request, then by Pod Priority, then by how far usage sits above the request. The class shown beside each pod is computed from its numbers by the same rules the API server uses. Start by dragging system daemons — memory in use past its 512 MiB reservation until the node crosses its eviction threshold, and watch which pod goes.

the rest of the node

A 6 GiB node. kube-reserved plus system-reserved set aside 512 MiB, but a reservation is a promise the scheduler keeps and not a limit the kernel enforces, so the daemons can and do go past it — that is the slider. memory.available is 6144 minus what the daemons are actually using minus what the pods are using. The default hard threshold is memory.available<100Mi. When it is crossed the kubelet ranks the pods and evicts one, then re-measures and repeats. Both the class computation and the ranking are ports of the Kubernetes source; the pod roster and the usage numbers are the illustration.

your pod's place in the queue
your pod's computed class
memory.available
pods evicted
your usage vs request
oom_score_adj
How your pod's class is computed

resourceQOS runs once per container per resource, and only CPU and memory count. Any single Burstable verdict makes the whole pod Burstable, whatever the other rows say.

The eviction queue, in the kubelet's order

Ranked by orderedBy(exceedMemoryRequests, priority, memory). evicted · over its request, so first in line if pressure continues · under its request. The class column is displayed, not consulted.

Drag the daemons to 1400 MiB. memory.available falls to 34 MiB, the kubelet evicts log-shipper, and the summary ordering holds: the BestEffort pod went first. Now go to 2000 MiB. The second eviction is ingress-nginx, which is Burstable at priority 1000 — and it goes ahead of batch-import, which is Burstable at priority −100. A pod with a thousand times the priority was chosen first, because ingress-nginx is 524 MiB over its request and batch-import is 448 MiB under its own. The first comparator decided and Priority was never consulted.

Then the part that breaks the rule outright. Set the daemons to 2600 and your pod's priority to −1000, leaving everything else alone. Your pod is Guaranteed, using 900 MiB of its 1024 MiB request, doing nothing wrong at all. It is the third pod evicted, and batch-import — Burstable, using 1600 MiB — survives.

The class is a function, and you can run it in your head

ComputePodQOS is three small functions stacked, and the whole thing is worth memorising because it explains every surprising class you will ever see.

  • resourceQOS(request, limit) runs per resource. Request and limit both zero is BestEffort. Request not equal to limit is Burstable. Equal and non-zero is Guaranteed.
  • requirementsQOS runs that for CPU and memory only — the set is literally supportedQoSComputeResources = {cpu, memory} — and returns Burstable if either resource is Burstable or if the two disagree.
  • ComputePodQOS runs that for every init container and every container, and returns Burstable the moment any container is Burstable or any two containers disagree.

Four consequences fall out, and each one catches people.

Guaranteed needs four numbers, not two. Set requests.memory and limits.memory to 1024 and drag limits.cpu to 1000 while the CPU request stays at 500. The derivation panel shows memory Guaranteed, CPU Burstable, container Burstable. Memory being perfectly pinned buys nothing. This is the most common way a pod that was meant to be Guaranteed is not, and kubectl describe reports only the answer, never the reason.

An injected sidecar decides your class. Tick the mesh box. Your api container is untouched and still Guaranteed on its own, and the pod is Burstable, because istio-proxy arrived with no resources and is therefore BestEffort. A pod whose containers disagree is Burstable by definition. Nothing in your manifest changed and nothing in your CI would catch it. If a mesh, a log sidecar or a secrets agent is injected by a mutating webhook in your cluster, its resource block is part of your QoS class.

Omitting requests is not the same as omitting everything. Kubernetes defaults a missing request to the limit when only a limit is given, so limits alone with no requests generally arrives at Guaranteed. But a LimitRange in the namespace with a defaultRequest lower than its default limit produces Burstable pods from manifests that specify nothing at all — the class is decided by an object the author of the deployment may never have seen.

The class is frozen at admission. It is computed once, stored in status.qosClass, and GetPodQOS returns the stored value rather than recomputing. An in-place resize that would change the class is rejected by admission, so you cannot promote a running Burstable pod to Guaranteed by raising its requests. You replace the pod.

Check what you actually got, on everything, in one command:

kubectl get pods -A -o custom-columns=\
NS:.metadata.namespace,POD:.metadata.name,QOS:.status.qosClass \
  | grep -v Guaranteed

What the ranking actually compares

rankMemoryPressure in pkg/kubelet/eviction/helpers.go is one line:

orderedBy(exceedMemoryRequests(stats), priority, memory(stats)).Sort(pods)

Three comparators, applied in order, first difference wins. The word QOS does not appear in that file's ranking code at all.

  • exceedMemoryRequests — a boolean per pod: is its working set larger than the sum of its containers' memory requests? Pods where this is true sort ahead of pods where it is false. Nothing else about the amount matters at this stage.
  • priority — the integer from the pod's priorityClassName, defaulting to 0. Lower goes first.
  • memory — usage minus request. The larger overage goes first. This runs only when the first two tied.

Now the summary ordering becomes derivable rather than memorised. A BestEffort pod has a memory request of exactly zero, so any usage at all is usage above its request, so it is always in the first group. A Guaranteed pod's usage cannot exceed its request without exceeding its identical limit, at which point it is OOMKilled instead — so a Guaranteed pod is always in the second group. Burstable pods land in whichever group their current usage puts them in, which is why the class is an estimate and not a rule.

That is the whole reconciliation, and the part people miss is the middle comparator. With the daemons at 2600 and your pod's priority at −1000, your Guaranteed pod is evicted while metrics-agent (Burstable, priority 500) and batch-import (Burstable, priority −100) both survive. All three are under their requests, so the first comparator tied three ways and Priority decided all of it. Guaranteed pods are evicted last only among pods of equal Priority.

The reverse also holds and is more useful: a Burstable pod that stays under its request is exactly as safe as a Guaranteed one, at equal Priority. Requesting what you actually use is the whole of the protection. Pinning limits to requests on top of that buys you a class name, exclusive CPUs if you also want the static CPU manager, and the risk of throttling against a CPU limit you did not need.

Kubernetes' own tracker has been arguing about this for five years. Issue 22531 on the website repository, from 2020, is titled "Docs are not clear if QoS classes impact resource utilization priority or only scheduling/eviction". Pull request 27754, from 2021, is "Clarify use of QoS in eviction ranking". Issue 129759, opened in 2025 and still open with twenty-nine comments, is "Documentation of pod selection for node-pressure eviction is confusing regarding QoS". If you have found this confusing, the documentation is the reason and not you.

Eviction is not the OOM killer, and the difference is where you look

Two mechanisms can end a pod for using memory. They share almost nothing.

  • The kernel OOM killer runs when a container's own cgroup reaches memory.max. It kills processes, the container exits 137, kubectl describe says Reason: OOMKilled, and the kubelet restarts it in place with a back-off. The node's memory is irrelevant; it can be almost entirely free.
  • The kubelet's eviction manager runs when the node's memory.available falls below a threshold. It terminates whole pods, sets the phase to Failed with Reason: Evicted, and a replacement is created by the controller, usually on a different node. Your own limit is irrelevant; you can be well inside it.

The QoS class touches both, differently and weakly. For the OOM killer it contributes oom_score_adj: −997 for Guaranteed, 1000 for BestEffort, and 1000 − (1000 × memoryRequest) / nodeCapacity for Burstable. For eviction it contributes nothing directly, and only shapes the outcome through the requests it was itself derived from.

The signal that starts the whole thing is worth one paragraph of scepticism. memory.available is derived from cgroupfs, and the kubelet subtracts inactive_file from usage but not active_file. Page cache that is merely recently touched is counted as memory in use, even though the kernel would drop it under pressure without complaint. Kubernetes issue 43916, "kubelet counts active page cache against memory.available", has 223 reactions and 144 comments and has been open since 2017. On a node running anything that reads files heavily — a log shipper, a build agent, a database with a large working set — evictions can fire against memory that was never really scarce.

The default hard thresholds are worth knowing exactly, because the first thing people do is change one of them and lose the rest: memory.available<100Mi on Linux, nodefs.available<10%, imagefs.available<15%, and nodefs.inodesFree<5%. Override any single one of them and every other threshold silently drops to zero — the documented behaviour is that the defaults "will only be set if none of the parameters is changed". Set MergeDefaultEvictionSettings: true in the kubelet configuration if you want the others to survive your one change.

A hard threshold also means no grace. The kubelet uses a zero-second grace period, ignores terminationGracePeriodSeconds, and ignores PodDisruptionBudgets entirely. If you want a warning shot, that is what soft thresholds are: evictionSoft plus evictionSoftGracePeriod, where the signal must stay below the threshold for the whole grace period before anything is terminated.

Where the fixes stop working

Make everything Guaranteed. It is the standard advice and it is not wrong, but read what you pay. Every pod now requests its peak rather than its typical usage, and the scheduler packs on requests, so a cluster of Guaranteed pods fits far fewer of them per node while the nodes themselves idle. You have also pinned a CPU limit on every workload, which is a throttling risk on any service with a bursty thread pool. The honest version of the advice is: set the memory request to your real peak, and set the memory limit equal to it if you want the class; be much more careful before doing the same to CPU.

Raise the priority. With the daemons at 2600, drag your pod's priority to 2000. It moves from position 4 to position 6, behind even redis-cache, and survives all three evictions. This works, and it works so well that it is dangerous: Priority is a cluster-wide ordering, so raising yours lowers everyone else's by comparison, and the same number also drives preemption — a high-priority pending pod can have running pods killed to make room for it, which is a scheduler behaviour with nothing to do with this page. Priority inflation is a real failure mode of large clusters and the ceiling is that everything important ends up at the same number, at which point the comparator ties again and you are back to overage.

Reserve more for the system. Raising kube-reserved and system-reserved lowers allocatable, so the scheduler places fewer pods and pressure arrives later. It also does nothing at all once the pods that are placed grow beyond their requests, which is the case that causes evictions in the first place. The related trap: the documentation notes that if a system daemon such as the kubelet or journald consumes more than its reservation and the node has only pods that are under their requests left, the kubelet "must choose to evict one of these pods to preserve node stability" — by lowest Priority. There is no configuration in which a Guaranteed pod cannot be evicted.

Set a PodDisruptionBudget. This does nothing here. Node-pressure eviction does not respect PodDisruptionBudgets; the API-initiated eviction used by kubectl drain does. Two different things share the word.

Checking it on a real cluster

An evicted pod leaves a corpse with the reason written on it:

kubectl get pods -A --field-selector=status.phase=Failed
kubectl describe pod api-7d9f -n prod | grep -A3 Status:
  Status:   Failed
  Reason:   Evicted
  Message:  The node was low on resource: memory. Threshold quantity: 100Mi,
            available: 84Mi. Container api was using 1408Mi, request is 1024Mi.

That message is the ranking, printed. It gives you the signal, the threshold, the observed value, and — the part that matters — your usage against your request. If the message says you were over your request, you were in the first comparator's group and the fix is the request. If it does not mention exceeding a request, you were in the second group and lost on Priority, and no amount of resource tuning would have saved you.

Evicted pods are not garbage-collected promptly, which is useful: they accumulate as Failed objects and are a free history of node pressure. The kube-controller-manager's --terminated-pod-gc-threshold defaults to 12,500 terminated pods before it starts pruning.

On the node itself, three things to look at in order:

  • kubectl describe node ip-10-0-1-8 → the MemoryPressure condition, and just below it the Allocatable figures and the sum of requests. A node whose requests total 95% of allocatable while its actual usage is 40% is a scheduling problem, not an eviction problem.
  • journalctl -u kubelet | grep -i evict → the lines "attempting to reclaim memory", "pods ranked for eviction" and the ranked list itself. That list is the output of the comparators above, in order, which makes it the ground truth for any argument about why a particular pod was chosen.
  • In Prometheus, kubelet_evictions is a counter labelled by eviction_signal, so you can tell a memory eviction from a disk one without reading logs. Graph it beside kube_pod_status_qos_class to see whether your evictions are landing where you assume.

The one number worth alerting on is neither of those. It is the gap between request and usage, per pod, over a long window:

max_over_time(container_memory_working_set_bytes[7d]) / on(pod,container) kube_pod_container_resource_requests{resource="memory"}

Above 1, that pod spends time in the first comparator's group and is a candidate on every pressured node it lands on. Well below 1, it is safe from eviction and is holding allocatable memory nobody can use. Both are request-sizing problems, and the QoS class is a label on the answer rather than a lever on it.

A node is under memory pressure. It holds a Guaranteed pod at priority 0 using 900 MiB of its 1 GiB request, and a Burstable pod at priority 1000 requesting 2 GiB and using 400 MiB. Which does the kubelet evict, and why?

Next: the field that decided this one, and the other two mechanisms that read it — placement, preemption and Pod Priority.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.