attribute namespace spend and enforce resource budgets

Kubernetes namespace-level FinOps: ResourceQuotas, LimitRanges, and team cost attribution

14 min read

Cloud billing shows cluster totals, not which namespace burned budget on unused CPU requests. ResourceQuotas cap team consumption, LimitRanges shape pod profiles, and OpenCost or Kubecost close the attribution loop—with labels finance can reconcile.

Why shared clusters hide team-level waste

FinOps conversations often stop at cloud billing dashboards, reserved capacity, and spot strategy. A large share of waste still originates inside the cluster: teams over-request CPU and memory, nobody tracks per-namespace consumption, and shared estates become black holes where resources disappear without an owner. Without enforcement, one team dev namespace can reserve sixty percent of schedulable capacity while production namespaces fight for the remainder. Symptoms include OOMKilled critical services, leadership unable to map spend to products, developers copying four CPU and eight GiB from staging without validation, and no feedback loop tying requests to cost. AWS Cost Explorer, GCP Billing, and Azure Cost Management show cluster or node pool totals—they cannot tell you that payments-api-dev spent budget on CPU requests that never ran.

Three layers: quota, limits, and attribution

Governance inside Kubernetes stacks three controls. ResourceQuota caps aggregate consumption per namespace—CPU and memory requests and limits, PVC count, pod count, LoadBalancer Services, and deployment objects. LimitRange sets per-container defaults, maximums, and maxLimitRequestRatio so a single pod cannot destabilize scheduling with tiny requests and huge limits. Cost attribution labels namespaces with team, cost-center, environment, and product, then feeds OpenCost or Kubecost so showback dashboards map utilization back to owners. Quotas enforce boundaries; limits shape workload profiles; attribution closes the visibility loop finance needs.

ResourceQuota: namespace budgets for compute and objects

Place ResourceQuota in each tenant namespace. Separate requests.cpu and requests.memory—which drive scheduling—from limits.cpu and limits.memory—which cap runtime. Include object count quotas for pods, services, and deployments to prevent namespace sprawl from cluttering the API server and monitoring. Put cost-center labels on namespaces and quotas so billing exports join Kubernetes consumption to finance codes.

YAML · team ResourceQuota with object caps
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-payments-quota
  namespace: payments-api
  labels:
    team: payments
    cost-center: "cc-1042"
    environment: production
spec:
  hard:
    requests.cpu: "8"
    requests.memory: 16Gi
    limits.cpu: "16"
    limits.memory: 32Gi
    persistentvolumeclaims: "10"
    pods: "30"
    services.loadbalancers: "2"
    count/deployments.apps: "15"
    requests.storage: 200Gi

LimitRange: defaults and bounds per container

Quotas cap the namespace total; LimitRange governs individual containers. default and defaultRequest inject CPU and memory when manifests omit resources—preventing unbounded containers and easing scheduling. max forces decomposition of oversized workloads. maxLimitRequestRatio blocks the anti-pattern of minimal requests with extreme limits that breaks bin-packing. Add a Pod-type max when init containers or sidecars stack resources.

YAML · LimitRange with ratio guardrails
apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: payments-api
spec:
  limits:
    - type: Container
      default:
        cpu: "0.5"
        memory: 512Mi
      defaultRequest:
        cpu: "0.25"
        memory: 256Mi
      max:
        cpu: "2"
        memory: 4Gi
      min:
        cpu: "0.1"
        memory: 64Mi
      maxLimitRequestRatio:
        cpu: "10"
        memory: "4"
    - type: Pod
      max:
        cpu: "4"
        memory: 8Gi

Cost attribution with OpenCost and PromQL estimates

Label every namespace consistently, deploy OpenCost or Kubecost per cluster, and scrape allocation metrics into Prometheus or your FinOps platform. Compare actual utilization cost against quota headroom to find over-allocated namespaces and teams bursting beyond sustainable levels. Adjust on-demand rates in PromQL to match your node pricing; OpenCost reconciles with cloud billing when CUR or billing export is configured.

YAML · Prometheus scrape for OpenCost
scrape_configs:
  - job_name: opencost
    scrape_interval: 1m
    static_configs:
      - targets:
          - opencost.opencost.svc.cluster.local:9003
    metric_relabel_configs:
      - source_labels: [__address__]
        target_label: cluster
        replacement: prod-us-east-1
PromQL · monthly namespace cost estimate (adjust rates)
# CPU (example on-demand vCPU-hour rate)
sum by (namespace) (
  rate(container_cpu_usage_seconds_total{
    container!="", container!="POD"
  }[24h])
) * 730 * 0.0312

# Memory (example GiB-hour rate)
sum by (namespace) (
  container_memory_working_set_bytes{
    container!="", container!="POD"
  }
) / 1024 / 1024 / 1024 * 730 * 0.00388

Progressive quotas across dev, staging, and production

Scale namespace budgets with environment risk: dev gets smaller CPU requests and tighter per-container max values; production receives higher quotas, more pods, LoadBalancer allowance, and ingress object caps. One team label and cost-center across all three namespaces keeps attribution consistent while limits differ.

YAML · dev namespace with quota and LimitRange
apiVersion: v1
kind: Namespace
metadata:
  name: payments-api-dev
  labels:
    team: payments
    cost-center: "cc-1042"
    environment: dev
    managed-by: platform-team
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: dev-quota
  namespace: payments-api-dev
spec:
  hard:
    requests.cpu: "4"
    requests.memory: 8Gi
    limits.cpu: "8"
    limits.memory: 16Gi
    pods: "15"
    count/deployments.apps: "5"
    requests.storage: 50Gi
---
apiVersion: v1
kind: LimitRange
metadata:
  name: dev-defaults
  namespace: payments-api-dev
spec:
  limits:
    - type: Container
      default:
        cpu: "0.25"
        memory: 256Mi
      defaultRequest:
        cpu: "0.1"
        memory: 128Mi
      max:
        cpu: "1"
        memory: 2Gi
YAML · production namespace with higher caps
apiVersion: v1
kind: Namespace
metadata:
  name: payments-api-production
  labels:
    team: payments
    cost-center: "cc-1042"
    environment: production
    managed-by: platform-team
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: production-quota
  namespace: payments-api-production
spec:
  hard:
    requests.cpu: "16"
    requests.memory: 32Gi
    limits.cpu: "32"
    limits.memory: 64Gi
    pods: "50"
    count/deployments.apps: "20"
    count/ingresses.networking.k8s.io: "10"
    services.loadbalancers: "3"
    requests.storage: 500Gi
---
apiVersion: v1
kind: LimitRange
metadata:
  name: production-defaults
  namespace: payments-api-production
spec:
  limits:
    - type: Container
      default:
        cpu: "1"
        memory: 1Gi
      defaultRequest:
        cpu: "0.5"
        memory: 512Mi
      max:
        cpu: "4"
        memory: 8Gi
      maxLimitRequestRatio:
        cpu: "8"
        memory: "4"

Provision namespaces and quotas with Terraform

Manual namespace creation drifts from policy. Encode team, environment, cost-center, and quota tiers in Terraform variables, review changes in pull requests, and apply through your existing pipeline. Nested for_each over teams and environments avoids the bug of reusing one team environment list for every tenant.

HCL · Terraform namespaces and ResourceQuota per team and env
variable "teams" {
  type = map(object({
    cost_center  = string
    environments = list(string)
    quota = map(object({
      cpu_request    = string
      memory_request = string
      cpu_limit      = string
      memory_limit   = string
      max_pods       = string
    }))
  }))
}

locals {
  team_envs = merge([
    for team, cfg in var.teams : {
      for env in cfg.environments :
      "${team}-${env}" => {
        team        = team
        environment = env
        cost_center = cfg.cost_center
        quota       = cfg.quota[env]
      }
    }
  ]...)
}

resource "kubernetes_namespace" "team" {
  for_each = local.team_envs

  metadata {
    name = "${each.value.team}-api-${each.value.environment}"
    labels = {
      team        = each.value.team
      cost-center = each.value.cost_center
      environment = each.value.environment
      managed-by  = "terraform"
    }
  }
}

resource "kubernetes_resource_quota" "team" {
  for_each = kubernetes_namespace.team

  metadata {
    name      = "${each.value.metadata[0].labels.team}-quota"
    namespace = each.value.metadata[0].name
  }

  spec {
    hard = {
      "requests.cpu"    = local.team_envs[each.key].quota.cpu_request
      "requests.memory" = local.team_envs[each.key].quota.memory_request
      "limits.cpu"      = local.team_envs[each.key].quota.cpu_limit
      "limits.memory"   = local.team_envs[each.key].quota.memory_limit
      "pods"            = local.team_envs[each.key].quota.max_pods
    }
  }
}

Operational practices: defaults first, alerts, admission, and capacity mix

Deploy LimitRanges with sensible defaults before tight quotas; collect two to four weeks of usage, then tighten. Use PriorityClass so critical production pods schedule ahead of batch work when quotas are under pressure—not to bypass quotas, but to order preemption fairly. Route quota increase requests through ticketing for audit trail. Alert at eighty percent of ResourceQuota using kube-state-metrics. Enforce team, cost-center, and environment labels on Namespace creation with Kyverno or Gatekeeper. Pair quotas with cluster autoscaler or Karpenter so node count follows approved requests—configure expander and scale-down delays to limit waste. Reserve baseline capacity for steady-state quota usage; use spot for interruptible dev and CI workloads. Namespace FinOps is ongoing discipline: every CPU cycle and memory byte should have an owner, a budget line, and a business justification.

YAML · PriorityClass for critical versus batch workloads
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: production-critical
value: 1000000
globalDefault: false
description: "Critical production workloads"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: batch-processing
value: 100
globalDefault: false
description: "Batch jobs and non-critical workloads"
YAML · Prometheus alert for quota consumption
groups:
  - name: namespace-quota
    rules:
      - alert: NamespaceQuotaNearLimit
        expr: |
          sum by (namespace, resource) (
            kube_resourcequota{type="used"}
          )
          /
          sum by (namespace, resource) (
            kube_resourcequota{type="hard"}
          ) > 0.8
        for: 15m
        labels:
          severity: warning
        annotations:
          summary: "Namespace {{ $labels.namespace }} above 80% of {{ $labels.resource }} quota"
YAML · Kyverno require cost labels on Namespace
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-namespace-cost-labels
spec:
  validationFailureAction: Enforce
  rules:
    - name: required-cost-labels
      match:
        any:
          - resources:
              kinds: [Namespace]
      validate:
        message: "Namespaces must include team, cost-center, and environment labels."
        pattern:
          metadata:
            labels:
              team: "?*"
              cost-center: "?*"
              environment: "?*"

Cluster-wide FinOps visibility and VPA right-sizing build on patterns from our FinOps guide for cloud-native platforms.

Enforce required namespace labels at admission with policies from our Policy as Code guide with Kyverno and Gatekeeper.