A shared OpenShift cluster behaves like a shared bank account: it works perfectly until someone forgets to tell the others they spent everything. One team ships a Deployment with replicas: 30 and no resource requests, the scheduler happily packs it onto every worker node, and by Monday morning three other teams are staring at 0/12 nodes are available: insufficient memory.
Resource governance is not about saying "no" to developers. It is about making the cluster's capacity explicit, bounded and auditable per tenant, so that one namespace's bad day never becomes everyone's incident. This article walks through the three layers OpenShift gives you — LimitRange, ResourceQuota, and the OpenShift-specific ClusterResourceQuota — and then wires them into a self-applying project template so that every new project is born governed, with no ticket and no manual step.
The problem: why namespaces alone are not isolation
A namespace (or OpenShift project) is an RBAC and naming boundary. It is not a capacity boundary. By default:
- A pod without
resources.requestsis scheduled as BestEffort. It competes for CPU with everything else and is the first candidate for eviction under memory pressure — including pods belonging to other teams on the same node. - A pod without
resources.limitscan consume all the CPU on a node, and OOM-kill its neighbours by exhausting node memory. - Nothing stops a team from creating 5,000 ConfigMaps, 200 Services of type LoadBalancer, or 40 TiB of PVCs.
The consequences are concrete: degraded latency for unrelated workloads, etcd bloat, surprise cloud bills for load balancers, and a cluster autoscaler that scales to the moon because a broken CronJob keeps spawning pods.
The fix is a layered model:
| Layer | Object | Scope | Answers |
|---|---|---|---|
| 1 | LimitRange |
Namespace | "What does a single pod/container look like?" |
| 2 | ResourceQuota |
Namespace | "How much can this namespace consume in total?" |
| 3 | ClusterResourceQuota |
Multiple namespaces | "How much can this team consume across all its projects?" |
Each layer is necessary. LimitRange without quota caps nothing globally. Quota without LimitRange breaks every pod that forgets to set requests.
Layer 1: LimitRange, the per-object guardrail
LimitRange does two jobs: it injects defaults into pods that do not declare resources, and it rejects objects outside a min/max envelope at admission time.
apiVersion: v1
kind: LimitRange
metadata:
name: tenant-defaults
namespace: team-payments-dev
spec:
limits:
- type: Container
default: # becomes spec.containers[].resources.limits
cpu: 500m
memory: 512Mi
defaultRequest: # becomes spec.containers[].resources.requests
cpu: 50m
memory: 128Mi
min:
cpu: 10m
memory: 32Mi
max:
cpu: "4"
memory: 8Gi
maxLimitRequestRatio: # blocks 10m request / 4 CPU limit overcommit abuse
cpu: "20"
memory: "4"
- type: Pod
max:
cpu: "8"
memory: 16Gi
- type: PersistentVolumeClaim
min:
storage: 1Gi
max:
storage: 200Gi
Two OpenShift-specific types are worth adding on clusters that use the internal registry heavily:
- type: openshift.io/Image
max:
storage: 2Gi # rejects pushing an image layer set above 2Gi
- type: openshift.io/ImageStream
max:
openshift.io/image-tags: "30"
openshift.io/images: "60"
Verify what a tenant actually gets:
oc describe limitrange tenant-defaults -n team-payments-dev
# Prove the defaulting works
oc run nginx --image=registry.access.redhat.com/ubi9/nginx-124 -n team-payments-dev
oc get pod nginx -n team-payments-dev -o jsonpath='{.spec.containers[0].resources}{"\n"}'
# {"limits":{"cpu":"500m","memory":"512Mi"},"requests":{"cpu":"50m","memory":"128Mi"}}
maxLimitRequestRatiois the under-used knob. It is what prevents a team from requesting 10m CPU to dodge the quota while setting a 4-core limit and then saturating a node.
Layer 2: ResourceQuota, the namespace budget
ResourceQuota caps aggregate consumption. Split it into two objects — compute and objects — so that a developer hitting the Service limit gets a readable error instead of a wall of text.
apiVersion: v1
kind: ResourceQuota
metadata:
name: compute-quota
namespace: team-payments-dev
spec:
hard:
requests.cpu: "8"
requests.memory: 16Gi
limits.cpu: "16"
limits.memory: 32Gi
pods: "60"
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: object-quota
namespace: team-payments-dev
spec:
hard:
persistentvolumeclaims: "20"
requests.storage: 500Gi
gold.storageclass.storage.k8s.io/requests.storage: 100Gi
gold.storageclass.storage.k8s.io/persistentvolumeclaims: "5"
services: "30"
services.loadbalancers: "0"
services.nodeports: "0"
secrets: "60"
configmaps: "60"
count/deployments.apps: "40"
count/cronjobs.batch: "10"
openshift.io/imagestreams: "25"
Setting services.loadbalancers: "0" and services.nodeports: "0" is a cheap way to force every tenant through Routes/Ingress instead of opening cloud load balancers at will.
Scopes: treat batch and interactive workloads differently
Quota scopes let you carve the budget. A classic pattern is to reserve headroom for long-running services by bounding what short-lived jobs can take:
apiVersion: v1
kind: ResourceQuota
metadata:
name: batch-quota
namespace: team-payments-dev
spec:
scopes:
- Terminating # pods with activeDeadlineSeconds set
hard:
pods: "20"
requests.cpu: "4"
requests.memory: 8Gi
And with scopeSelector, you can stop tenants from grabbing high-priority scheduling slots:
apiVersion: v1
kind: ResourceQuota
metadata:
name: no-high-priority
namespace: team-payments-dev
spec:
scopeSelector:
matchExpressions:
- operator: In
scopeName: PriorityClass
values: ["system-cluster-critical", "system-node-critical"]
hard:
pods: "0"
The gotchas that will page you
-
Quota on
requests.*makes requests mandatory. Any pod without them is rejected withmust specify requests.cpu. This is exactly why the LimitRange must be created before or with the quota. -
Quota rejections surface on the controller, not the pod. A blocked Deployment shows nothing in
oc get pods. Look at the ReplicaSet:oc describe rs -n team-payments-dev | grep -A5 Events. - Lowering a quota never evicts running pods. It only blocks new creations until usage drops below the new ceiling.
-
count/<resource>.<group>works for any resource, including CRDs — useful to capcount/virtualmachines.kubevirt.ioorcount/routes.route.openshift.io.
Layer 3: ClusterResourceQuota, the team budget across projects
Real teams own several projects: team-payments-dev, -staging, -sandbox. Per-namespace quotas let a team multiply its footprint simply by creating more projects. OpenShift's ClusterResourceQuota (CRQ) is a cluster-scoped object that applies a single shared budget across all namespaces matching a selector.
apiVersion: quota.openshift.io/v1
kind: ClusterResourceQuota
metadata:
name: crq-team-payments
spec:
selector:
labels:
matchLabels:
tenant: payments
quota:
hard:
requests.cpu: "40"
requests.memory: 80Gi
limits.cpu: "80"
limits.memory: 160Gi
pods: "300"
persistentvolumeclaims: "60"
requests.storage: 2Ti
The selector can also match project annotations, which is how you bind a quota to everything a given user self-provisions:
oc create clusterresourcequota crq-user-alice \
--project-annotation-selector=openshift.io/requester=alice \
--hard=pods=50 \
--hard=requests.cpu=10 \
--hard=requests.memory=20Gi
If you specify both labels and annotations in spec.selector, a project must match both to be included. Start with labels — they are easier to drive from GitOps.
AppliedClusterResourceQuota: what the tenant sees
A developer with admin on their project cannot read cluster-scoped objects. OpenShift exposes a namespaced, read-only projection for exactly this: AppliedClusterResourceQuota.
# As the tenant, inside one of their projects
oc get appliedclusterresourcequota -n team-payments-dev
oc describe appliedclusterresourcequota crq-team-payments -n team-payments-dev
# Name: crq-team-payments
# Namespace: team-payments-dev
# Resource Used Hard
# -------- ---- ----
# pods 187 300
# requests.cpu 26200m 40
# requests.memory 52Gi 80Gi
Put that command in your onboarding docs. "Why is my deployment stuck?" becomes self-service.
Industrializing it: the self-applying project template
Creating these objects by hand for each new project does not scale. OpenShift lets you replace the template used by oc new-project / "Create Project" in the console, so every project is provisioned with its guardrails already in place.
Start from the built-in template:
oc adm create-bootstrap-project-template -o yaml > project-request-template.yaml
Then extend it. The key parameters injected by the API server are PROJECT_NAME, PROJECT_DISPLAYNAME, PROJECT_DESCRIPTION, PROJECT_ADMIN_USER and PROJECT_REQUESTING_USER.
apiVersion: template.openshift.io/v1
kind: Template
metadata:
name: project-request
namespace: openshift-config
objects:
- apiVersion: project.openshift.io/v1
kind: Project
metadata:
name: ${PROJECT_NAME}
annotations:
openshift.io/description: ${PROJECT_DESCRIPTION}
openshift.io/display-name: ${PROJECT_DISPLAYNAME}
openshift.io/requester: ${PROJECT_REQUESTING_USER}
labels:
tenant: unassigned # platform team relabels to attach the CRQ
- apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: admin
namespace: ${PROJECT_NAME}
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: admin
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: User
name: ${PROJECT_ADMIN_USER}
- apiVersion: v1
kind: LimitRange
metadata:
name: tenant-defaults
namespace: ${PROJECT_NAME}
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 50m
memory: 128Mi
max:
cpu: "4"
memory: 8Gi
min:
cpu: 10m
memory: 32Mi
maxLimitRequestRatio:
cpu: "20"
- apiVersion: v1
kind: ResourceQuota
metadata:
name: compute-quota
namespace: ${PROJECT_NAME}
spec:
hard:
requests.cpu: ${{QUOTA_REQUESTS_CPU}}
requests.memory: ${QUOTA_REQUESTS_MEMORY}
limits.cpu: ${{QUOTA_LIMITS_CPU}}
limits.memory: ${QUOTA_LIMITS_MEMORY}
pods: ${{QUOTA_PODS}}
services.loadbalancers: "0"
services.nodeports: "0"
- apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: ${PROJECT_NAME}
spec:
podSelector: {}
policyTypes:
- Ingress
- apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-openshift-ingress
namespace: ${PROJECT_NAME}
spec:
podSelector: {}
ingress:
- from:
- namespaceSelector:
matchLabels:
policy-group.network.openshift.io/ingress: ""
policyTypes:
- Ingress
parameters:
- name: PROJECT_NAME
- name: PROJECT_DISPLAYNAME
- name: PROJECT_DESCRIPTION
- name: PROJECT_ADMIN_USER
- name: PROJECT_REQUESTING_USER
- name: QUOTA_REQUESTS_CPU
value: "4"
- name: QUOTA_REQUESTS_MEMORY
value: 8Gi
- name: QUOTA_LIMITS_CPU
value: "8"
- name: QUOTA_LIMITS_MEMORY
value: 16Gi
- name: QUOTA_PODS
value: "30"
Note the ${{...}} syntax for numeric values: single-brace substitution always yields a string, which the quota schema accepts for pods but not cleanly for everything — using ${{ }} emits a proper JSON value. Keep memory values as plain strings.
Install and activate it:
oc create -f project-request-template.yaml -n openshift-config
oc patch project.config.openshift.io/cluster --type=merge \
-p '{"spec":{"projectRequestTemplate":{"name":"project-request"}}}'
# The openshift-apiserver rolls out; wait for it
oc rollout status deploy/apiserver -n openshift-apiserver --timeout=5m
Test end to end, as a normal user, never as kubeadmin:
oc login -u alice
oc new-project demo-governance
oc get limitrange,resourcequota,networkpolicy -n demo-governance
Store this template in Git and apply it with Argo CD or oc apply. A project template is cluster policy: it deserves a pull request and a reviewer.
Auditing consumption
Governance without measurement is wishful thinking. Three levels of audit:
Ad hoc, across the cluster:
# Namespaces closest to saturation on memory requests
oc get resourcequota --all-namespaces \
-o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,USED:.status.used.requests\.memory,HARD:.status.hard.requests\.memory'
# Team-level view
oc get clusterresourcequota -o custom-columns=\
'NAME:.metadata.name,CPU_USED:.status.total.used.requests\.cpu,CPU_HARD:.status.total.hard.requests\.cpu,NS:.status.namespaces[*].namespace'
# Find ungoverned namespaces — the real risk
for ns in $(oc get ns -o name | cut -d/ -f2 | grep -v '^openshift\|^kube\|^default'); do
[ "$(oc get resourcequota -n "$ns" --no-headers 2>/dev/null | wc -l)" -eq 0 ] && echo "NO QUOTA: $ns"
done
Continuous, with Prometheus. kube-state-metrics exposes kube_resourcequota with a type label of hard or used:
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: tenant-quota-alerts
namespace: openshift-monitoring
spec:
groups:
- name: quotas
rules:
- alert: NamespaceQuotaNearlyExhausted
expr: |
kube_resourcequota{type="used", resource=~"requests.cpu|requests.memory|pods"}
/ ignoring(type)
kube_resourcequota{type="hard", resource=~"requests.cpu|requests.memory|pods"}
> 0.9
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $labels.namespace }} is at >90% of {{ $labels.resource }}"
(Deploy user-workload rules in the tenant's own namespace; cluster-wide rules belong with the platform monitoring stack.)
Capacity reconciliation. Periodically compare the sum of all requests.cpu hard values against real allocatable capacity. Overcommitting quotas 2–3× is normal and healthy; overcommitting 10× means your quotas are decorative.
Security best practices
-
Tenants must not be able to edit their own quotas. The built-in
adminClusterRole grantsget/list/watchonresourcequotasbut notcreate/update/delete. Verify it after any RBAC customization:
oc auth can-i delete resourcequota -n team-payments-dev --as alice # must print "no"
oc auth can-i patch limitrange -n team-payments-dev --as alice # must print "no"
-
Control self-provisioning. If anyone can create unlimited projects, per-namespace quotas are trivially bypassed. Either remove the
self-provisionerbinding and provision projects via GitOps, or always pair self-service with an annotation-selectorClusterResourceQuotaper user/team. -
Quota the control plane too. Caps on
secrets,configmapsandcount/*protect etcd from a runaway operator or CI loop — an availability control for the whole cluster, not just a billing one. -
Block implicit network exposure with
services.loadbalancers: "0"andservices.nodeports: "0", and ship a default-denyNetworkPolicyin the same template. Resource isolation and network isolation should be provisioned together. -
Deny high-priority classes by quota scope so tenants cannot set
priorityClassName: system-cluster-criticalto jump the queue and preempt platform workloads. -
Keep SCCs in the picture.
restricted-v2should remain the default; a tenant able to run privileged pods can mount host paths and read other tenants' data regardless of any quota. -
Treat the project template as audited policy. Changes to it propagate to every future project. Git, review, signed commits, and an alert on
project.config.openshift.io/clusterdrift. -
Label-driven CRQ membership is a privilege. Whoever can set
tenant=paymentson a namespace can move it into (or out of) a budget. Restrict namespace labelling to the platform team.
Key takeaways
Multi-tenancy on OpenShift is an engineering problem with a well-defined answer, and it is only three objects deep:
-
LimitRangemakes every pod well-formed — defaults for requests and limits, a sane min/max envelope, andmaxLimitRequestRatioto stop overcommit games. Always deploy it before a quota that constrainsrequests.*. -
ResourceQuotacaps a namespace on compute, storage per StorageClass, and object counts. Use scopes to separate batch from long-running workloads and to forbid critical priority classes. -
ClusterResourceQuotacaps a team across all its projects, matched by namespace labels or theopenshift.io/requesterannotation, andAppliedClusterResourceQuotamakes that budget visible to developers without any cluster-scoped RBAC. -
The project template is what makes it industrial.
oc adm create-bootstrap-project-template, extend it with LimitRange, ResourceQuota, RoleBinding and NetworkPolicy, store it in Git, and pointproject.config.openshift.io/clusterat it. Governance becomes a property of project creation instead of a recurring ticket. - Audit continuously. Alert at 90% saturation, hunt for namespaces with no quota at all, and reconcile the sum of quotas against real allocatable capacity every quarter.
Get these in place and the noisy-neighbour incident stops being a postmortem — it becomes a 403 exceeded quota that the responsible team sees immediately, in their own namespace, with their own name on it.
Top comments (0)