The scheduler never reads your limits. It reads requests, subtracts them from allocatable node capacity, and places the pod. That is the entire role requests play in placement, and it explains the single most common misconfiguration in production clusters: a workload with a 100m CPU request and a 2-core limit, scheduled onto a node that has no chance of ever giving it 2 cores, because seven other pods made the same optimistic bet.

Limits, by contrast, are enforced by the kernel at runtime and have nothing to do with placement. A CPU limit becomes a CFS bandwidth quota. A memory limit becomes a cgroup ceiling that triggers the OOM killer. These are blast-radius controls — they bound how much damage one misbehaving container can do to its neighbours. Treating them as a pair, and especially setting them equal because a blog post said Guaranteed QoS is better, throws away most of the density you paid for.

CPU and memory are not symmetric, and the asymmetry drives everything

CPU is compressible. When a container exceeds its share, the kernel throttles it — the process slows down but survives. Memory is not compressible. When a container exceeds its memory limit, it dies. That single difference should produce completely different policies for the two resources, and in most clusters it does not.

The policy that follows from the asymmetry:

  • Memory: set request and limit to the same value, always. There is no upside to a gap. A pod that is allowed to burst above its memory request is a pod that will get OOM-killed at an unpredictable time on a node that happens to be under pressure, and the node-pressure eviction that follows will take out whichever pods have exceeded their requests by the largest margin — which is to say, the ones you were counting on to burst.
  • CPU: set a request that reflects sustained usage and, for most workloads, set no limit at all.

The second one is the argument people fight about, so here is the case.

CPU limits cause throttling you will not see in utilisation graphs

The CFS quota mechanism works on a 100ms period by default. A container with a 1-core limit gets 100ms of CPU time per 100ms period. If your service is a JVM or a Go binary with a garbage collector that briefly parallelises across eight cores, it will burn its entire quota in 12ms and then sit throttled for the remaining 88ms of the period. Average CPU utilisation over that second reads as 12 percent. Your p99 latency reads as a disaster.

This is the diagnostic that matters, and almost nobody has it on a dashboard:

rate(container_cpu_cfs_throttled_periods_total{container!=""}[5m])
  /
rate(container_cpu_cfs_periods_total{container!=""}[5m])

Any workload where that ratio sits above roughly 0.05 during normal operation is being materially slowed by its limit. I have seen services at 0.4 — throttled during 40 percent of all scheduling periods — where the owning team was confident CPU was not the problem, because the utilisation panel showed 30 percent.

The counter-argument to removing CPU limits is noisy neighbours. It is a real concern and it is handled by requests, not limits. CFS shares are allocated proportionally to requests. If a node is saturated, a container with a 500m request gets five times the CPU time of one with 100m. Contention is resolved fairly by the mechanism that already exists. Limits only bind when the node has spare capacity — which is exactly when you would want the burst to be allowed.

Two exceptions where CPU limits earn their place: multi-tenant clusters where you are selling a capacity guarantee and need predictable per-tenant behaviour regardless of node conditions, and benchmarking environments where you need reproducibility more than throughput. Neither describes the average internal platform.

Picking the request number

The usable methodology is percentile-based, workload-classified, and takes about twenty minutes per service once you have the data.

Pull 14 days of usage — enough to capture a weekly cycle, short enough that you are not sizing against traffic patterns that no longer exist. Two weeks of a business-hours service includes ten weekday peaks and four weekend troughs, which is sufficient signal.

# CPU: the p95 of per-container usage, over 14 days
quantile_over_time(0.95,
  rate(container_cpu_usage_seconds_total{
    namespace="payments", container="api"
  }[5m])[14d:5m]
)

# Memory: the maximum working set, not a percentile
max_over_time(
  container_memory_working_set_bytes{
    namespace="payments", container="api"
  }[14d]
)

Use p95 for CPU because the tail is compressible — being under-provisioned for the top 5 percent means brief slowdowns, and if you have removed the limit, it means nothing at all on a node with headroom. Use max for memory because the tail is fatal. A service that touches 3.1 GiB once a fortnight during a batch reconciliation needs 3.1 GiB plus headroom, not its p95 of 1.8 GiB.

Then apply a classification multiplier to the CPU number:

Workload classCPU requestRationale
Latency-critical, user-facingp95 × 1.3Headroom absorbs GC pauses and connection storms without relying on node slack
Async worker, queue consumerp95 × 1.0Backlog is the shock absorber; slower is acceptable
Batch / cronp50 × 1.0Runtime elasticity is free here; pack these densely
Sidecars (proxy, log shipper)measured, floor 50mAlmost always over-requested by copy-paste; 100m × 4000 pods is 400 cores of nothing

Memory gets a flat 1.25× on the observed maximum, with a floor that accounts for whatever your runtime allocates at startup. For JVM workloads, set -XX:MaxRAMPercentage=75 and let the heap derive from the cgroup limit rather than hardcoding -Xmx in two places that will drift apart.

What this produces on a real Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments-api
spec:
  replicas: 12
  template:
    spec:
      containers:
      - name: api
        image: registry.internal/payments-api:8f21c04
        resources:
          requests:
            cpu: 850m          # p95 of 650m x 1.3
            memory: 2Gi        # max 1.6Gi x 1.25
          limits:
            memory: 2Gi        # equal to request: no gap, ever
            # no cpu limit — throttling is worse than the noisy neighbour
        env:
        - name: JAVA_TOOL_OPTIONS
          value: "-XX:MaxRAMPercentage=75 -XX:+UseG1GC"
      - name: envoy
        resources:
          requests:
            cpu: 60m           # measured, not the 100m from the template
            memory: 128Mi
          limits:
            memory: 128Mi

Note the QoS class this produces: Burstable, not Guaranteed. That is deliberate and it is fine. Guaranteed QoS buys you two things — exemption from node-pressure eviction ahead of Burstable pods, and eligibility for exclusive CPU pinning under the static CPU manager policy. The first matters if you are running on nodes that regularly hit memory pressure, which is a problem you should fix directly. The second matters for a narrow set of latency-sensitive workloads, and requires kubelet configuration most clusters do not have. Chasing Guaranteed by setting a CPU limit equal to the request is paying the throttling tax for a benefit you are not collecting.

The organisational half

None of this holds without a mechanism, because requests decay. A service is sized correctly in March, ships a caching layer in May that halves its CPU, and nobody revisits the manifest for two years. The cluster carries the difference.

Three controls, in increasing order of how much they will annoy people:

LimitRange with defaults per namespace. Catches the pods that specify nothing at all, which are the worst offenders — a pod with no memory request is BestEffort and is first in line for eviction, while simultaneously being invisible to the scheduler's capacity math.

apiVersion: v1
kind: LimitRange
metadata:
  name: sane-defaults
  namespace: payments
spec:
  limits:
  - type: Container
    default:              { memory: 512Mi }
    defaultRequest:       { cpu: 100m, memory: 512Mi }
    max:                  { cpu: "8", memory: 16Gi }
    min:                  { cpu: 10m, memory: 32Mi }

ResourceQuota on requests, not limits. Quota the thing that consumes real capacity. Quotaing limits produces the perverse incentive to set limits low, which is the opposite of what you want.

A recurring drift report. Run VPA in Off mode across the fleet so it produces recommendations without acting on them, then diff those against the declared requests weekly and open a PR when the gap exceeds 40 percent in either direction. The mechanics of doing that safely — and why you should not let VPA apply the changes itself if you are also running HPA — are covered in the piece on autoscaler interaction, and the full rollout process is in the right-sizing methodology.

One last thing worth internalising: fixing requests improves allocation accuracy, but it does not by itself improve node utilisation. You can right-size every workload in the cluster and still run at 35 percent, because the gap between what is allocated and what is provisioned is a packing problem with its own set of levers. That is a separate argument, and it is usually where the larger number is hiding.