PerfectScalePerfectScale

PerfectScale

How to Fix Kubernetes Resource Policies That Optimize for the Wrong Phase

Your ResourceQuota, LimitRange, and admission policies encode one number per container. Containers have two phases. Here's how to rewrite the policy layer so it stops picking the wrong one.

This page is also available in Deutsch, Español, Français, Italiano, 日本語, and Português.

Oct 19, 202619 min read
Josh Palmer

About Josh Palmer

Head of Content

I'm Josh Palmer, Head of Content at DoiT, where I split my time across multiple business units including DoiT Cloud Intelligence, PerfectScale (Kubernetes cost optimization), and SELECT (Snowflake, Databricks, and BigQuery cost optimization). Before DoiT, I spent four and a half years at OnBoard building content for a board intelligence platform used by 6,000+ organizations, and before that, two years as Content Marketing Manager at Zylo, a SaaS management platform.

My personal page

TL;DR

  • A Kubernetes resource policy is every layer that decides what requests and limits a container ends up with: ResourceQuota, LimitRange, admission rules (Kyverno, Gatekeeper, ValidatingAdmissionPolicy), Helm chart defaults, and whatever recommender writes the numbers.
  • Every one of those layers assumes one number per container for the container's whole life. Containers have two phases, startup and steady state, and the gap between them can be 3x to 10x.
  • Policies tuned for steady state throttle and OOM-kill pods during startup. Policies tuned for startup reserve capacity nobody uses, fill namespace quotas with idle requests, and leave pods Pending while the cluster sits half empty.
  • The fix is structural: quota on requests, not limits; LimitRange that allows bursting instead of clamping it; admission rules that require requests and memory limits but stop mandating CPU limits; and a recommender that sees startup and steady state as separate numbers.
  • In-place pod resize makes the last step practical. Startup gets what it needs, then the pod shrinks to its plateau without a restart, and quota is reclaimed at the same moment.

In the last post we covered how to see a startup spike: filter throttling and OOM metrics by container age, and the burst that averages hide shows up in the first 60 seconds of every container's life. This post is about what to do once you can see it, because the hard part isn't editing one Deployment. It's that the policy layer around that Deployment was written for one phase and will keep pushing every workload back toward it.

What a Kubernetes resource policy actually is

When people search for kubernetes resource quota they're usually configuring one of two objects in a namespace, but the policy that determines a container's final requests and limits has more layers than that. Each one can be tuned to the wrong phase independently.

Layer What it controls Steady-state tuning breaks startup when… Startup tuning wastes capacity when…
ResourceQuota Namespace-wide ceiling on summed requests.cpu, requests.memory, limits.cpu, limits.memory, pod count Quota on limits.* forces teams to shrink limits to fit, so startup hits the CFS quota or the OOM killer Quota on requests.* is consumed by peak-sized requests that sit 90% idle after the first minute
LimitRange Per-container default, defaultRequest, min, max, maxLimitRequestRatio max or a low maxLimitRequestRatio clamps the limit near the request, removing the burst headroom startup needs defaultRequest set high so unspecified containers survive boot, which inflates every container that didn't set its own
Admission policy (Kyverno, Gatekeeper, ValidatingAdmissionPolicy) Which fields must be present and what shape they take "Every container must set a CPU limit" turns an optional throttle into a mandatory one "Requests must equal limits" (Guaranteed QoS everywhere) locks the startup peak in as a permanent reservation
Chart and template defaults What a container gets when nobody thought about it Defaults copied from a service that boots fast, applied to one that doesn't Defaults copied from a JVM service, applied to a Go sidecar
Recommender (VPA, scripts, a platform) The numbers that eventually land in the spec Histogram over days recommends the plateau, next rollout throttles Recommender sees the peak, recommends it, and never revisits once the pod is quiet

The scheduler reads requests. The kernel enforces limits. The quota system counts whichever of the two you told it to. None of them knows the container just finished class loading and won't need that CPU again until the next deploy.

How to tell which phase your policy optimizes for

You don't need to read every manifest. Three signals tell you which direction the policy leans.

Pods throttle or OOM-kill in their first two minutes and nowhere else. That's a steady-state policy. Somewhere a LimitRange, an admission rule, or a recommender pulled limits down to the plateau. Confirm with the young-container throttling query from the detection post and kube_pod_container_status_last_terminated_reason{reason="OOMKilled"} clustered right after rollouts.

Namespaces hit quota while nodes run at 20 to 30% CPU. That's a startup policy. Requests are sized to survive boot, so the quota is consumed by reservations that go idle 60 seconds in. Check it directly:

# Requested CPU vs actual CPU, per namespace
sum by (namespace) (kube_pod_container_resource_requests{resource="cpu"})
/
sum by (namespace) (rate(container_cpu_usage_seconds_total{container!=""}[5m]))

A ratio of 1.5 to 2 is normal headroom. A ratio of 5 or more across a namespace means the requests describe a phase the containers left a long time ago. kubectl describe resourcequota -n <namespace> shows the same story from the other side: requests.cpu used at 95% of hard while kubectl top pods adds up to a fraction of that.

Both at once. This is more common than it sounds. CPU limits are clamped to steady state (throttling at boot) while memory requests are sized to the startup peak (idle reservations). The policy picked the wrong phase for each resource, in opposite directions.

The quota math that makes this expensive

Take the example from the detection post: a service with 40 replicas, a 2-core startup burst for about 45 seconds, and a 200m steady state.

Size requests for the plateau and the Deployment consumes 8 cores of requests.cpu quota. Size them for the peak and it consumes 80. The namespace quota that comfortably held ten such services now holds one, and the other nine sit Pending with exceeded quota in their events while the nodes they would have landed on run idle. On a cluster where requests drive node provisioning through Karpenter or the Cluster Autoscaler, those 72 phantom cores also become real nodes with a real bill.

Same 40 replicas against a 64-core requests.cpu quota: requests sized to steady state use 8 cores and leave room for seven more services; requests sized to the startup peak reserve 80 cores, leave 56 of them idle after the first minute, and push 8 pods into Pending

The quota is doing exactly what it was told. The problem is that a quota counts a number that was true for 45 seconds and treats it as true for the life of the pod.

Fix 1: Put quota on requests, not limits

ResourceQuota can cap requests.cpu, limits.cpu, or both. Teams often add limits.cpu to a quota on the theory that it bounds worst-case usage. What it does in practice is force limits down toward requests so the namespace fits, which removes the headroom startup needs.

Requests are what the scheduler reserves and what you pay for in node capacity. Cap those. Let limits float.

apiVersion: v1
kind: ResourceQuota
metadata:
name: team-payments
namespace: payments
spec:
hard:
requests.cpu: "64"
requests.memory: 256Gi
limits.memory: 512Gi # memory limits are a real safety bound; keep them
# no limits.cpu — CPU limits throttle, they don't protect the node
pods: "400"

Keep limits.memory in the quota. Memory is not compressible, a container that exceeds its memory limit gets killed rather than slowed, and a namespace-level memory ceiling is a legitimate blast-radius control. CPU limits are different, as Anton covered in the CPU limits post: they protect nothing on the node and cost you latency at exactly the moment a pod is trying to come up.

One consequence to plan for: once a quota names requests.cpu, every pod in the namespace must specify a CPU request or it gets rejected at admission. That's where LimitRange defaults come in, and it's the next place the wrong phase gets baked in.

Fix 2: Make LimitRange allow bursting instead of clamping it

LimitRange is where most steady-state policies do their damage, usually through two fields.

maxLimitRequestRatio caps how far a limit can sit above its request. A ratio of 1 or 2 means a container requesting 200m can have a limit of at most 400m, and the 2-core startup burst runs at a fifth of the speed it needs. If you're going to keep CPU limits at all, the ratio has to cover the real startup-to-plateau gap, which for JVM and Node services is often 5 to 10.

max on CPU limits does the same thing more bluntly. A max of 1 core per container is reasonable for steady state and guarantees every JIT-heavy service boots throttled.

apiVersion: v1
kind: LimitRange
metadata:
name: two-phase-defaults
namespace: payments
spec:
limits:
- type: Container
defaultRequest:
cpu: 100m # scheduling default for containers that set nothing
memory: 128Mi
default:
memory: 512Mi # memory limit default; no CPU limit default
max:
memory: 8Gi
min:
cpu: 10m
memory: 32Mi
maxLimitRequestRatio:
memory: "4" # memory limit may sit up to 4x above request

Two things are deliberate here. There is no default.cpu, so a container that sets a CPU request and nothing else runs without a CPU limit and can burst into idle node capacity at boot. And maxLimitRequestRatio is set for memory only, because memory is where the ratio does useful work: it lets the limit clear the startup allocation while the request stays at the plateau, without letting anyone set a 128Mi request with a 64Gi limit.

The defaultRequest values are low on purpose. A LimitRange default is applied to every container that didn't specify its own, and a generous default silently inflates the requests of every sidecar and job in the namespace. The default should be the floor that keeps the quota admission check passing, not a sizing recommendation.

Fix 3: Rewrite admission rules around what each resource is for

The third layer is the one teams forget they have. A Kyverno ClusterPolicy or Gatekeeper constraint written two years ago that says "every container must declare CPU and memory requests and limits" is still enforcing a one-number model on every deploy.

Rewrite it to require what matters and stop requiring what hurts:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-requests-and-memory-limit
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["deployments", "statefulsets", "daemonsets"]
validations:
- expression: >
object.spec.template.spec.containers.all(c,
has(c.resources) && has(c.resources.requests) &&
has(c.resources.requests.cpu) && has(c.resources.requests.memory))
message: "Every container must set cpu and memory requests."
- expression: >
object.spec.template.spec.containers.all(c,
has(c.resources.limits) && has(c.resources.limits.memory))
message: "Every container must set a memory limit."

CPU requests: required, because the scheduler and HPA depend on them. Memory requests and limits: required, because the OOM killer is unforgiving and a memory limit is a real safety bound. CPU limits: not mentioned. Teams that have a specific reason for one (a noisy batch job, a multi-tenant node) can still set it; the policy just stops making everyone else pay the startup tax.

If your organisation mandates Guaranteed QoS (requests equal to limits) for certain tiers, be explicit about the cost. Guaranteed means the startup peak becomes a permanent reservation on every replica. That's the right trade for a latency-critical database. It's the wrong one for 40 stateless API replicas.

Fix 4: Size memory for startup, CPU for steady state

Once the policy layer stops forcing a single answer, the per-workload decision gets simpler, and it's different for each resource.

Memory limit clears the startup peak. There is no graceful degradation for memory. If initialization allocates 900Mi and the limit is 768Mi, the container dies with exit code 137 before it ever reaches its 400Mi plateau. Take max_over_time(container_memory_working_set_bytes[5m]) over a window that includes a rollout and set the limit above that number with margin. For JVM workloads remember that heap is only part of the picture: metaspace, code cache, and thread stacks all sit outside -Xmx.

Memory request sits at the plateau. The request is what the scheduler reserves and what quota counts. Setting it to the steady-state working set with modest headroom keeps bin-packing honest. The gap between request and limit puts the pod in Burstable QoS, which means it's a candidate for eviction under node memory pressure before Guaranteed pods are. For most stateless services that is an acceptable trade; for the ones where it isn't, set request equal to limit deliberately and accept the reservation.

CPU request sits at the plateau, CPU limit is absent. With no CFS quota, startup borrows idle cycles from the node and gives them back a minute later. The request still matters: it's what Karpenter and the scheduler use to place the pod, and it's what HPA divides utilization by. Size it to steady-state p95, not the burst.

The one place this breaks is a rollout that lands many spiky pods on the same fresh node at once. With no idle CPU to borrow, startup runs slow through contention rather than throttling. Bound maxSurge so new pods arrive in waves rather than all at once, and consider a topologySpreadConstraint on kubernetes.io/hostname so a rollout's new pods don't stack on one node. A startupProbe with a failureThreshold sized to the slow case keeps the kubelet from restarting a pod that's merely booting under load.

Fix 5: Let the recommender see two phases, and let the pod change size

Everything above stops the policy from forcing the wrong number. It doesn't produce the right one. For that the recommender has to know that startup and steady state are different, and the pod has to be able to move between them.

The Vertical Pod Autoscaler on its default recommender builds a decaying histogram over roughly eight days of samples. A 45-second burst is noise to it, so it recommends the plateau, and the next rollout throttles. You can constrain it with minAllowed so it never drops below a floor that survives boot, but that just re-encodes the startup number as a permanent reservation through a different door.

In-place pod resize changes the shape of the problem. It went stable in Kubernetes 1.35 and is on by default in 1.36, and it lets a running container's requests and limits change without a restart. The pattern is: start the pod with enough CPU and memory to boot cleanly, then resize it down to steady-state values once it reports ready. GKE's startup CPU boost does a version of this for Autopilot. The pod spec just needs to declare that a CPU change shouldn't trigger a restart:

containers:
- name: app
resizePolicy:
- resourceName: cpu
restartPolicy: NotRequired
- resourceName: memory
restartPolicy: NotRequired # or RestartContainer if the runtime can't shrink heap live
resources:
requests:
cpu: "2" # startup value; resized to ~200m after readiness
memory: 1Gi
limits:
memory: 1Gi

Four replicas booting in waves at a 2-core CPU request, each resized in place to 200m once readiness passes; namespace requests.cpu consumed rises and falls with each pod and settles at 0.8 cores instead of holding 8 cores at the startup peak

This is where the quota work from Fix 1 pays off. A resize is checked against ResourceQuota like any other request change, so when the pod shrinks from 2 cores to 200m the namespace gets 1.8 cores of quota back immediately. Forty replicas boot in waves, each one holding its startup reservation for about a minute, and the namespace's steady-state footprint settles at 8 cores instead of 80. Pods stop sitting Pending behind reservations that describe a phase nobody is in.

One constraint to design around: a resize can't change a pod's QoS class. A pod that started Burstable stays Burstable, and a Guaranteed pod has to keep requests equal to limits through every resize. That's another reason not to mandate Guaranteed by policy for workloads that don't need it.

A policy rewrite checklist

  1. Inventory the layers. For each namespace: kubectl get resourcequota,limitrange -o yaml, plus every Kyverno/Gatekeeper/VAP rule that touches resources. Write down what each one forces.
  2. Find the phase each layer assumes. Any limits.cpu in a quota, any max.cpu or low maxLimitRequestRatio.cpu in a LimitRange, any "must set CPU limit" admission rule is a steady-state policy. Any defaultRequest above a few hundred millicores, or any requests-equal-limits mandate, is a startup policy.
  3. Measure the gap. Run the requests-to-usage ratio query per namespace. Anything above 3 to 4 is quota consumed by the wrong phase.
  4. Move quota to requests. Drop limits.cpu from ResourceQuota; keep requests.* and limits.memory.
  5. Open the LimitRange. Remove default.cpu, raise or remove max.cpu, apply maxLimitRequestRatio to memory only.
  6. Rewrite admission. Require CPU and memory requests and a memory limit. Stop requiring a CPU limit.
  7. Re-size the top ten restarters. Memory limit to startup peak, memory and CPU requests to steady-state p95, no CPU limit. Add maxSurge and spread constraints if rollouts contend.
  8. Enable two-phase sizing where the cluster supports it. On 1.33+ with in-place resize available, declare resizePolicy and let a controller shrink pods after readiness.
  9. Re-run step 3 a week later. The ratio should fall toward 1.5 to 2 and the young-container throttling rate should fall toward zero at the same time. If only one moved, you fixed one resource and not the other.

FAQ

What is a Kubernetes resource quota? A ResourceQuota is a namespace-scoped object that caps the total resources all pods in that namespace can request or be limited to, along with object counts like pods, services, and PVCs. It's enforced at admission: a pod whose requests or limits would push the namespace over its hard values is rejected. Quotas count the numbers in the pod spec, not actual usage, which is why a quota can be exhausted while the nodes behind it sit idle.

Should a ResourceQuota cap limits or requests? Cap requests.cpu and requests.memory, because requests are what the scheduler reserves and what you pay for in node capacity. Cap limits.memory too, since it's a real safety bound against a namespace consuming all node memory. Avoid limits.cpu in quotas: it pressures teams to shrink CPU limits to fit, which throttles pods during startup without protecting the node.

What's the difference between ResourceQuota and LimitRange? ResourceQuota sets a namespace-wide total. LimitRange sets per-container (or per-pod, or per-PVC) defaults, minimums, maximums, and the allowed ratio between limit and request. Quota answers "how much can this team use in total"; LimitRange answers "what does one container get if it doesn't say, and what's the biggest it can ask for." Both are enforced at admission.

Do in-place pod resizes count against ResourceQuota? Yes. A resize that raises requests is validated against the namespace quota and rejected if it would exceed it. A resize that lowers requests returns the difference to the quota immediately. That's what lets a startup-then-shrink pattern keep a namespace's steady-state quota footprint near its actual usage.

Why do my pods get throttled at startup even though the node has idle CPU? Because a CPU limit is enforced by the kernel's CFS quota per container, independent of node load. If the limit is 500m, the container gets 50ms of every 100ms period regardless of how idle the node is. Startup work like JIT compilation or module loading exhausts that quota early in each period and waits. Removing the CPU limit, or raising maxLimitRequestRatio so the limit can sit well above the request, lets startup use the idle capacity.

Policies that match how containers actually behave

Everything above can be done by hand: rewrite the quota, open the LimitRange, update the admission policy, re-size the noisiest workloads, repeat when the code changes. The hard part is the last clause. Startup profiles shift with every dependency bump and every JVM flag change, and a policy you tuned correctly in March is wrong again by June.

PerfectScale by DoiT profiles every container at the workload level, so startup and steady-state behavior show up as separate patterns rather than one blended average, and its recommendations size memory to clear the boot peak while keeping requests at the plateau. For Java workloads it detects JVM containers automatically once the Coroot agent is enabled, tracks heap, non-heap, and GC over time, flags containers running without explicit heap settings, and respects -Xms and -Xmx when it recommends or applies a change. On clusters running Kubernetes 1.33 or later, its automation applies right-sizing in place without restarting the pod, and falls back to a rolling restart only when a resize isn't feasible on the current node, so the namespace quota reflects what the workloads are using now rather than what they needed for 45 seconds after the last deploy.

Size Java pods for steady state: live workshop on the JVM startup spike and in-place pod resize

Want to see a pod boot at 2 cores and settle at 200m with no restart and no quota left stranded? Join the JVM startup spike workshop, or book a technical session and we'll look at the quota and LimitRange policies in your own clusters.