Kubernetes Resource Requests and Limits: Right-Sizing for Cost and Performance

The Cost of Getting Resource Requests Wrong

Resource requests and limits are the most consequential configuration in a Kubernetes cluster, and most teams get them wrong. Over-request, and you're paying for capacity that sits idle. Under-request, and the scheduler packs too many pods onto nodes, leading to CPU throttling and OOM kills during traffic spikes. I've seen both failure modes cost teams significant money and reliability.

The tricky part isn't setting initial values. It's keeping them accurate as your application's resource profile changes with every deployment.

Requests vs Limits: What the Scheduler Actually Does

The scheduler uses requests — not limits — to decide where to place a pod. If your pod requests 500m CPU and 512Mi memory, the scheduler finds a node with at least that much allocatable capacity remaining. The node might have 2 CPU and 4Gi of memory available, and the scheduler will happily place three more pods requesting 500m each on that node.

Limits cap the maximum a container can consume. CPU limits are enforced via CFS (Completely Fair Scheduler) throttling — when a container exceeds its CPU limit, it gets throttled, meaning it waits for its next time slice. Memory limits are enforced with an OOM kill — exceed the limit, and the kernel kills the process.

This distinction matters because the failure modes are completely different. CPU throttling increases latency but the pod keeps running. An OOM kill terminates the pod and potentially loses in-flight work.

The Case Against CPU Limits

There's a growing consensus that setting CPU limits on most workloads does more harm than good. Here's why: CPU is a compressible resource. If a node has spare CPU capacity and your pod is limited to 1 CPU, your pod can't use that spare capacity even though it's sitting there idle. Your pod gets throttled at 1 CPU while the node has 3 CPU doing nothing.

The counterargument is that without CPU limits, a runaway process can starve other pods on the node. That's true, but resource requests already guarantee a minimum allocation. If pod A requests 500m and pod B requests 500m, they'll each get at least 500m even under contention. Limits only matter when there's spare capacity to fight over.

Our team removed CPU limits from most workloads about a year ago. The result: p99 latency dropped 15% because pods weren't being throttled during brief CPU bursts, and our cloud spend didn't increase because the total node capacity stayed the same.

resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    # no cpu limit — let pods burst when capacity is available
    memory: 1Gi

Right-Sizing with Actual Metrics

Setting resource requests based on developer estimates is how you end up with every pod requesting 2 CPU and 4Gi when it actually uses 200m and 300Mi. You need actual usage data.

The Vertical Pod Autoscaler (VPA) in recommendation mode is the best tool for this. Install it, create a VPA resource for each workload, and it'll analyze actual CPU and memory usage to suggest appropriate requests:

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: api-server-vpa
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-server
  updatePolicy:
    updateMode: "Off"  # recommend only, don't auto-apply
  resourcePolicy:
    containerPolicies:
    - containerName: api-server
      minAllowed:
        cpu: 100m
        memory: 128Mi
      maxAllowed:
        cpu: "4"
        memory: 4Gi

After a week of production traffic, check the recommendations:

$ kubectl describe vpa api-server-vpa
  Recommendation:
    Container Recommendations:
      Container Name: api-server
      Lower Bound:
        Cpu:     234m
        Memory:  287Mi
      Target:
        Cpu:     412m
        Memory:  398Mi
      Upper Bound:
        Cpu:     893m
        Memory:  712Mi

The "target" is what VPA thinks you should set as your request. The "upper bound" is a reasonable limit. I typically set requests at the target and memory limits at the upper bound with 20% headroom.

Prometheus Queries for Manual Right-Sizing

If you're not running VPA, you can query Prometheus directly. This query shows the 95th percentile CPU usage over the past 7 days for each container in a namespace:

quantile_over_time(0.95,
  rate(container_cpu_usage_seconds_total{
    namespace="production",
    container!=""
  }[5m])[7d:]
) by (pod, container)

Compare that against what's requested:

kube_pod_container_resource_requests{
  namespace="production",
  resource="cpu"
} by (pod, container)

The ratio between actual p95 usage and the request tells you how over-provisioned you are. In my experience, most teams are requesting 3-5x what they actually use at peak. That's 3-5x the infrastructure cost for that workload.

Memory: The Resource You Can't Get Wrong

Unlike CPU, memory is incompressible. You can't throttle memory usage — the kernel either grants the allocation or kills the process. This means your memory requests and limits need to account for the worst case, not the average case.

Set memory requests to the p99 working set size — not the RSS, which includes shared memory and cached pages that the kernel can reclaim. The metric you want is container_memory_working_set_bytes:

max_over_time(
  container_memory_working_set_bytes{
    namespace="production",
    container="api-server"
  }[7d]
)

Set the memory limit 20-30% above the request. This gives your application room for occasional spikes without getting OOM killed, while still protecting the node from a true memory leak consuming everything.

QoS Classes and Eviction Priority

Kubernetes assigns a Quality of Service class to each pod based on its resource configuration. This determines eviction priority when a node runs out of resources:

Guaranteed — requests equal limits for both CPU and memory. These pods are evicted last. Use for critical workloads like databases.

BestEffort — no requests or limits set at all. These are evicted first. Fine for batch jobs that can be rescheduled, terrible for anything user-facing.

Burstable — everything else. Requests are set but don't equal limits. This is where most workloads should land.

The eviction order within the Burstable class depends on which pods are using the most memory relative to their request. A pod using 900Mi with a 500Mi request gets evicted before a pod using 400Mi with a 300Mi request. This is another reason accurate requests matter — under-requesting makes your pod a prime eviction candidate.

Namespace-Level Guardrails

LimitRanges and ResourceQuotas are your safety net. A LimitRange sets default requests and limits for pods that don't specify them, and caps the maximum any single container can request:

apiVersion: v1
kind: LimitRange
metadata:
  name: default-resources
  namespace: production
spec:
  limits:
  - default:
      memory: 512Mi
    defaultRequest:
      cpu: 100m
      memory: 256Mi
    max:
      cpu: "4"
      memory: 8Gi
    type: Container

A ResourceQuota caps total consumption for the entire namespace. Without one, a team can accidentally scale their deployment to 100 replicas and consume the entire cluster's capacity:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: production-quota
  namespace: production
spec:
  hard:
    requests.cpu: "40"
    requests.memory: 80Gi
    limits.memory: 160Gi
    pods: "200"

We set these on every namespace in production. The quota numbers should be large enough that normal operations don't hit them, but small enough to prevent one team's incident from affecting everyone else.

Continuous Right-Sizing as a Practice

Right-sizing isn't a one-time project. Application resource profiles change with every feature deployment. A new caching layer reduces CPU usage. A richer response format increases memory allocation. The requests you set three months ago are probably wrong today.

Build a monthly review into your operations cadence. Pull VPA recommendations or Prometheus queries for all production workloads, compare against current requests, and adjust. Automate what you can — some teams run VPA in auto mode for stateless workloads — but always review changes to stateful workloads manually. An automated memory reduction on a database pod that's actually using that memory for its buffer cache will ruin your day.

Handling Java and JVM Applications

JVM applications deserve special mention because their memory behavior confuses Kubernetes. The JVM allocates a heap at startup and manages memory internally. The container's memory usage (as seen by cgroup) includes the heap, metaspace, thread stacks, native memory, and memory-mapped files.

A common mistake: setting Xmx512m and a container memory limit of 512Mi. The JVM heap is 512MB, but total JVM memory usage will be 700-800MB. The container gets OOM killed because the limit doesn't account for non-heap memory.

The fix is to set the JVM heap to roughly 70% of the container memory limit, and configure the JVM to respect container memory limits using UseContainerSupport and MaxRAMPercentage flags. Setting MaxRAMPercentage to 70.0 leaves 30% for non-heap allocations. This has eliminated OOM kills in our Java services.

For Go applications, the memory picture is simpler but GOMAXPROCS needs attention. By default, Go sets GOMAXPROCS to the number of CPUs visible to the process - which in a container is the host's CPU count, not the container's limit. A Go process on a 64-core node will spawn 64 OS threads even if it's limited to 1 CPU. Use the automaxprocs library from Uber to detect container CPU limits correctly.

Pod Priority and Preemption

Priority classes work alongside resource requests to determine scheduling order. When the cluster is full, a high-priority pod can preempt lower-priority pods. This matters for resource sizing because it means your critical services can always find capacity, even during burst periods.

Create priority classes that match your service tiers. System components get the highest priority. Customer-facing services get high priority. Background workers get normal priority. Batch jobs and development workloads get low priority.

The interaction between priority and the Cluster Autoscaler is worth understanding: preempted pods become unschedulable, which triggers the autoscaler to add nodes. The high-priority pod runs immediately on the space freed by preemption, and the evicted pods wait for new nodes. This gives you fast scaling for critical services without maintaining excess capacity.