04-state-storage-resources

Requests, Limits and Quality of Service

Requests get you scheduled; limits get you throttled or killed. Two settings behind a remarkable share of Kubernetes incidents.

September 13, 2026
kubernetesrequestslimitsqosoomkilledcpu-throttlingresourcequota

Two Fields, Two Different Jobs

yaml
resources:
  requests:
    cpu: 250m
    memory: 256Mi
  limits:
    cpu: "1"
    memory: 512Mi

These look like a pair. They are not.

  • Requests are used by the scheduler. They reserve capacity and decide which node a pod can fit on. The kernel never enforces them.
  • Limits are enforced by the kernel at runtime. The scheduler ignores them entirely.

Conflating the two is behind a large share of Kubernetes incidents, so it's worth stating the consequence plainly: a pod is scheduled based on what it asked for, and constrained based on what you capped it at. Those are separate decisions made by separate components.

💡

CPU is measured in cores, where 1000m ("millicores") equals one core. Memory uses binary units — Mi is mebibytes, Gi gibibytes. 250m CPU is a quarter of a core. Writing memory: 512M (decimal) instead of 512Mi is valid but means something slightly different, which occasionally matters at tight limits.

How the Scheduler Uses Requests

The scheduler sums the requests of all pods already on a node and asks whether yours fits in what's left. It does not look at actual usage.

This produces two situations worth recognising:

Requests too high — you ask for 4Gi and use 200Mi. The scheduler reserves 4Gi, so the node fills up with reservations while sitting nearly idle. You pay for capacity nobody uses, and pods go Pending on a cluster with plenty of free memory.

Requests too low — you ask for 128Mi and routinely use 1Gi. The scheduler cheerfully packs many such pods onto one node, then they all grow, and the node runs out of real memory. Now the kubelet starts evicting pods to save the node.

bash
kubectl describe node ip-10-0-1-42 | grep -A 8 "Allocated resources"

That shows requests versus capacity — the scheduler's view, which is often strikingly different from actual usage in kubectl top node.

How the Kernel Enforces Limits

Here's the part that matters most, because the two resources fail in completely different ways:

ResourceExceeding the limitSymptom
MemoryContainer is OOM-killedExit code 137, restart, OOMKilled reason
CPUContainer is throttledLatency, timeouts — no crash, no event, no error

Memory is a hard wall. Request one byte past the limit and the kernel terminates the process. It's loud and unambiguous.

CPU is elastic. Exceed the limit and the kernel simply gives you less CPU time. Nothing is killed, nothing is logged, no event is recorded. Your application just gets slower.

🚨

CPU throttling is the single most under-diagnosed performance problem in Kubernetes. There is no error to search for. A service that's "mysteriously slow under load" with healthy memory and no restarts is throttling until proven otherwise. Every observable signal says the pod is fine.

Confirming each:

bash
# Memory: was it OOM-killed?
kubectl get pod api-7d4f8b9c5-x2kfp -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
kubectl describe pod api-7d4f8b9c5-x2kfp | grep -A 5 "Last State"
 
# Actual usage against limits
kubectl top pods
kubectl top pods --containers

For throttling specifically, the container runtime exposes container_cpu_cfs_throttled_seconds_total — if you have Prometheus, that metric is the definitive answer.

Check yourself

Service A restarts periodically with exit code 137. Service B never restarts but its p99 latency tripled after a traffic increase. Memory on B sits at 40% of its limit. What's happening in each?

QoS Classes

Kubernetes derives a Quality of Service class from your settings, and it decides who gets evicted when a node runs short:

ClassConditionEviction order
Guaranteedrequests equal limits, for every resource and containerLast
Burstablerequests set, lower than limitsMiddle
BestEffortno requests or limits at allFirst
bash
kubectl get pod api-7d4f8b9c5-x2kfp -o jsonpath='{.status.qosClass}'
⚠️

BestEffort pods are evicted first under node pressure. A pod with no resource settings isn't "flexible" — it's the first thing thrown overboard when a node is squeezed, and it can be evicted even while using very little. Omitting resources isn't a neutral default; it's an explicit choice to be the lowest priority on the node.

Guaranteed is the strongest protection and it costs flexibility: requests equal to limits means reserving your peak all the time. It's the right choice for latency-sensitive workloads that can't tolerate throttling or eviction.

Should You Set a CPU Limit at All?

A genuinely contested question worth knowing both sides of.

The case against CPU limits: throttling is invisible and hurts latency, and if requests are set correctly the scheduler already prevents overcommitment. Letting a pod burst into idle CPU is free performance. Many experienced teams set CPU requests and deliberately omit CPU limits.

The case for CPU limits: without them, one runaway process can starve everything else on the node, and performance becomes unpredictable — a pod behaves differently depending on what else happens to be scheduled beside it. Limits make behaviour reproducible.

The common ground: always set memory limits. Memory is non-compressible — a leak without a limit takes down the whole node, not just the pod. The debate is about CPU only.

Runtimes that size themselves from available memory need care. Older JVMs read the host's total memory rather than the cgroup limit, size a heap far larger than the container allows, and get OOM-killed while believing they're well within budget. Modern JVMs are container-aware, but setting it explicitly (-XX:MaxRAMPercentage) removes the guesswork. Node and Go have their own versions of this.

Setting Values From Evidence

Numbers invented at the keyboard are either wasteful or a future incident. A workable method:

  1. Deploy with generous limits and no requests initially, in a non-production environment.
  2. Run realistic load.
  3. Observe with kubectl top pods --containers and your metrics system — steady state and peak.
  4. Set requests near the observed steady state (the scheduler should reserve what you normally use).
  5. Set memory limits above observed peak with headroom — commonly 1.5–2×.
  6. Revisit after releases that change memory behaviour.

The Vertical Pod Autoscaler in recommendation mode can do the observation for you and suggest values without applying them, which is a good way to check your reasoning.

Namespace-Level Controls

Two objects let operators govern resource use across a namespace.

ResourceQuota caps the namespace in total:

yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-quota
  namespace: team-a
spec:
  hard:
    requests.cpu: "20"
    requests.memory: 40Gi
    limits.cpu: "40"
    limits.memory: 80Gi
    pods: "50"

LimitRange sets per-container defaults and bounds:

yaml
apiVersion: v1
kind: LimitRange
metadata:
  name: defaults
  namespace: team-a
spec:
  limits:
    - type: Container
      default:
        cpu: 500m
        memory: 512Mi
      defaultRequest:
        cpu: 100m
        memory: 128Mi
      max:
        cpu: "4"
        memory: 8Gi

LimitRange is how you stop BestEffort pods appearing by accident — containers with no resources specified get the defaults injected at admission.

⚠️

Once a ResourceQuota with requests.cpu or requests.memory exists in a namespace, every pod there must specify those values or it will be rejected at admission. This surprises teams who add a quota and suddenly find previously working manifests failing. Pairing the quota with a LimitRange that supplies defaults avoids it.

Check yourself

A pod specifies no requests and no limits. What's true about it?

What's Next

Phase 5 builds on these numbers: how the scheduler uses requests to place pods deliberately, how autoscaling reacts to demand, and how to survive the node drains and disruptions that are a routine part of cluster life.