📐 The Capacity Planning Cycle

Capacity planning is not a one-time event — it's a continuous loop that runs alongside your normal operations cadence. The goal is never to be surprised by resource exhaustion, but also never to massively over-provision.

📊 Measure current utilisation 📈 Forecast traffic growth 🎯 Target headroom + buffers ⚙️ Provision nodes + quotas 🔔 Alert + Review thresholds monthly review loop

Weekly

Review utilisation dashboards. Check if any namespace is approaching quota. Review HPA scaling events.

Monthly

Right-size workloads using VPA recommendations. Review Kubecost reports. Adjust node pool sizes.

Quarterly

Forecast next quarter's growth. Reserve/commit instances. Plan node pool changes for upcoming launches.

Annually

Architecture review. Evaluate whether current cluster topology matches 12-month growth projection.

📊 Measuring Utilisation & Setting Headroom

Node Allocatable vs Capacity

Kubernetes reserves resources for the OS and kubelet. The allocatable amount is what pods can actually request — always less than the node's physical capacity.

# View allocatable vs capacity for all nodes
kubectl get nodes -o custom-columns=\
'NAME:.metadata.name,\
CPU-CAP:.status.capacity.cpu,\
CPU-ALLOC:.status.allocatable.cpu,\
MEM-CAP:.status.capacity.memory,\
MEM-ALLOC:.status.allocatable.memory'

# Typical overhead on a 4-CPU / 16Gi node:
# capacity.cpu:    4000m
# allocatable.cpu: 3800m   (200m reserved for system)
# capacity.memory: 16Gi
# allocatable.memory: 15.4Gi  (~600Mi reserved)

Cluster-Wide Utilisation PromQL

# Requested CPU as % of allocatable (across all nodes)
sum(kube_pod_container_resource_requests{resource="cpu"})
  / sum(kube_node_status_allocatable{resource="cpu"}) * 100

# Actual CPU usage as % of allocatable
sum(rate(container_cpu_usage_seconds_total{container!=""}[5m]))
  / sum(kube_node_status_allocatable{resource="cpu"}) * 100

# Memory requested as % of allocatable
sum(kube_pod_container_resource_requests{resource="memory"})
  / sum(kube_node_status_allocatable{resource="memory"}) * 100

# Per-node: how much of its allocatable CPU is requested
sum by (node) (kube_pod_container_resource_requests{resource="cpu"})
  / sum by (node) (kube_node_status_allocatable{resource="cpu"}) * 100

Utilisation Snapshot

CPU Requested
55%
CPU Actual Usage
28%
Memory Requested
72%
Memory Actual
85%

Example: CPU has healthy headroom; memory actual usage is dangerously high — add nodes or right-size memory limits.

Headroom Zones

Used 55%
Headroom 15%
Spike buffer 15%
Safety margin 15%
ZonePurposeTrigger
Headroom (55–70%)Room for organic growth without adding nodesAdd nodes when requested >70%
Spike buffer (70–85%)Absorbs traffic spikes and rolling deploymentsAlert at 70%, page at 80%
Safety margin (85–100%)Emergency reserve — never intentionally usedImmediate action required at 85%
⚠️ Track requested, not actual usage The scheduler uses requests (not actual usage) to decide where pods fit. A node at 30% actual CPU utilisation can still be "full" from the scheduler's view if requests sum to 100%. Always monitor both requested and actual utilisation.

📈 Forecasting Growth & Node Sizing

Simple Linear Forecast with PromQL

# Project CPU request growth 30 days into the future
# based on the last 14 days of trend
predict_linear(
  sum(kube_pod_container_resource_requests{resource="cpu"})[14d:1h],
  30 * 24 * 3600   # 30 days in seconds
)

# Example interpretation:
# Current: 120 CPU cores requested
# predict_linear result: 180 cores in 30 days
# → Need to add ~60 cores (e.g. 4 × m5.4xlarge) within 30 days

# Memory forecast
predict_linear(
  sum(kube_pod_container_resource_requests{resource="memory"})[14d:1h],
  30 * 24 * 3600
)
💡 Forecast at the P90 of weekly peaks, not average Average utilisation misses weekly traffic spikes. Use max_over_time or P90 to forecast based on peak demand. Sizing for average means you'll run out of headroom every Monday morning.

Node Size Trade-offs

Node sizeAdvantagesDisadvantagesBest for
Small (2–4 vCPU, 8–16 GB)Fine-grained scaling; blast radius of one node failure is smallHigh control-plane overhead (kubelet per node); harder to schedule large podsDev clusters; edge; high-availability sensitivity
Medium (8–16 vCPU, 32–64 GB)Good balance of flexibility and efficiencyModerate failure blast radiusMost general-purpose production workloads
Large (32–64 vCPU, 128–256 GB)Lower overhead ratio; fewer nodes to manage; great for bin-packingLarge blast radius; individual node failure evicts many pods; may waste resources for small podsBatch, ML, data-intensive workloads

The Three Autoscaling Layers

HPA — Pod Replicas

Scales Deployment/StatefulSet replicas based on CPU, memory, or custom metrics. Reacts in seconds. Acts within existing node capacity.

VPA — Pod Sizes

Adjusts CPU/memory requests per pod. Reacts in hours (requires pod restart). Reduces waste from over-provisioned requests.

Cluster Autoscaler / Karpenter

Adds/removes nodes based on pending pods and underutilised nodes. Reacts in 30–120s. Bridges gaps HPA can't fill.

# Layer them correctly:
# 1. VPA (Off mode) → gives right-sized requests
# 2. HPA → scales replicas on CPU/RPS
# 3. Karpenter → adds nodes when HPA can't fit new pods

# Don't run VPA Auto + HPA on the same workload —
# VPA evicting pods conflicts with HPA scaling targets.
# Use VPA for right-sizing, HPA for throughput scaling.

Capacity Alerts

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: capacity-alerts
  namespace: monitoring
spec:
  groups:
    - name: capacity
      rules:
        # Node CPU requested > 80% of allocatable
        - alert: NodeCPURequestsHigh
          expr: |
            sum by (node) (kube_pod_container_resource_requests{resource="cpu"})
            / sum by (node) (kube_node_status_allocatable{resource="cpu"}) > 0.80
          for: 15m
          labels: { severity: warning }
          annotations:
            summary: "Node {{ $labels.node }} CPU requests at {{ $value | humanizePercentage }}"

        # Cluster memory will be full in < 7 days
        - alert: ClusterMemoryWillExhaustSoon
          expr: |
            predict_linear(
              sum(kube_node_status_allocatable{resource="memory"}
                - on(node) group_left()
                sum by (node)(kube_pod_container_resource_requests{resource="memory"})
              )[3d:1h], 7 * 24 * 3600) < 0
          for: 1h
          labels: { severity: critical }
          annotations:
            summary: "Cluster memory capacity will be exhausted within 7 days"

💵 Chargeback & Resource Governance

Kubecost — Cost Attribution

helm repo add kubecost https://kubecost.github.io/cost-analyzer
helm repo update

helm install kubecost kubecost/cost-analyzer \
  --namespace kubecost \
  --create-namespace \
  --set kubecostToken="" \
  --set global.prometheus.fqdn=http://kube-prometheus-stack-prometheus.monitoring:9090

# Access UI
kubectl port-forward -n kubecost svc/kubecost-cost-analyzer 9090:9090

Key Kubecost Queries (API)

# Cost per namespace last 30 days
curl "http://localhost:9090/model/allocation?window=30d&aggregate=namespace"

# Cost per team label last 7 days
curl "http://localhost:9090/model/allocation?window=7d&aggregate=label:team"

# Identify idle/wasted CPU cost
curl "http://localhost:9090/model/savings/requestSizingV2"
# Returns per-workload recommendations:
# {
#   "namespace": "team-alpha",
#   "workload": "api-server",
#   "currentCPURequest": "2",
#   "recommendedCPURequest": "0.4",
#   "monthlySavings": 47.20
# }

Resource Quota Governance Workflow

StageWhoAction
Initial quotaPlatform teamSet conservative quota based on team's stated needs × 1.5×
Weekly reviewPlatform teamCheck kube_resourcequota used vs hard; notify teams at 80%
Quota increase requestTeam leadSubmit PR to namespace config repo with justification + forecast
ApprovalPlatform team + FinOpsReview cost impact, approve or suggest right-sizing first
Monthly chargebackFinOpsExport Kubecost report to finance; per-team cost visibility

Namespace Quota Utilisation Monitoring

# Alert when a namespace is using >80% of its quota
sum by (namespace, resource) (kube_resourcequota{type="used"})
  / sum by (namespace, resource) (kube_resourcequota{type="hard"}) > 0.80

# Find namespaces with NO ResourceQuota (governance gap)
kube_namespace_labels unless on(namespace)
  kube_resourcequota
💡 Make teams own their quotas Show each team their monthly Kubecost bill in their team Slack channel (automated webhook). When engineers see that leaving 10 over-provisioned pods running costs $3,000/month, they right-size them. Chargeback creates accountability that top-down mandates never achieve.

📝 Knowledge Check

Q1. A node shows 30% actual CPU utilisation in kubectl top nodes, but new pods are stuck Pending with "Insufficient CPU". What is happening?
  • A) The node's kubelet is malfunctioning and not reporting correctly
  • B) The scheduler uses CPU requests, not actual usage — the node's allocatable CPU is fully claimed even though actual usage is low
  • C) The pending pods have CPU limits set too high
  • D) The cluster autoscaler is preventing pod scheduling
B) Requests, not actual usage, determine schedulability. The scheduler sums resources.requests.cpu across all pods on a node. Even if actual CPU usage is 30%, if the sum of requests equals allocatable, no new pod can be scheduled regardless of actual headroom. The fix is to right-size requests (use VPA recommendations) to reflect realistic usage.
Q2. You use predict_linear on the last 14 days of memory requests and it forecasts you'll exceed capacity in 45 days. What should you do now?
  • A) Wait until capacity is actually exhausted before taking action
  • B) First check if requests are over-provisioned (VPA analysis); if growth is real, plan node additions to land before day 30
  • C) Immediately add twice the predicted capacity as a safety buffer
  • D) Disable the forecast alert — 45 days is plenty of time and it may not be accurate
B) Check requests first, then plan ahead. 45-day warning is exactly what capacity planning is for. First, verify whether the trend represents real growth or over-provisioned requests (VPA may eliminate the problem). If growth is genuine, plan and schedule node additions to land at day 30 — leaving 15 days of buffer. Don't wait until day 44.
Q3. Why should you NOT run VPA in Auto mode on the same Deployment that has HPA configured?
  • A) VPA and HPA use different Kubernetes APIs and are incompatible
  • B) VPA Auto evicts pods to resize them, which conflicts with HPA's replica count management and can cause availability issues
  • C) HPA ignores VPA recommendations, making VPA Auto mode pointless
  • D) Running both doubles the Prometheus scrape load
B) VPA evictions conflict with HPA. VPA Auto mode evicts pods to apply new resource requests. If HPA is simultaneously scaling replicas based on CPU utilisation, VPA's evictions cause CPU spikes that trigger HPA scale-ups, which then cause VPA to evict newly created pods — a feedback loop. Use VPA in Off/Recommend mode to inform right-sizing, and HPA for throughput scaling.