Requests, limits & QoS
How requests drive placement and eviction order.
A container dies at 3 a.m. with exit code 137, and the pager blames a memory leak. Usually there's no leak. There's a missing number in the YAML. Two settings, the memory request and the memory limit, decide whether your Pod (the smallest deployable unit in Kubernetes, one or more containers that share a network address) gets a fair slice of a machine or gets shoved off it. A lot of 'the cluster feels unstable' tickets start right here.
Requests reserve, limits cap
Start with a dinner reservation. You call ahead, the restaurant holds a table for your party, and even on a packed Friday that seat stays yours whether you show up early or late. A request works the same way. It's the amount of CPU and memory the scheduler (the control-plane component that decides which node each Pod lands on) sets aside for your container before it ever runs. That capacity is reserved even while the container sits idle. A limit is different. It's the fire-code ceiling the kubelet (the agent Kubernetes runs on every node) enforces once the container is alive and using resources.
CPU and memory hit that ceiling very differently, and this is where people get burned. CPU is squeezable. Push past your CPU limit and the Linux kernel just hands you fewer time slices, so the app runs slower but stays up. That slowdown is called throttling. Memory can't be squeezed. Push past your memory limit and there's nowhere to put the extra bytes, so the kernel kills the process outright. That's an OOMKill (out of memory), and exit code 137 is its fingerprint (128 plus signal 9, the kill signal).
apiVersion: v1kind: Podmetadata:name: webspec:containers:- name: webimage: nginx:1.27resources:requests:cpu: "250m" # scheduler reserves a quarter corememory: "256Mi" # scheduler reserves 256Milimits:memory: "256Mi" # kubelet OOMKills the container past this
$ kubectl apply -f web-pod.yamlpod/web created$ kubectl get pod web -o jsonpath='{.status.qosClass}{"\n"}'Burstable
That cpu value, 250m, reads as 250 millicores, a quarter of one CPU core (1000m is a full core). That request pulls double duty. Besides telling the scheduler how much to set aside, it also fixes your container's share of CPU time when the node is busy, so a container asking for 250m gets half the CPU of one asking for 500m when they're both straining against the same cores. Notice there's no CPU limit here on purpose, only a memory limit. Hold that thought. And notice what came back: Burstable. You didn't ask for a class. Kubernetes worked it out from the numbers you set.
The QoS class you never set
You never write a Quality of Service (QoS) class anywhere in your manifest. Kubernetes reads it off your requests and limits the way an emergency room assigns triage priority from your vital signs, not from what you claim at the front desk. Three classes exist, and they set the order Pods get sacrificed when a node runs short on memory. Guaranteed means every container has its request equal to its limit for both CPU and memory; these go last. BestEffort means you set nothing at all, no requests and no limits; these go first. Everything in between is Burstable. Your web Pod landed in Burstable because it requests CPU but never limits it, so request and limit don't match across the board.
$ kubectl get pods -o custom-columns=NAME:.metadata.name,QOS:.status.qosClass
NAME QOScache Guaranteedweb Burstablescratch BestEffort
Here's what actually happens under pressure. When a node's free memory falls past the kubelet's eviction threshold, the kubelet doesn't sit and wait for the kernel to start killing things at random. It ranks the Pods and evicts on purpose: BestEffort first, then Burstable Pods that have climbed above their requests, and Guaranteed last. At the same moment the kubelet writes each container's oom_score_adj value into the kernel, so if the kernel's own out-of-memory killer fires first during a sudden spike, it picks the same low-priority victims. The class shows up in both places, the orderly kubelet eviction and the kernel's panic kill.
Stop BestEffort sneaking into production
Trusting every developer to remember these fields is exactly how BestEffort Pods slip into a namespace. A LimitRange fixes it at the source. Think of it as the default portion size the kitchen plates up when a customer forgets to order one: it stamps default requests and limits onto any container that omits them, so a forgotten resources block becomes a sane Burstable Pod instead of a BestEffort one that dies first. Apply it once per namespace and every future Pod inherits the floor.
apiVersion: v1kind: LimitRangemetadata:name: default-limitsnamespace: paymentsspec:limits:- type: Containerdefault: # applied as the LIMIT when you omit onecpu: "500m"memory: "256Mi"defaultRequest: # applied as the REQUEST when you omit onecpu: "100m"memory: "128Mi"
$ kubectl apply -f limitrange.yamllimitrange/default-limits created$ kubectl run tmp --image=nginx --restart=Never -n paymentspod/tmp created$ kubectl get pod tmp -n payments -o jsonpath='{.status.qosClass}{"\n"}'Burstable
The tmp Pod set no resources at all, yet it came back Burstable, not BestEffort. The LimitRange backfilled the request and the limit before the Pod was admitted, so it schedules accurately and resists eviction instead of being first in line.
Reading the wreckage when it breaks
When a container keeps restarting, its status tells you why before any log line does. kubectl describe records the last time the process died and the reason. An OOMKill leaves an unmistakable trail: reason OOMKilled, exit code 137, and a restart count that keeps climbing every couple of minutes.
$ kubectl describe pod web | grep -A6 'Last State'
Last State: TerminatedReason: OOMKilledExit Code: 137Started: Thu, 16 Jul 2026 02:58:41 +0000Finished: Thu, 16 Jul 2026 03:01:12 +0000Restart Count: 4
OOMKilled with a rising restart count means the real memory the app needs is above the limit you gave it. The fix is to measure actual usage (kubectl top pod, or your metrics) and raise the memory request and limit to match, not to delete the limit and let one leaky container eat the whole node. Raising the request to what the app really uses pays off at eviction time too. The kubelet only targets Pods running above their requests, so a request that matches reality keeps yours off the early kill list.
CPU limits throttle; memory limits OOMKill. Those failure modes feel different to users.
Right-sizing starts from observed usage, not from aspirational spreadsheets. kubectl top is a beginning, not a capacity plan.
LimitRanges can default requests in a namespace so bare pods stop landing as BestEffort by accident.
Try this
Run three pods: requests only, requests+limits equal, and limits without requests. Check QoS class in each pod status and discuss eviction order.
$ kubectl apply -f web-pod.yamlpod/web created$ kubectl get pod web -o jsonpath='{.status.qosClass}{"\n"}'Burstable$ kubectl get pods -o custom-columns=NAME:.metadata.name,QOS:.status.qosClass$ kubectl apply -f limitrange.yamllimitrange/default-limits created$ kubectl run tmp --image=nginx --restart=Never -n paymentspod/tmp created$ kubectl get pod tmp -n payments -o jsonpath='{.status.qosClass}{"\n"}'Burstable$ kubectl describe pod web | grep -A6 'Last State'
Takeaway
Requests drive scheduling and fair share; limits cap usage. QoS Guaranteed, Burstable, and BestEffort decide eviction pain under node pressure.