DEV Community

Florient DOGBE
Florient DOGBE

Posted on

Quotas, LimitRange and ResourceQuota on OpenShift: Industrializing Multi-Tenancy per Project

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.requests is 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.limits can 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"}}
Enter fullscreen mode Exit fullscreen mode

maxLimitRequestRatio is 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

The gotchas that will page you

  • Quota on requests.* makes requests mandatory. Any pod without them is rejected with must 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 cap count/virtualmachines.kubevirt.io or count/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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}"
Enter fullscreen mode Exit fullscreen mode

(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 admin ClusterRole grants get/list/watch on resourcequotas but not create/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"
Enter fullscreen mode Exit fullscreen mode
  • Control self-provisioning. If anyone can create unlimited projects, per-namespace quotas are trivially bypassed. Either remove the self-provisioner binding and provision projects via GitOps, or always pair self-service with an annotation-selector ClusterResourceQuota per user/team.
  • Quota the control plane too. Caps on secrets, configmaps and count/* 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" and services.nodeports: "0", and ship a default-deny NetworkPolicy in 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-critical to jump the queue and preempt platform workloads.
  • Keep SCCs in the picture. restricted-v2 should 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/cluster drift.
  • Label-driven CRQ membership is a privilege. Whoever can set tenant=payments on 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:

  1. LimitRange makes every pod well-formed — defaults for requests and limits, a sane min/max envelope, and maxLimitRequestRatio to stop overcommit games. Always deploy it before a quota that constrains requests.*.
  2. ResourceQuota caps 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.
  3. ClusterResourceQuota caps a team across all its projects, matched by namespace labels or the openshift.io/requester annotation, and AppliedClusterResourceQuota makes that budget visible to developers without any cluster-scoped RBAC.
  4. 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 point project.config.openshift.io/cluster at it. Governance becomes a property of project creation instead of a recurring ticket.
  5. 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)