LimitRange and ResourceQuota: Namespace Guardrails That Actually Work
Two years ago, on the nineteenth of the month, our cloud bill arrived 40% higher than the previous one, and nobody could say why.
The postmortem took an afternoon. A data science intern -- smart kid, no malice, genuinely impressive initiative -- had deployed a Jupyter notebook to the shared sandbox namespace with no resource requests, no limits, and a GPU-hungry training loop, on a cluster where the autoscaler happily obliged by adding nodes. Nine nodes. For three weeks. The namespace had no guardrails because "it's just sandbox." That sentence cost us about eleven thousand dollars.
The fix took twenty minutes and two YAML files. This post is those two YAML files, plus the ways they bite you if you don't read the fine print.
Quick answer: LimitRange sets per-container defaults and bounds inside a namespace: it fills in missing requests and limits at admission and rejects containers outside your min/max. ResourceQuota caps the namespace total: summed CPU, memory, pod count, object counts, storage. Deploy them together -- LimitRange supplies defaults so that ResourceQuota's "must specify requests" requirement doesn't reject your workloads, and quota keeps any single team from eating the cluster. Both apply at admission only, so existing pods are unaffected until recreated.
LimitRange: per-container sanity
A LimitRange does two jobs. It defaults resources into containers that don't specify them, and it validates that containers stay within bounds:
apiVersion: v1
kind: LimitRange
metadata:
name: container-bounds
namespace: sandbox
spec:
limits:
- type: Container
default: # injected as limits if unset
cpu: 500m
memory: 512Mi
defaultRequest: # injected as requests if unset
cpu: 100m
memory: 128Mi
max:
cpu: "2"
memory: 2Gi
min:
cpu: 50m
memory: 64Mi
Now a pod submitted with no resources at all lands in the namespace as a 100m/128Mi request, 500m/512Mi limit container -- Burstable, predictable, and incapable of eating a node. A pod asking for 8 cores gets rejected at admission with Forbidden: maximum cpu usage per Container is 2.
Two subtleties. First, the default values only apply to fields the pod *didn't* set -- a pod with requests but no limits gets the default limits, keeping its requests. Second, there's a type: Pod variant bounding the sum across all containers, and a type: PersistentVolumeClaim variant bounding storage requests. The PVC one is criminally underused; max: storage: 100Gi has saved more than one team from a 2Ti "temporary" volume.
ResourceQuota: the namespace ceiling
Where LimitRange shapes each container, ResourceQuota shapes the whole namespace:
apiVersion: v1
kind: ResourceQuota
metadata:
name: sandbox-budget
namespace: sandbox
spec:
hard:
requests.cpu: "8"
requests.memory: 16Gi
limits.cpu: "16"
limits.memory: 32Gi
pods: "50"
services.loadbalancers: "2"
persistentvolumeclaims: "10"
Watch it work:
$ kubectl describe resourcequota sandbox-budget -n sandbox
Name: sandbox-budget
Resource Used Hard
-------- ---- ----
limits.cpu 12 16
limits.memory 24Gi 32Gi
persistentvolumeclaims 3 10
pods 41 50
requests.cpu 6 8
requests.memory 12Gi 16Gi
services.loadbalancers 1 2
That services.loadbalancers: "2" line is worth the entire post. Every type: LoadBalancer Service provisions a real cloud load balancer at $20-50/month plus traffic. Capping it to a sane number turns a class of billing surprises into a clean admission error.
The moment you create a quota tracking compute resources, the admission rule kicks in: every new pod must explicitly declare the tracked values. Pods without requests get rejected: Forbidden: failed quota: must specify requests.cpu. This breaks every sloppy deployment in the namespace at once, which is why you ship the LimitRange first -- its defaults satisfy the requirement before the quota check runs.
The gotchas that get you in production
Admission-time only. Neither object touches running pods. Change a LimitRange and nothing happens until pods are recreated. This is a feature (no surprise restarts) that feels like a bug (your "enforced" policy has a fleet of grandfathered violations). After rolling out guardrails, bounce the workloads you actually care about.
Quota blocks creation, not usage. A namespace at its pod cap still runs everything it has; new replicas just fail. The failure surfaces as events on the ReplicaSet (Error creating: pods "x" is forbidden: exceeded quota), not as a crash, so HPAs stall silently at the cap. Alert on quota utilization at 80%, or learn about it from the on-call graph like the rest of us did.
Scopes and selector quotas exist. You can scope a quota to certain priority classes or, with scopeSelector, to pods matching expressions -- the classic use is giving BestEffort pods a separate tiny budget. Most teams never need it; the ones who do, really do.
The defaults are sticky and surprising. A LimitRange default of cpu: 500m becomes a *limit* on every container that didn't ask for one, including your bursty batch jobs that now throttle. Defaults should be modest and requests-flavored; if you default a tight CPU limit, you've throttled the entire namespace by accident. I keep default limits generous and defaultRequest honest, and I check the result by watching throttling metrics per namespace after rollout -- a live view of usage against limits (part of why we built conndeck) makes a mis-sized default obvious in minutes instead of months.
Deletion is not blocked by quota. Quotas count live objects. kubectl delete always works. When a namespace is wedged at capacity and somebody's deployment can't roll, deleting the failed replica pods is often the fastest unblock.
Frequently asked questions
What is the difference between LimitRange and ResourceQuota?
LimitRange constrains individual pods and containers: it injects default requests and limits, and enforces per-container minimums and maximums at admission. ResourceQuota constrains the namespace as a whole: total CPU, memory, pod count, object counts, and storage summed across everything in it. You use LimitRange to stop any single container from being absurd and ResourceQuota to stop a team from consuming the cluster.
Why do pods fail with must specify limits or requests after I add a quota?
A ResourceQuota that tracks cpu, memory, limits.cpu, or requests.memory requires every new pod to declare those values explicitly. Pods created by deployments with no resource fields get rejected at admission. The standard fix is a LimitRange with default and defaultRequest in the same namespace, which fills in the missing values before the quota check runs.
Do LimitRange defaults apply to existing pods?
No. LimitRange is an admission-time mutation and validation, so it only affects pods as they are created. Existing pods keep whatever they have until their controller recreates them, which for a Deployment means the next rollout. If you change a LimitRange, restart or roll the workloads you want it to affect.
Can ResourceQuota limit the number of pods or load balancers?
Yes. Quotas can cap object counts like pods, services, configmaps, persistentvolumeclaims, and load balancer services, as well as summed compute resources. Capping services.loadbalancers to a small number is a cheap way to stop accidental cloud spend, since each one provisions a real external load balancer with an hourly price.
What happens when a namespace hits its ResourceQuota?
New pods that would exceed the quota are rejected at creation with a forbidden exceeded quota error, and replicaset or deployment controllers surface it as events on the workload. Existing pods keep running; quota blocks creation, not execution. If the quota counts pods and the namespace is full, autoscaling stalls silently at the cap, so alert on quota usage, not just node usage.
Where this bites in production
LimitRange and ResourceQuota are the cheapest governance you'll ever deploy: two objects per namespace, no operators, no admission webhook infrastructure. LimitRange shapes each container, quota caps the total, and together they turn "please be careful" into an API-enforced fact.
Ship the LimitRange first so defaults satisfy the quota, keep the defaults modest, alert on quota utilization, and never again have a namespace called sandbox with unlimited access to your credit card.