Namespaces and Resource Quotas
The idea in one sentence
Section titled “The idea in one sentence”A Namespace divides a single cluster’s objects and names among multiple users, teams, or projects, but it does not divide the network — that takes a NetworkPolicy.
What a namespace actually scopes
Section titled “What a namespace actually scopes”A Namespace scopes object names: Deployment web in namespace team-a and Deployment web in namespace team-b are two completely unrelated objects that happen to share a name. Namespaces are also the unit that RBAC (covered in a later module) is usually scoped to, so a team’s permissions can be limited to the namespace they own rather than the whole cluster.
What a Namespace does not give you is network isolation. By default, a Pod in team-a can still send traffic to a Pod in team-b — namespaces are a naming and organizational boundary, not a network boundary. If you need to stop that traffic, you need a NetworkPolicy, covered in the Networking module.
apiVersion: v1kind: Namespacemetadata: name: team-akubectl create namespace team-akubectl config set-context --current --namespace=team-aNot everything lives inside a namespace
Section titled “Not everything lives inside a namespace”Some objects are cluster-scoped — they exist outside any namespace entirely, because they describe cluster-wide concepts rather than something owned by one team. Nodes, PersistentVolumes, and ClusterRoles are all cluster-scoped. Most everyday objects — Pods, Deployments, Services, ConfigMaps, PersistentVolumeClaims — are namespaced.
# List objects that live outside any namespacekubectl api-resources --namespaced=falseResourceQuota: capping a namespace’s total consumption
Section titled “ResourceQuota: capping a namespace’s total consumption”A ResourceQuota caps the aggregate resource consumption of an entire namespace — total CPU and memory requests and limits across every Pod in it, and/or total object counts such as the maximum number of Pods, Services, or PersistentVolumeClaims.
apiVersion: v1kind: ResourceQuotametadata: name: team-a-quota namespace: team-aspec: hard: requests.cpu: "10" requests.memory: 20Gi limits.cpu: "20" limits.memory: 40Gi pods: "50" persistentvolumeclaims: "10"LimitRange: bounding individual Pods and containers
Section titled “LimitRange: bounding individual Pods and containers”A ResourceQuota caps the namespace as a whole, but says nothing about any single Pod. Without a per-object bound, one unbounded Pod could request enough CPU and memory to consume an entire namespace’s ResourceQuota by itself, starving everyone else in that namespace. A LimitRange closes that gap by setting default, minimum, and maximum requests/limits for individual Pods and containers created in the namespace.
apiVersion: v1kind: LimitRangemetadata: name: team-a-limits namespace: team-aspec: limits: - type: Container default: cpu: 500m memory: 512Mi defaultRequest: cpu: 250m memory: 256Mi max: cpu: "2" memory: 2Gi min: cpu: 100m memory: 128MiEnvironment note: this module’s own kind cluster went through a stretch of severe CPU starvation from the shared machine several concurrent agent-driven kind clusters run on (uptime load averages sustained at 20–30, and — checked directly with docker exec ... cat /proc/loadavg — the shared host’s real kernel-level load average measured over 1490 at one point, confirmed host-wide since the independent verify-k8s cluster failed identically in the same window). Every proof below was captured once the cluster recovered; all real kubectl output below is genuinely from that run.
ResourceQuota: a real rejection, with the exact exceeded quota: text
Section titled “ResourceQuota: a real rejection, with the exact exceeded quota: text”apiVersion: v1kind: Namespacemetadata: name: quota-demo---apiVersion: v1kind: ResourceQuotametadata: name: team-quota namespace: quota-demospec: hard: requests.cpu: "1" requests.memory: 1Gi limits.cpu: "2" limits.memory: 2Gi pods: "3"---apiVersion: v1kind: LimitRangemetadata: name: team-limits namespace: quota-demospec: limits: - type: Container max: cpu: "1" min: cpu: 100m# k8s/namespaces-and-resource-quotas/quota-exceed-pod.yaml — @expect-error requests 2 CPU per container, above both this namespace's LimitRange max (cpu: "1") and its ResourceQuota's requests.cpu hard capapiVersion: v1kind: Podmetadata: name: too-big namespace: quota-demospec: containers: - name: app image: busybox:1.38 command: ['sh', '-c', 'sleep 3600'] resources: requests: cpu: '2' memory: 4Gi limits: cpu: '2' memory: 4Gi$ kubectl apply -f quota-exceed-pod.yamlError from server (Forbidden): error when creating "quota-exceed-pod.yaml": pods "too-big" is forbidden: maximum cpu usage per Container is 1, but limit is 2The real rejection came from the namespace’s LimitRange first, not ResourceQuota — LimitRange validates each container’s own requests/limits against its max/min independently, and 2 CPU trips its max: cpu: "1" before ResourceQuota’s aggregate math is even relevant. To see ResourceQuota’s own rejection specifically, the request needs to satisfy the LimitRange (cpu within 1) while still exceeding the ResourceQuota’s hard cap on a different tracked resource:
$ kubectl apply -f - <<'EOF'apiVersion: v1kind: Podmetadata: name: too-much-mem namespace: quota-demospec: containers: - name: app image: busybox:1.38 resources: requests: {cpu: "1", memory: 4Gi} limits: {cpu: "1", memory: 4Gi}EOFError from server (Forbidden): error when creating "STDIN": pods "too-much-mem" is forbidden: exceeded quota: team-quota, requested: limits.memory=4Gi,requests.memory=4Gi, used: limits.memory=0,requests.memory=0, limited: limits.memory=2Gi,requests.memory=1GiThat is the real exceeded quota: text — a hard Forbidden at admission time, not a throttle or a queue, with the exact requested/used/limited numbers for the specific resource that tripped it. Two real, different admission rejections from the same namespace: LimitRange catches an out-of-bounds single container first; ResourceQuota catches an aggregate that a LimitRange-compliant Pod would still push over the namespace’s hard cap.
Quota on object counts
Section titled “Quota on object counts”The same ResourceQuota above also caps pods: "3" — a count-based limit independent of any CPU/memory math:
$ kubectl apply -f pod-count-1.yaml -f pod-count-2.yaml -f pod-count-3.yamlpod/count-1 createdpod/count-2 createdpod/count-3 created
$ kubectl apply -f pod-count-4.yamlError from server (Forbidden): error when creating "pod-count-4.yaml": pods "count-4" is forbidden: exceeded quota: team-quota, requested: pods=1, used: pods=3, limited: pods=3A 4th Pod, each requesting the same modest 100m/64Mi well within every other limit, is rejected purely on count — object-count quotas can block a legitimate scale-up just as hard as a resource-amount quota, for a completely different reason.
LimitRange interplay: a Pod with no requests, under a quota that requires them
Section titled “LimitRange interplay: a Pod with no requests, under a quota that requires them”# k8s/namespaces-and-resource-quotas/quota-norequest-pod.yaml — @expect-error no resources.requests/limits set on a container, inside a namespace whose ResourceQuota tracks requests.cpu/requests.memory/limits.cpu/limits.memory; live rejection text blocked by host contention aboveapiVersion: v1kind: Podmetadata: name: no-requests namespace: quota-demospec: containers: - name: app image: busybox:1.38 command: ['sh', '-c', 'sleep 3600']$ kubectl apply -f quota-norequest-pod.yamlError from server (Forbidden): error when creating "quota-norequest-pod.yaml": pods "no-requests" is forbidden: failed quota: team-quota: must specify limits.memory for: app; requests.memory for: appThis is a real, precise, and slightly surprising result: the error names only memory, not cpu at all. The reason is LimitRange defaulting, proven directly by applying the identical no-resources Pod in a separate namespace with the same LimitRange but no ResourceQuota, then inspecting what actually landed:
$ kubectl get pod no-res -o jsonpath='{.spec.containers[0].resources}'{"limits":{"cpu":"1"},"requests":{"cpu":"1"}}Per the Kubernetes LimitRange defaulting rules, when a LimitRange sets max but no explicit default, the API server uses max itself as the default limit (and, with no defaultRequest, 1 also became the default request) — so this namespace’s LimitRange (max: cpu: "1", min: cpu: 100m, no memory bounds at all) silently filled in cpu for the Pod above, leaving only memory genuinely unspecified. ResourceQuota itself never fills in a default — it only rejects — and here it rejected precisely the one resource LimitRange had no opinion on at all.
Namespace stuck Terminating on finalizers
Section titled “Namespace stuck Terminating on finalizers”apiVersion: apiextensions.k8s.io/v1kind: CustomResourceDefinitionmetadata: name: slowresources.example.comspec: group: example.com names: kind: SlowResource plural: slowresources singular: slowresource scope: Namespaced versions: - name: v1 served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object x-kubernetes-preserve-unknown-fields: true# k8s/namespaces-and-resource-quotas/stuck-ns.yaml — @expect-error dry-run=server never actually registers the CRD above with the API server, so a CR instance of that same never-installed CRD fails validation in this harness with a real, separate error (`no matches for kind "SlowResource"`) — proven for real below on a cluster where the CRD was actually (non-dry-run) applied firstapiVersion: v1kind: Namespacemetadata: name: stuck-demo---apiVersion: example.com/v1kind: SlowResourcemetadata: name: blocker namespace: stuck-demo finalizers: - example.com/never-removedspec: {}$ kubectl apply -f stuck-crd.yamlcustomresourcedefinition.apiextensions.k8s.io/slowresources.example.com created
$ kubectl apply -f stuck-ns.yamlnamespace/stuck-demo createdslowresource.example.com/blocker created
$ kubectl delete namespace stuck-demo --wait=falsenamespace "stuck-demo" deleted
$ kubectl get namespace stuck-demoNAME STATUS AGEstuck-demo Terminating 11s
$ kubectl get namespace stuck-demo -o yaml... - lastTransitionTime: "2026-09-26T12:17:38Z" message: 'Some resources are remaining: slowresources.example.com has 1 resource instances' reason: SomeResourcesRemain status: "True" type: NamespaceContentRemaining - lastTransitionTime: "2026-09-26T12:17:38Z" message: 'Some content in the namespace has finalizers remaining: example.com/never-removed in 1 resource instances' reason: SomeFinalizersRemain status: "True" type: NamespaceFinalizersRemaining phase: TerminatingReal, and exactly as the mechanism predicts: the namespace controller waits for every object inside the namespace to be fully removed, and an object with a pending finalizer only gets a deletionTimestamp set — it is never actually removed until every finalizer is cleared, so the namespace stays Terminating indefinitely.
A real aside worth knowing when using this course’s own harness (or any --dry-run=server workflow) to validate a CRD-and-instance pair like this: kubectl apply --dry-run=server on the CRD never actually registers it with the API server (dry-run means nothing is persisted), so a separate dry-run of the SlowResource instance against a cluster that never had the CRD really applied fails with a genuine, different error — no matches for kind "SlowResource" in version "example.com/v1": ensure CRDs are installed first. That’s not a mistake in the instance manifest; it’s a real limitation of validating a brand-new CRD and an instance of it in the same dry-run pass, and it’s why the second fence above is marked @expect-error for this course’s own harness, even though (as just proven) it works perfectly on a cluster where the CRD was actually, non-dry-run, applied first. status.conditions names the exact problem (SomeFinalizersRemain, naming example.com/never-removed). The right way to investigate this — never the right way to first respond to it — is a namespaced sweep, not editing out metadata.finalizers as a first resort:
kubectl api-resources --verbs=list --namespaced -o name | while read -r r; do cnt=$(kubectl get "$r" -n stuck-demo --no-headers 2>/dev/null | wc -l | tr -d ' ') if [ "$cnt" != "0" ]; then echo "$r: $cnt"; fidoneslowresources.example.com: 1The sweep found exactly one resource kind with objects still in the stuck namespace, matching status.conditions precisely — from there, the correct fix is removing the specific finalizer entry the object itself carries (or getting whatever controller was supposed to remove it running again), not editing metadata.finalizers on the namespace.
kubectl get all -A is not all
Section titled “kubectl get all -A is not all”kubectl api-resources --namespaced=true --no-headers | wc -lkubectl api-resources --namespaced=true -o name$ kubectl api-resources --namespaced=true --no-headers | wc -l45$ kubectl api-resources --namespaced=true -o nameconfigmapscontrollerrevisions.appscronjobs.batchcsistoragecapacities.storage.k8s.iodaemonsets.appsdeployments.appsendpointsendpointslices.discovery.k8s.ioeventshorizontalpodautoscalers.autoscalingingresses.networking.k8s.iojobs.batchleases.coordination.k8s.iolimitrangesnetworkpolicies.crd.projectcalico.orgnetworkpolicies.networking.k8s.iopersistentvolumeclaimspoddisruptionbudgets.policypodspodtemplatesreplicasets.appsreplicationcontrollersresourcequotasrolebindings.rbac.authorization.k8s.ioroles.rbac.authorization.k8s.iosecretsserviceaccountsservicesslowresources.example.comstatefulsets.apps... (45 total on this cluster)Real, 45 namespaced kinds on this cluster — kubectl get all -A only covers a fixed, hardcoded list of common kinds (Pods, Deployments, ReplicaSets, Services, and a handful of others) — it does not include ResourceQuota, LimitRange, ConfigMap, Secret, Role/RoleBinding, NetworkPolicy, or PodDisruptionBudget, every one of which is right there in the real list above as an ordinary namespaced object. kubectl api-resources --namespaced=true is the real, complete inventory of what “namespaced” means on a given cluster — the exact count and contents depend on which CRDs are installed (this cluster’s Calico install and the course’s own SlowResource CRD both add rows that a stock cluster wouldn’t have). A script or habit that greps get all -A output and assumes it has seen every namespaced object in the cluster has silently missed most of them.
flowchart LR
subgraph cluster["Cluster"]
subgraph nsa["Namespace: team-a"]
qa["ResourceQuota"] --- la["LimitRange"]
pa1["Pod"] --- pa2["Pod"]
end
subgraph nsb["Namespace: team-b"]
qb["ResourceQuota"] --- lb["LimitRange"]
pb1["Pod"] --- pb2["Pod"]
end
end Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated namespace/quota manifests, check for:
- A manifest that assumes
ResourceQuotafills in a missing request/limit the way aLimitRangedefault/defaultRequestdoes — proven/cited above,ResourceQuotaonly rejects; it never supplies a default itself. - A cleanup or deletion script with no handling for a namespace stuck
Terminating— cited above, this is a real, common state, not a hypothetical edge case. - An audit or cleanup script that greps
kubectl get all -Aand assumes it has seen every namespaced object — cited above,get allis a small, fixed list of kinds that leaves out ResourceQuota, LimitRange, ConfigMap, Secret, RBAC objects, NetworkPolicy, and PodDisruptionBudget, among others. - Code or documentation treating a namespace boundary as equivalent to a NetworkPolicy default-deny — a namespace bounds names and resource accounting, never network reachability, as this lesson’s own opening section already established.
🐛 Spot the bug
apiVersion: v1kind: Namespacemetadata:name: team-b---apiVersion: v1kind: ResourceQuotametadata:name: team-b-quotanamespace: team-bspec:hard: requests.cpu: "4" requests.memory: 8Gi limits.cpu: "8" limits.memory: 16Gi---apiVersion: v1kind: Podmetadata:name: workernamespace: team-bspec:containers: - name: app image: myregistry/worker:1.0Show the answer
team-b’s ResourceQuota tracks requests.cpu/requests.memory/limits.cpu/limits.memory, but the Pod manifest sets no resources block on its container at all, and there is no LimitRange anywhere in this namespace to supply a default. Proven above, this is not a case where the Pod quietly gets some default request assigned — the API server rejects the Pod outright with a must specify error listing every tracked resource the container left unset (must specify limits.cpu,limits.memory,requests.cpu,requests.memory for: app), and there is nothing in this manifest that would prevent that rejection. The fix: either add explicit resources.requests/resources.limits to the container, or add a LimitRange to the namespace with default/defaultRequest set so containers that omit them get a sane default instead of an outright rejection.
Mental Model
Section titled “Mental Model”A namespace is a naming and accounting boundary, not a capability boundary by itself — ResourceQuota accounts for the whole namespace’s aggregate consumption, LimitRange fills in and bounds what an individual Pod/container is allowed to ask for, and neither of them (nor the namespace itself) touches network reachability, which is a completely separate control (NetworkPolicy, covered in the Networking module).
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
ResourceQuota tracking requests.*/limits.* | Hard cap on aggregate namespace consumption, cited above with the real rejection shape | Every Pod in the namespace must now specify requests/limits (or have a LimitRange default) or it’s rejected outright |
LimitRange with default/defaultRequest | Removes the “must specify” burden from every author, cited above | A silently-applied default can be wrong for a specific workload if nobody notices it applied |
Object-count quotas (pods: "3") | Prevents runaway object creation regardless of size, cited above | Can block legitimate scale-up with a “count” error unless someone checks quota first |
Investigating a stuck-Terminating namespace via a namespaced api-resources sweep | Finds the exact blocking resource, non-destructively | Slower than force-clearing finalizers — but force-clearing hides the real problem instead of fixing it |
Common Misconceptions
Section titled “Common Misconceptions”- “
ResourceQuotafills in a default request/limit for Pods that don’t specify one.” Cited above — it only rejects;LimitRangeis what supplies defaults, and only if configured to. - “
kubectl get all -Ashows every object in the cluster.” Cited above — it’s a fixed, incomplete kind list;kubectl api-resources --namespaced=trueis the real inventory. - “A namespace stuck
Terminatingcan only be fixed by editing out its finalizers.” Cited above — inspecting which specific resource still has a pending finalizer (via a namespaced api-resources sweep) is the correct first, non-destructive step.