Kubernetes / resource management / eviction / scheduling
QoS Classes: Guaranteed, Burstable, BestEffort
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.
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.
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.
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.
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.requirementsQOSruns that for CPU and memory only — the set is literallysupportedQoSComputeResources = {cpu, memory}— and returns Burstable if either resource is Burstable or if the two disagree.ComputePodQOSruns 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'spriorityClassName, 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 describesaysReason: 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.availablefalls below a threshold. It terminates whole pods, sets the phase toFailedwithReason: 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→ theMemoryPressurecondition, and just below it theAllocatablefigures 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_evictionsis a counter labelled byeviction_signal, so you can tell a memory eviction from a disk one without reading logs. Graph it besidekube_pod_status_qos_classto 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.