💰 Cost Levers & Typical Savings

Kubernetes clusters are notoriously over-provisioned. Most teams run at 15–30% actual CPU utilisation. The levers below are ordered by typical impact and ease of implementation.

Spot / Preemptible Nodes
60–80%
Right-sizing requests
30–50%
Cluster Autoscaler / Karpenter
20–40%
Idle namespace cleanup
10–25%
Reserved/Committed instances
20–40%
Scale-to-zero (KEDA / VPA min=0)
10–30%

📊 Measure First

Install Kubecost or OpenCost. You can't optimise what you can't see. Get per-namespace, per-team cost attribution before cutting anything.

🧪 Test in Non-Prod

Apply spot nodes and lower limits to staging first. Validate workload tolerance before rolling to production.

🔄 Iterative Approach

Right-size → auto-scale → spot → commit. Each step compounds. Don't try to do everything at once.

📉 Chargeback

Assign costs to teams via namespace labels. When teams see their bill, they right-size their own workloads.

📐 Right-sizing Containers

Over-requesting resources is the most common source of wasted spend. A container requesting 2 CPU cores but using 200m means you're paying for 1.8 cores of idle capacity — multiplied across hundreds of pods.

Finding Over-provisioned Workloads

# See actual vs requested CPU/memory
kubectl top pods -A --sort-by=cpu

# Find pods with no resource requests at all (BestEffort — dangerous)
kubectl get pods -A -o json | jq -r '
  .items[] |
  select(.spec.containers[].resources.requests == null) |
  [.metadata.namespace, .metadata.name] | @tsv'

# Prometheus queries to find over-provisioned containers
# CPU request vs actual usage (last 1 week avg)
# ratio < 0.3 = using less than 30% of what was requested
sum by (namespace, pod, container) (
  rate(container_cpu_usage_seconds_total[1w])
) /
sum by (namespace, pod, container) (
  kube_pod_container_resource_requests{resource="cpu"}
)

# Memory: avg usage vs limit
avg by (namespace, pod, container) (
  container_memory_working_set_bytes
) /
sum by (namespace, pod, container) (
  kube_pod_container_resource_limits{resource="memory"}
)

Right-sizing Rule of Thumb

ResourceRequestLimitNotes
CPUP50 of actual usageP95–P99 of actual usageCPU is compressible — throttling not fatal
MemoryP90 of actual usageP99 + 20% headroomMemory is not compressible — OOMKill is fatal
💡 Set requests = limits for Guaranteed QoS on critical services For latency-sensitive workloads, equal requests and limits give Guaranteed QoS — the container is never throttled and is last to be evicted. Accept the cost overhead; use VPA to keep it calibrated.

Vertical Pod Autoscaler (VPA)

VPA automatically recommends (and optionally sets) right-sized CPU and memory requests based on historical usage. In Off mode it only reports recommendations — safe to deploy anywhere.

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: my-app-vpa
  namespace: my-app
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: my-app
  updatePolicy:
    updateMode: "Off"      # Recommend only — don't restart pods
    # updateMode: "Auto"   # Restart pods with new requests (use in non-prod first)
  resourcePolicy:
    containerPolicies:
      - containerName: app
        minAllowed:
          cpu: "50m"
          memory: "64Mi"
        maxAllowed:
          cpu: "4"
          memory: "4Gi"
# View VPA recommendations
kubectl describe vpa my-app-vpa -n my-app
# Recommendation:
#   Container Recommendations:
#     Container Name: app
#     Lower Bound:    cpu: 80m, memory: 120Mi
#     Target:         cpu: 150m, memory: 200Mi   ← set this as requests
#     Upper Bound:    cpu: 600m, memory: 800Mi
⚠️ VPA Auto mode evicts pods to apply new requests When updateMode: Auto, VPA evicts pods to resize them. This can violate PodDisruptionBudgets. Always use Off mode first to gather recommendations, then apply manually or with Initial mode (apply only on new pods).

Waste Detection — Idle & Zombie Resources

# Find deployments with 0 replicas (zombie resources)
kubectl get deployments -A -o json | jq -r '
  .items[] | select(.spec.replicas == 0) |
  [.metadata.namespace, .metadata.name] | @tsv'

# Find unattached PVCs (paying for storage with no consumer)
kubectl get pvc -A -o json | jq -r '
  .items[] | select(.status.phase == "Bound") |
  select(.metadata.annotations["volume.kubernetes.io/selected-node"] == null) |
  [.metadata.namespace, .metadata.name, .spec.resources.requests.storage] | @tsv'

# Find LoadBalancer Services with no endpoints (paying for LB with no traffic)
kubectl get svc -A --field-selector=spec.type=LoadBalancer -o json | \
  jq -r '.items[] | [.metadata.namespace, .metadata.name, .status.loadBalancer.ingress[0].ip] | @tsv'

⚡ Spot Nodes & Cluster Autoscaler

Spot (AWS) / Preemptible (GCP) / Spot (Azure) instances offer 60–80% discounts over on-demand pricing. The trade-off: the cloud provider can reclaim them with 2-minute notice. Kubernetes handles this gracefully when workloads are designed for interruption.

Workload Suitability for Spot

Workload typeSpot suitable?Notes
Stateless web services (≥2 replicas)✅ YesSpread across AZs; PDB ensures availability
Batch / ML training jobs✅ Yes (with checkpointing)KEDA + spot ideal; checkpoint state to S3
CI/CD runners✅ YesJobs are inherently retriable
Stateful databases (primary)❌ NoUse on-demand for primary; spot for read replicas only
Control-plane nodes❌ NoNever run control plane or etcd on spot
Single-replica critical services⚠️ RiskyScale to ≥2 first; add PDB

Mixed Node Pool Strategy

# Taint spot nodes so only opted-in pods schedule there
kubectl taint nodes -l node-lifecycle=spot \
  node-lifecycle=spot:NoSchedule

# Workloads that tolerate spot
spec:
  tolerations:
    - key: "node-lifecycle"
      operator: "Equal"
      value: "spot"
      effect: "NoSchedule"
  affinity:
    nodeAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 100
          preference:
            matchExpressions:
              - key: node-lifecycle
                operator: In
                values: ["spot"]
💡 Use multiple instance types for spot Configure Cluster Autoscaler node groups with multiple instance type options (e.g. m5.xlarge, m5a.xlarge, m4.xlarge). If one type is unavailable, CA falls back to another — reducing interruption risk significantly.

Cluster Autoscaler Configuration

# Key CA flags (set as args in the Deployment)
--scale-down-enabled=true
--scale-down-delay-after-add=10m       # wait 10m after scale-up before scaling down
--scale-down-unneeded-time=10m         # node idle for 10m before scale-down
--scale-down-utilization-threshold=0.5 # scale down if CPU+mem both < 50%
--max-graceful-termination-sec=600     # 10min for pods to terminate
--balance-similar-node-groups=true     # balance spot pools across AZs
--skip-nodes-with-system-pods=false    # allow scale-down even with system pods

# Prevent CA from scaling down a specific node
kubectl annotate node worker-1 \
  cluster-autoscaler.kubernetes.io/scale-down-disabled=true

# Check CA activity log
kubectl -n kube-system logs deployment/cluster-autoscaler | grep -i "scale\|removed\|added"

Handling Spot Interruptions Gracefully

# AWS Node Termination Handler — catches spot interruption notices
# and cordons/drains the node before it's reclaimed
helm install aws-node-termination-handler \
  aws/aws-node-termination-handler \
  --namespace kube-system \
  --set enableSpotInterruptionDraining=true \
  --set enableScheduledEventDraining=true \
  --set nodeTerminationGracePeriod=120

# PodDisruptionBudget — ensures ≥1 pod stays up during spot eviction
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: my-app-pdb
  namespace: my-app
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: my-app

🚀 Karpenter & Namespace Cost Controls

Karpenter vs Cluster Autoscaler

FeatureCluster AutoscalerKarpenter
Provisioning speed2–5 min (ASG scale-out)30–60 sec (direct EC2 API)
Instance selectionPre-defined node groupsDynamic — picks cheapest fit for pending pods
Spot diversityRequires many separate ASGsSingle NodePool, tries 100s of instance types
Bin-packingLimitedConsolidation: replaces large nodes with smaller ones
Cloud supportAll cloudsAWS, Azure (preview), GCP (preview)
Config complexityModerate (node group annotations)Low (NodePool CRDs)

Karpenter NodePool

apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
  name: default
spec:
  template:
    spec:
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]   # prefer spot, fallback to on-demand
        - key: node.kubernetes.io/instance-type
          operator: In
          values:                         # allow many types for spot diversity
            - m5.xlarge
            - m5a.xlarge
            - m5d.xlarge
            - m4.xlarge
            - m6i.xlarge
        - key: topology.kubernetes.io/zone
          operator: In
          values: ["us-east-1a", "us-east-1b", "us-east-1c"]
      nodeClassRef:
        apiVersion: karpenter.k8s.aws/v1beta1
        kind: EC2NodeClass
        name: default
  limits:
    cpu: "1000"          # max total CPU across all Karpenter nodes
    memory: "4000Gi"
  disruption:
    consolidationPolicy: WhenUnderutilized   # replace/remove underutilised nodes
    consolidateAfter: 30s

Namespace ResourceQuota for Cost Control

apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-alpha-quota
  namespace: team-alpha
spec:
  hard:
    requests.cpu: "20"           # total CPU requests across all pods
    requests.memory: "40Gi"
    limits.cpu: "40"
    limits.memory: "80Gi"
    count/persistentvolumeclaims: "20"
    requests.storage: "500Gi"
    count/services.loadbalancers: "2"   # limit expensive LB services

Scale-to-Zero for Non-Prod

# Use KEDA ScaledObject with minReplicaCount: 0 to scale idle workloads to zero
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: my-app
  namespace: staging
spec:
  scaleTargetRef:
    name: my-app
  minReplicaCount: 0    # scale to zero when idle
  maxReplicaCount: 10
  cooldownPeriod: 300   # 5 min idle before scale-down to zero
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus:9090
        metricName: http_requests_total
        threshold: "10"
        query: sum(rate(http_requests_total{namespace="staging"}[1m]))
💡 Schedule non-prod scale-down with CronJobs For dev/staging clusters that don't need to run overnight: use a CronJob to scale all Deployments to 0 at 8 PM and back to 1 at 8 AM. A 12-hour daily shutdown saves ~50% of non-prod compute costs immediately.

📝 Knowledge Check

Q1. VPA is deployed with updateMode: Auto. A pod's memory request is updated. How does VPA apply the new request?
  • A) VPA hot-patches the running container's cgroup limits without restart
  • B) VPA evicts the pod; when it restarts the new requests are applied by the admission webhook
  • C) VPA updates the Deployment spec and triggers a rolling update
  • D) VPA waits for the next scheduled maintenance window
B) VPA evicts the pod. VPA's Updater component evicts the running pod (respecting PDBs). When the pod is re-created, VPA's Admission Controller webhook mutates the pod spec to inject the new recommended requests before the pod starts. This is why VPA Auto mode can disrupt stateful or single-replica workloads.
Q2. Your cluster uses Cluster Autoscaler with one node group. At peak load CA scales to 20 nodes; at off-peak it should scale down to 5 but stays at 15. What is the most likely cause?
  • A) The --scale-down-unneeded-time is set too high
  • B) Pods without PodDisruptionBudgets are preventing node drain
  • C) Pods have no resources.requests set — CA can't calculate node utilisation
  • D) CA requires manual approval to scale down more than 5 nodes at once
C) Missing resource requests. CA calculates node utilisation based on pod resources.requests. If pods have no requests set, CA sees them as consuming zero resources and considers all nodes "needed" — it cannot safely determine which nodes are safe to remove. Always set resource requests on every pod.
Q3. What is Karpenter's key advantage over Cluster Autoscaler for spot instance usage?
  • A) Karpenter can use reserved instances while CA cannot
  • B) Karpenter dynamically selects from hundreds of instance types per pending pod, maximising spot availability and minimising cost
  • C) Karpenter scales faster because it uses Kubernetes native APIs
  • D) Karpenter integrates with VPA to automatically right-size nodes
B) Dynamic instance type selection. CA requires pre-defined node groups — each group is one instance type. To use spot diversity you need many groups. Karpenter's NodePool can specify 50+ instance types and automatically picks the cheapest available spot option that fits the pending pods, making it far more resilient to spot interruptions and better at bin-packing.