A pod gets killed and restarts with OOMKilled in the status. A service that has "plenty of CPU available" on the node is still slow, and kubectl top shows it barely using any CPU at all. A node that looks half-empty in kubectl describe node won't schedule a new pod. All three of these are requests-and-limits problems, and all three get misdiagnosed constantly because requests and limits look like one setting and are actually two unrelated mechanisms that happen to share a YAML block.
Short answer
Requests are what the scheduler uses to decide which node a pod fits on — they don't limit anything at runtime, they're a reservation. Limits are what the kubelet/container runtime enforces once the pod is running — CPU limits get throttled via the CFS quota, memory limits get the container OOM-killed the instant it's exceeded, with no throttling equivalent.
If you only set one thing on every workload, set memory requests and memory limits equal to each other (this alone gets you the Guaranteed QoS class for memory and removes an entire category of eviction risk), and set a CPU request that reflects real usage — leave CPU limits off unless you have a specific reason to cap a noisy neighbor, because CPU throttling causes more mysterious slowness than it prevents.
Requests vs. limits: what each one actually does
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "256Mi"
Requests feed the scheduler's bin-packing decision. When the scheduler is deciding whether pod X fits on node Y, it sums the requests of everything already running on that node and checks against the node's allocatable capacity (from kubectl describe node, not raw hardware capacity — allocatable is capacity minus what's reserved for the kubelet, container runtime, and OS). If the sum of requests plus this new pod's request exceeds allocatable, the pod doesn't get scheduled there. That's the entire mechanism. Requests are never enforced against actual usage at runtime — a pod can use far more than it requested and nothing stops it, as long as the node has spare capacity and the container is under its own limit.
Limits are enforced by the kubelet and container runtime once the pod is running, and CPU and memory are enforced completely differently:
- CPU limits are implemented via the Linux CFS (Completely Fair Scheduler) quota mechanism. A
500mCPU limit translates to "this container gets 50ms of CPU time per 100ms scheduling period." If the container tries to use more than that in a period, it gets throttled — not killed, just paused until the next period. This is a per-container quota, independent of whether the node has spare CPU sitting idle. A container can be throttled on a mostly-idle node, which is the single most common CPU-limits surprise. - Memory limits have no equivalent soft mechanism. There's no "throttle memory usage." If a container's memory usage exceeds its limit, the kernel's OOM killer terminates it immediately. The pod restarts (if the workload controller allows it), and you get
OOMKilledin the container status.
QoS classes: what determines eviction order
Kubernetes assigns every pod a Quality of Service class based purely on how requests and limits are set — not on any priority field you configure separately:
- Guaranteed — every container in the pod has requests equal to limits, for both CPU and memory. These pods are evicted last under node memory pressure.
- Burstable — at least one container has a request set, but requests and limits aren't equal (or limits aren't set at all). Evicted after BestEffort is gone, roughly in order of how far usage exceeds requests.
- BestEffort — no requests or limits set at all, on any container. Evicted first under any memory pressure, with no useful ordering — Kubernetes doesn't know how much these pods need, so it can't be precise about which one to remove.
This matters because eviction order is a production reliability decision, not an implementation detail. A BestEffort pod running your payment processor next to a Guaranteed pod running a batch job you don't care about is backwards, and it happens by accident constantly — because nobody set requests/limits on the payment processor and somebody carefully tuned them on the batch job.
Overcommitment: why "the node has capacity" doesn't mean what you'd expect
Because the scheduler only checks requests, not limits, against allocatable capacity, a node can be scheduled up to its requested capacity while every pod's limit (if set higher than its request) allows it to use far more. This is intentional — it's how Kubernetes lets you pack workloads efficiently when usage is bursty and doesn't all peak simultaneously. It's also exactly how a node ends up under real memory pressure despite kubectl describe node showing requests well under allocatable: several Burstable pods burst at once, actual usage exceeds what was requested, and the node starts evicting.
There's no way around this trade-off, only ways to manage it deliberately:
- Setting requests close to real steady-state usage (not padded "to be safe," which just wastes capacity and defeats the point of overcommitment) makes the scheduler's math accurate.
- Setting memory limits equal to requests (Guaranteed QoS) removes memory overcommitment entirely for that workload, at the cost of some efficiency.
- Leaving memory limits unset is rarely correct — an unbounded container can consume the entire node's memory before the OOM killer decides which process to kill, potentially taking down unrelated pods on the same node (see Node-pressure eviction).
Practical implementation: finding the actual problem workloads
Real usage first, not guesses:
kubectl top pod -A --sort-by=memory
kubectl top pod -A --sort-by=cpu
Requires metrics-server running in the cluster — if these commands return nothing, that's the first thing to check, not a resource-limits problem.
Find pods with no requests/limits set at all — this is the single most common finding in a cluster that's never had a resource-management pass:
kubectl get pods -A -o json | jq -r '
.items[] |
select(.spec.containers[].resources.requests == null) |
"\(.metadata.namespace)/\(.metadata.name)"'
Check what a node actually has committed against it — allocatable, requested, and limits, side by side:
kubectl describe node <node-name>
Illustrative excerpt of the relevant section:
Allocated resources:
Resource Requests Limits
-------- -------- ------
cpu 3400m (85%) 6200m (155%)
memory 9800Mi (76%) 14200Mi (110%)
That 155% on CPU limits is normal and fine — it's overcommitment working as intended. What to actually watch is the requests percentage approaching 100%, which means the node genuinely can't schedule anything else regardless of what limits say.
Reading OOMKilled specifically in pod events, so you're not guessing whether a restart was memory-related:
kubectl describe pod <pod-name>
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Started: Mon, 08 Sep 2026 14:02:11 +0000
Finished: Mon, 08 Sep 2026 14:04:47 +0000
Exit code 137 (128 + SIGKILL's signal number 9) alongside Reason: OOMKilled is the unambiguous signature — if the pod is cycling through CrashLoopBackOff and this is what you find, it's the memory limit being too low (or the application genuinely leaking/growing beyond expected usage, which kubectl top pod over time will show), not an application bug to chase in the code. See Debugging CrashLoopBackOff in Kubernetes for the full diagnostic tree when OOMKilled isn't the cause.
To confirm CPU throttling specifically (as opposed to genuinely needing more CPU), check the cgroup throttling stats via kubectl exec into the container, or better, if cAdvisor metrics are already flowing into Prometheus, query:
rate(container_cpu_cfs_throttled_periods_total{container="api"}[5m])
/
rate(container_cpu_cfs_periods_total{container="api"}[5m])
A ratio consistently above roughly 0.1-0.2 means the container is meaningfully throttled and the CPU limit is worth raising or removing.
Common mistakes
- Copying resource values from a tutorial or another team's manifest instead of measuring actual usage.
100m/128Miis not a sensible default for every workload — it's a sensible default for nothing in particular. - Setting CPU limits reflexively because "limits are good practice," without understanding that CPU throttling is a real, frequent cause of latency spikes that's invisible unless you're specifically looking at
container_cpu_cfs_throttled_periods_total. Many production setups deliberately set CPU requests but skip CPU limits. - Setting memory requests without memory limits, which leaves the workload
Burstableand exposed to unbounded memory growth taking down the node — the opposite of the safety people assume "setting resources" gives them. - Never revisiting values after the initial deploy. Requests set six months ago for a workload that's since grown 4x in traffic are actively lying to the scheduler.
- Treating
kubectl topsnapshots as sufficient for sizing. A single snapshot misses peak usage entirely; use it to spot obvious outliers, but size from sustained metrics history in Prometheus/Grafana, not a point-in-time read.
Production considerations
- Requests drive cluster autoscaler and Karpenter decisions too, not just the scheduler — inaccurate requests mean inaccurate scaling decisions, either provisioning nodes you don't need or failing to provision ones you do.
LimitRangeobjects can enforce sane defaults and bounds per namespace so a workload deployed without explicit resources doesn't silently land asBestEffort. Worth setting as a namespace-level guardrail, not a substitute for tuning individual workloads.- Vertical Pod Autoscaler can recommend (or, in some modes, automatically apply) request values based on observed usage, which is a reasonable way to keep requests honest over time without a manual review cadence — though its "Auto" update mode restarts pods to apply changes, which itself needs to be reconciled with your availability requirements (see Kubernetes High Availability for what that touches).
- Resource pressure is an availability problem, not just a performance one — a node under memory pressure evicts pods, and eviction under sustained pressure is exactly the scenario a
PodDisruptionBudgetis supposed to protect against for voluntary disruptions, though eviction under actual node pressure is involuntary and PDBs don't block it.
Conclusion
Requests and limits aren't one setting, they're two mechanisms with different enforcement models solving different problems — get the distinction wrong and you end up with a cluster that schedules confidently and then falls over under real load, or one so conservatively padded it wastes half its capacity. Measure actual usage before setting either, set memory requests equal to limits by default, be deliberate (not reflexive) about CPU limits, and revisit the numbers as workloads change instead of treating the initial deploy as permanent.
If resource sizing is one gap in a broader pattern — no LimitRange guardrails, autoscaling tuned once and forgotten, no visibility into which workloads are actually throttled — that's exactly the kind of production-readiness work covered under Kubernetes Consulting. For a structured first look at where a specific cluster stands, see the Kubernetes production readiness guide or the Kubernetes Cluster Audit.