📐 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.
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
Example: CPU has healthy headroom; memory actual usage is dangerously high — add nodes or right-size memory limits.
Headroom Zones
| Zone | Purpose | Trigger |
|---|---|---|
| Headroom (55–70%) | Room for organic growth without adding nodes | Add nodes when requested >70% |
| Spike buffer (70–85%) | Absorbs traffic spikes and rolling deployments | Alert at 70%, page at 80% |
| Safety margin (85–100%) | Emergency reserve — never intentionally used | Immediate action required at 85% |
📈 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
)
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 size | Advantages | Disadvantages | Best for |
|---|---|---|---|
| Small (2–4 vCPU, 8–16 GB) | Fine-grained scaling; blast radius of one node failure is small | High control-plane overhead (kubelet per node); harder to schedule large pods | Dev clusters; edge; high-availability sensitivity |
| Medium (8–16 vCPU, 32–64 GB) | Good balance of flexibility and efficiency | Moderate failure blast radius | Most general-purpose production workloads |
| Large (32–64 vCPU, 128–256 GB) | Lower overhead ratio; fewer nodes to manage; great for bin-packing | Large blast radius; individual node failure evicts many pods; may waste resources for small pods | Batch, 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
| Stage | Who | Action |
|---|---|---|
| Initial quota | Platform team | Set conservative quota based on team's stated needs × 1.5× |
| Weekly review | Platform team | Check kube_resourcequota used vs hard; notify teams at 80% |
| Quota increase request | Team lead | Submit PR to namespace config repo with justification + forecast |
| Approval | Platform team + FinOps | Review cost impact, approve or suggest right-sizing first |
| Monthly chargeback | FinOps | Export 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
📝 Knowledge Check
kubectl top nodes, but new pods are stuck Pending with "Insufficient CPU". What is happening?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.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?