CPU limits are usually a mistake
Requests and limits look symmetric in the YAML. They are not. They do different jobs, through different kernel mechanisms, and the right policy is different for each resource.
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { memory: 256Mi }
Note what is missing.
What each one actually does
requests.cpu is used by the scheduler to place the pod, and becomes cpu.shares in the cgroup. Shares are relative weights, and they only bind when the node is saturated. A pod requesting 100m on an idle node can use whole cores.
limits.cpu becomes a CFS quota: some microseconds of CPU per 100ms period. When you exhaust the quota, the cgroup is throttled — every thread stops until the next period starts.
requests.memory is for scheduling. limits.memory is a hard cgroup ceiling, and exceeding it means the kernel OOM-kills the process.
The asymmetry: CPU is compressible. Take it away and things get slower. Memory is not. Take it away and the process dies.
Why CPU limits hurt
Quota is enforced per 100ms period, and that granularity is the problem.
A service with limits.cpu: 500m gets 50ms of CPU per 100ms. If it is multithreaded — and a Go binary on an 8-core node has GOMAXPROCS=8 by default — eight threads running for 10ms each consume the entire budget in the first 10ms of wall time. The cgroup is then throttled for 90ms.
Average utilisation looks like 15%. p99 latency has a 90ms cliff in it. Your dashboards say the service is idle and your users say it is slow.
The metric that settles it:
rate(container_cpu_cfs_throttled_seconds_total[5m])
If that is non-zero on a service you consider under-utilised, this is happening to you.
Go makes it worse by default: GOMAXPROCS reads the number of host cores, not the cgroup quota, so the runtime starts eight Ps for a container entitled to half a core. automaxprocs fixes this by setting GOMAXPROCS from the quota, and as of Go 1.25 the runtime is cgroup-aware itself.
Memory limits are not optional
Because memory is incompressible, a container without a limit can grow until it takes the node with it — evicting pods that were behaving perfectly well.
Set requests.memory == limits.memory. That gives the pod Guaranteed QoS, which means the kubelet evicts it last under node pressure, and it makes the failure mode predictable: exceed your budget and you get OOM-killed, alone, rather than degrading a shared node.
Guaranteed QoS requires requests to equal limits for every resource in the container, CPU included — so omitting the CPU limit puts you in Burstable. That is the trade, and for latency-sensitive services it is usually the right one: Burstable with no CPU limit beats Guaranteed with throttling.
The policy
- CPU: set requests, usually omit limits. Requests give you a scheduling guarantee and a fair share under contention, which is what you wanted. Limits only add throttling.
- Memory: set both, equal.
The common objection is that a pod without a CPU limit can starve its neighbours. Under contention it cannot: shares are proportional to requests, so a pod requesting 100m gets its 100m. The unlimited pod only uses spare capacity that would otherwise be idle — which is the entire point of having it.
Set CPU limits when you are deliberately capping a noisy batch job, or when a platform team needs hard multi-tenant ceilings. Not by default, and not on anything user-facing.