LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Kubernetes Cost Optimization: Rightsizing, Spot Nodes, Bin Packing, Kubecost, and Scale-to-Zero

kubernetescost-optimizationvpakedakubecostfinopskarpenter

Kubernetes makes it easy to over-provision. Default resource requests are conservative, autoscalers react slowly, and nobody deletes staging workloads on Friday afternoon. The result: clusters running at 20-30% utilization while the cloud bill climbs. This guide covers the full stack of cost optimization — from understanding what you’re paying for, to the tools that cut waste without breaking production.

Understanding Where the Money Goes

Before optimizing, instrument. Cloud Kubernetes costs break down into three main buckets:

Compute (60-80% of total): node instance costs. This is where most waste lives — over-provisioned nodes, idle nodes left running, expensive on-demand instances where spot would work.

Storage (10-20%): persistent volumes, object storage for logs/metrics, etcd backups. Often overlooked but can surprise you at scale.

Network (5-15%): cross-AZ data transfer (expensive), load balancer hours, NAT gateway traffic. Cross-AZ egress in AWS is ~$0.01/GB — trivial per request, significant at millions of requests.

The Utilization Baseline

Measure before changing anything:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Node-level CPU and memory utilization
kubectl top nodes

# Pod-level utilization
kubectl top pods -A --sort-by=cpu | head -30
kubectl top pods -A --sort-by=memory | head -30

# Requests vs actual usage per namespace
kubectl resource-capacity -n production --pods --util

# Using kubectl-view-utilization plugin
kubectl view-utilization -h

# Prometheus query: cluster-wide CPU utilization
# (actual usage / total requested)
sum(rate(container_cpu_usage_seconds_total{container!=""}[5m])) /
sum(kube_pod_container_resource_requests{resource="cpu"})

If this ratio is below 0.4 (40%), you have significant over-provisioning. Most greenfield clusters start at 15-25%.

Rightsizing with Vertical Pod Autoscaler (VPA)

The most impactful single change for most clusters: fix your resource requests. Most engineers set requests once at deployment time and never revisit them. VPA watches actual usage and recommends (or automatically sets) better values.

Install VPA

1
2
3
4
5
6
7
8
9
git clone https://github.com/kubernetes/autoscaler.git
cd autoscaler/vertical-pod-autoscaler
./hack/vpa-up.sh

# Verify
kubectl get pods -n kube-system | grep vpa
# vpa-admission-controller-...  Running
# vpa-recommender-...           Running
# vpa-updater-...               Running

VPA Recommendation Mode (Safe Starting Point)

Start in Off mode — VPA observes and recommends but doesn’t change anything:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: web-api-vpa
  namespace: production
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: web-api
  updatePolicy:
    updateMode: "Off"    # Recommend only, don't apply
  resourcePolicy:
    containerPolicies:
      - containerName: web-api
        minAllowed:
          cpu: 50m
          memory: 64Mi
        maxAllowed:
          cpu: 4
          memory: 4Gi
        controlledResources: ["cpu", "memory"]

After a few days, check recommendations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
kubectl get vpa web-api-vpa -n production -o yaml

# Output shows:
# status:
#   recommendation:
#     containerRecommendations:
#     - containerName: web-api
#       lowerBound:
#         cpu: 120m
#         memory: 256Mi
#       target:              ← Use this for requests
#         cpu: 250m
#         memory: 512Mi
#       upperBound:          ← Use this for limits
#         cpu: 800m
#         memory: 1Gi
#       uncappedTarget:
#         cpu: 250m
#         memory: 512Mi

Common finding: containers declared with requests: cpu: 500m, memory: 1Gi actually use cpu: 80m, memory: 200Mi. Fixing this immediately frees cluster capacity.

VPA Auto Mode

Once you trust the recommendations, switch to Auto to let VPA apply changes:

1
2
3
4
spec:
  updatePolicy:
    updateMode: "Auto"      # Evict pods to apply new requests
    # Or "Initial" — only set on new pods, don't evict existing ones

Important caveats:

  • VPA in Auto mode evicts pods to apply changes — ensure your Deployments have minReadySeconds and enough replicas to tolerate rolling updates
  • VPA and HPA conflict when both target CPU — use VPA for memory and custom metrics-based HPA for CPU, or use the KEDA approach below
  • VPA needs at least 8 data points (by default) before making recommendations — give it a week of real traffic

Reading VPA Recommendations Programmatically

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/usr/bin/env python3
"""Extract VPA recommendations and output as a rightsizing report."""
import subprocess, json, sys

def get_vpa_recommendations(namespace="--all-namespaces"):
    result = subprocess.run(
        ["kubectl", "get", "vpa", "-n", namespace, "-o", "json"]
        if namespace != "--all-namespaces"
        else ["kubectl", "get", "vpa", "-A", "-o", "json"],
        capture_output=True, text=True
    )
    return json.loads(result.stdout)

vpas = get_vpa_recommendations()
print(f"{'VPA':<40} {'Container':<20} {'CPU Target':<12} {'Mem Target':<12}")
print("-" * 90)

for item in vpas.get("items", []):
    name = item["metadata"]["name"]
    ns = item["metadata"]["namespace"]
    recs = item.get("status", {}).get("recommendation", {}).get("containerRecommendations", [])
    for rec in recs:
        container = rec["containerName"]
        cpu = rec.get("target", {}).get("cpu", "N/A")
        mem = rec.get("target", {}).get("memory", "N/A")
        print(f"{ns}/{name:<38} {container:<20} {cpu:<12} {mem:<12}")

Spot and Preemptible Nodes

Spot instances (AWS) / preemptible VMs (GCP) / spot VMs (Azure) cost 60-90% less than on-demand. The trade-off: the cloud provider can reclaim them with 2 minutes notice. For the right workloads, this is an excellent deal.

Which Workloads Fit Spot

Good for spot:

  • Batch jobs, data processing, ML training
  • Stateless web tier (multiple replicas — losing one is fine)
  • CI/CD runners
  • Dev and staging environments
  • Any workload that can tolerate a restart

Keep on on-demand:

  • Control plane nodes
  • Stateful databases (unless using a cloud-managed database)
  • Single-replica critical services
  • Jobs with tight deadlines

Karpenter: The Modern Node Provisioner

Karpenter replaces the Cluster Autoscaler for AWS (and increasingly GCP/Azure). It’s smarter about node selection — it picks the optimal instance type for the pending pods rather than just scaling a fixed node group.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Install Karpenter (AWS)
helm repo add karpenter https://charts.karpenter.sh/
helm repo update

helm upgrade --install karpenter karpenter/karpenter \
  --namespace karpenter --create-namespace \
  --set settings.clusterName=$CLUSTER_NAME \
  --set settings.interruptionQueue=$KARPENTER_SQS_QUEUE \
  --set controller.resources.requests.cpu=1 \
  --set controller.resources.requests.memory=1Gi
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# NodePool: define what nodes Karpenter can provision
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
  name: general-purpose
spec:
  template:
    metadata:
      labels:
        nodepool: general-purpose
    spec:
      nodeClassRef:
        apiVersion: karpenter.k8s.aws/v1beta1
        kind: EC2NodeClass
        name: default
      requirements:
        # Allow many instance families for better spot availability
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: [m5, m5a, m5n, m6i, m6a, m7i, m7a, c5, c6i, c7i]
        - key: karpenter.k8s.aws/instance-size
          operator: In
          values: [large, xlarge, 2xlarge]
        # Prefer spot, fall back to on-demand
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
        - key: kubernetes.io/arch
          operator: In
          values: [amd64, arm64]    # Allow Graviton for better price/perf
  disruption:
    consolidationPolicy: WhenUnderutilized   # Remove underused nodes
    consolidateAfter: 30s
  limits:
    cpu: 1000                 # Cluster-wide limit on this pool
    memory: 4000Gi

---
apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
metadata:
  name: default
spec:
  amiFamily: AL2
  role: KarpenterNodeRole-$CLUSTER_NAME
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: $CLUSTER_NAME
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: $CLUSTER_NAME
  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        volumeSize: 100Gi
        volumeType: gp3
        iops: 3000
        throughput: 125
        encrypted: true

Tolerations for Spot Nodes

Mark spot nodes with a taint and add tolerations only to workloads that can handle interruption:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Karpenter automatically taints spot nodes:
# karpenter.sh/capacity-type=spot:NoSchedule

# Workloads that tolerate spot
spec:
  template:
    spec:
      tolerations:
        - key: karpenter.sh/capacity-type
          operator: Equal
          value: spot
          effect: NoSchedule
      # Also use affinity to prefer spot but allow on-demand
      affinity:
        nodeAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              preference:
                matchExpressions:
                  - key: karpenter.sh/capacity-type
                    operator: In
                    values: [spot]

Handling Spot Interruptions Gracefully

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# PodDisruptionBudget — ensure at least N pods stay running during interruption
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: web-api-pdb
  namespace: production
spec:
  minAvailable: 2      # Or use maxUnavailable: 1
  selector:
    matchLabels:
      app: web-api
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Graceful shutdown — give pods time to drain connections
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: web-api
          lifecycle:
            preStop:
              exec:
                # Signal the app to stop accepting new requests
                command: ["/bin/sh", "-c", "sleep 5"]

Karpenter watches for spot interruption notices (via SQS) and drains nodes proactively before the 2-minute deadline, giving pods a clean shutdown.

Bin Packing: Fitting More Pods Per Node

Even with correct resource requests, poor scheduling decisions leave nodes half-empty while others are full. The scheduler’s default LeastAllocated strategy spreads pods across nodes for resilience — good for fault tolerance, bad for cost.

Descheduler: Rebalance After the Fact

The Descheduler evicts pods that landed suboptimally, letting the scheduler re-place them better:

1
2
3
helm repo add descheduler https://kubernetes-sigs.github.io/descheduler/
helm upgrade --install descheduler descheduler/descheduler \
  --namespace kube-system
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
apiVersion: v1
kind: ConfigMap
metadata:
  name: descheduler-policy
  namespace: kube-system
data:
  policy.yaml: |
    apiVersion: "descheduler/v1alpha2"
    kind: "DeschedulerPolicy"
    profiles:
      - name: default
        pluginConfig:
          - name: DefaultEvictor
            args:
              ignorePvcPods: true
              evictLocalStoragePods: false
              nodeFit: true
        plugins:
          balance:
            enabled:
              - RemovePodsViolatingTopologySpreadConstraints
              - LowNodeUtilization
          deschedule:
            enabled:
              - RemoveDuplicates
              - RemovePodsViolatingNodeAffinity
              - RemovePodsViolatingInterPodAntiAffinity

          # Key plugin: consolidate pods onto fewer nodes
          balance:
            enabled:
              - LowNodeUtilization
        pluginConfig:
          - name: LowNodeUtilization
            args:
              thresholds:
                cpu: 20       # Nodes below 20% CPU are "underutilized"
                memory: 20
                pods: 20
              targetThresholds:
                cpu: 50       # Evict pods until node is below 50%
                memory: 50
                pods: 50

Karpenter Consolidation

Karpenter’s consolidationPolicy: WhenUnderutilized automatically removes underutilized nodes and reschedules their pods. This is bin packing on autopilot:

1
2
3
4
5
6
7
spec:
  disruption:
    consolidationPolicy: WhenUnderutilized
    consolidateAfter: 30s    # How long to wait before consolidating
    # budgets: limit how many nodes can be disrupted simultaneously
    budgets:
      - nodes: "10%"         # Don't disrupt more than 10% of nodes at once

Topology Spread Constraints for Cost-Aware Spreading

Instead of spreading pods evenly across all AZs (expensive cross-AZ traffic), prefer the cheapest AZ while maintaining basic resilience:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
spec:
  template:
    spec:
      topologySpreadConstraints:
        - maxSkew: 2
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway    # Soft constraint
          labelSelector:
            matchLabels:
              app: web-api

Scale-to-Zero with KEDA

KEDA (Kubernetes Event-Driven Autoscaler) extends HPA to scale workloads to zero when there’s no work to do, and back up instantly when demand returns. For batch workloads, background jobs, and low-traffic services, this can eliminate compute cost entirely during idle periods.

Install KEDA

1
2
3
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace

Scale to Zero on Queue Depth

The most common KEDA pattern: scale workers based on queue depth.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# ScaledObject: scale the order-processor Deployment based on SQS queue depth
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: order-processor-scaler
  namespace: production
spec:
  scaleTargetRef:
    name: order-processor
  minReplicaCount: 0        # Scale to zero when queue is empty
  maxReplicaCount: 50       # Max 50 workers
  pollingInterval: 15       # Check queue every 15 seconds
  cooldownPeriod: 60        # Wait 60s after queue drains before scaling to 0

  triggers:
    - type: aws-sqs-queue
      authenticationRef:
        name: keda-aws-credentials
      metadata:
        queueURL: https://sqs.us-east-1.amazonaws.com/123456789/orders
        queueLength: "5"        # Target: 5 messages per worker
        awsRegion: us-east-1
        identityOwner: operator
1
2
3
4
5
6
7
8
9
# TriggerAuthentication: how KEDA authenticates with AWS
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: keda-aws-credentials
  namespace: production
spec:
  podIdentity:
    provider: aws            # Use IRSA (IAM Roles for Service Accounts)

Scale on Cron Schedule

For predictable traffic patterns, pre-scale before demand arrives:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: business-hours-scaler
  namespace: production
spec:
  scaleTargetRef:
    name: report-generator
  minReplicaCount: 0
  maxReplicaCount: 20
  triggers:
    - type: cron
      metadata:
        timezone: America/New_York
        start: "0 8 * * 1-5"     # 8 AM weekdays — scale up
        end: "0 19 * * 1-5"      # 7 PM weekdays — scale down
        desiredReplicas: "10"

Scale on Prometheus Metrics

Scale based on any metric in Prometheus — request rate, queue latency, custom business metrics:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: api-scaler
  namespace: production
spec:
  scaleTargetRef:
    name: api-server
  minReplicaCount: 0
  maxReplicaCount: 100
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: http_requests_per_second
        threshold: "100"       # Scale up when > 100 req/s per replica
        query: |
          sum(rate(http_requests_total{service="api-server"}[2m]))

Scale on Kafka Lag

For Kafka consumers, scale on consumer group lag:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: kafka-consumer-scaler
  namespace: production
spec:
  scaleTargetRef:
    name: kafka-consumer
  minReplicaCount: 0
  maxReplicaCount: 30
  triggers:
    - type: kafka
      metadata:
        bootstrapServers: kafka.production.svc:9092
        consumerGroup: order-processor-group
        topic: orders
        lagThreshold: "100"     # 100 messages lag per consumer
        offsetResetPolicy: latest

Scale-to-Zero for Preview Environments

One of the highest-ROI KEDA use cases: preview (PR) environments that scale to zero overnight and on weekends:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: preview-pr-123-scaler
  namespace: preview-pr-123
spec:
  scaleTargetRef:
    name: app
  minReplicaCount: 0
  maxReplicaCount: 2
  triggers:
    # Scale up during business hours only
    - type: cron
      metadata:
        timezone: UTC
        start: "0 8 * * 1-5"
        end: "0 19 * * 1-5"
        desiredReplicas: "1"
    # OR: scale up when there's HTTP traffic (using Prometheus/NGINX metrics)
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        metricName: nginx_ingress_requests
        threshold: "1"
        query: |
          sum(rate(nginx_ingress_controller_requests{
            namespace="preview-pr-123"
          }[5m]))

Measuring Cost with Kubecost

Kubecost allocates cloud costs to Kubernetes namespaces, deployments, labels, and teams. The free tier covers most needs for a single cluster.

Install Kubecost

1
2
3
4
5
6
7
8
9
helm repo add kubecost https://kubecost.github.io/cost-analyzer/
helm upgrade --install kubecost kubecost/cost-analyzer \
  --namespace kubecost --create-namespace \
  --set kubecostToken="your-token" \
  --set global.prometheus.fqdn=http://prometheus.monitoring.svc:9090 \
  --set global.prometheus.enabled=false    # Use existing Prometheus

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

Key Kubecost Views

Cost allocation: break down spend by namespace, label, deployment, or team. Use this to answer “which team is spending the most?” and drive accountability.

Savings insights: Kubecost identifies specific underutilized workloads and quantifies the savings from rightsizing.

Cost efficiency: ratio of actual cost to requested resources vs actual usage. A cluster at 30% efficiency has a lot of headroom.

Automated Savings Reports via API

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import requests

KUBECOST_URL = "http://localhost:9090"

# Get namespace cost breakdown for last 7 days
response = requests.get(
    f"{KUBECOST_URL}/model/allocation",
    params={
        "window": "7d",
        "aggregate": "namespace",
        "accumulate": "true",
    }
)

allocations = response.json()["data"][0]
results = []
for namespace, data in allocations.items():
    results.append({
        "namespace": namespace,
        "total_cost": round(data["totalCost"], 2),
        "cpu_cost": round(data["cpuCost"], 2),
        "memory_cost": round(data["ramCost"], 2),
        "efficiency": round(data.get("totalEfficiency", 0) * 100, 1),
    })

results.sort(key=lambda x: x["total_cost"], reverse=True)
print(f"{'Namespace':<30} {'7d Cost':>10} {'CPU':>8} {'Mem':>8} {'Efficiency':>12}")
print("-" * 72)
for r in results[:20]:
    print(f"{r['namespace']:<30} ${r['total_cost']:>9} ${r['cpu_cost']:>7} ${r['memory_cost']:>7} {r['efficiency']:>10}%")

Cost Allocation Labels

Tag your workloads so Kubecost can group costs by team and product:

1
2
3
4
5
6
7
8
# Standard label set for cost attribution
metadata:
  labels:
    app.kubernetes.io/name: web-api
    app.kubernetes.io/part-of: payments-platform
    team: payments
    cost-center: "cc-1234"
    environment: production
1
2
# Kubecost query by team label
curl "http://localhost:9090/model/allocation?window=30d&aggregate=label:team"

LimitRanges and ResourceQuotas: Governance at Scale

Without governance, a misconfigured deployment can request all cluster resources. LimitRanges and ResourceQuotas prevent this.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# LimitRange: default requests/limits for containers that don't specify them
apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: production
spec:
  limits:
    - type: Container
      default:
        cpu: 500m
        memory: 256Mi
      defaultRequest:
        cpu: 100m
        memory: 128Mi
      max:
        cpu: "4"
        memory: 4Gi
      min:
        cpu: 50m
        memory: 64Mi
    - type: PersistentVolumeClaim
      max:
        storage: 100Gi
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# ResourceQuota: hard caps per namespace
apiVersion: v1
kind: ResourceQuota
metadata:
  name: production-quota
  namespace: production
spec:
  hard:
    requests.cpu: "100"         # Total requested CPU across all pods
    requests.memory: 200Gi
    limits.cpu: "200"
    limits.memory: 400Gi
    count/pods: "500"
    count/services: "50"
    count/persistentvolumeclaims: "100"
    requests.storage: 5Ti

Practical Cost Reduction Playbook

Apply these in order — earlier items have lower risk and higher ROI:

Week 1: Measure and Identify Waste

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Find pods with no resource requests (they get 0 priority from VPA/scheduler)
kubectl get pods -A -o json | jq -r '
  .items[] |
  select(.spec.containers[].resources.requests == null) |
  [.metadata.namespace, .metadata.name] | @tsv
'

# Find deployments with only 1 replica (no HA, no spot tolerance)
kubectl get deployments -A -o json | jq -r '
  .items[] |
  select(.spec.replicas == 1) |
  [.metadata.namespace, .metadata.name, (.spec.replicas | tostring)] | @tsv
'

# Find namespaces without ResourceQuota
kubectl get resourcequota -A -o json | jq -r '[.items[].metadata.namespace] | unique | .[]' > has_quota.txt
kubectl get namespaces -o json | jq -r '.items[].metadata.name' > all_namespaces.txt
comm -23 <(sort all_namespaces.txt) <(sort has_quota.txt)

# Find PVCs that aren't mounted (orphaned storage)
kubectl get pvc -A -o json | jq -r '
  .items[] |
  select(.status.phase == "Bound") |
  select(.metadata.annotations["volume.kubernetes.io/storage-provisioner"] != null) |
  [.metadata.namespace, .metadata.name, .spec.resources.requests.storage] | @tsv
' | head -30

Week 2: Rightsize with VPA

Deploy VPA in Off mode everywhere, collect 1 week of data, apply recommendations to staging first, then production.

Week 3: Enable Spot for Eligible Workloads

Move stateless Deployments (web tier, API servers, background workers) to spot node groups or Karpenter NodePools that prefer spot.

Week 4: KEDA Scale-to-Zero

Enable KEDA for batch workloads, queue consumers, and non-production environments.

Week 5+: Continuous Optimization

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Kyverno policy: require resource requests on all containers
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-resource-requests
spec:
  validationFailureAction: enforce
  rules:
    - name: check-requests
      match:
        resources:
          kinds: [Pod]
      validate:
        message: "CPU and memory requests are required"
        pattern:
          spec:
            containers:
              - resources:
                  requests:
                    memory: "?*"
                    cpu: "?*"

Quick Reference: Cost Levers and Expected Impact

Optimization Typical Savings Complexity Risk
VPA rightsizing 20-40% Low Low (staging first)
Spot/preemptible nodes 50-70% on eligible workloads Medium Medium (interruptions)
KEDA scale-to-zero (non-prod) 60-80% on non-prod Medium Low
Karpenter consolidation 10-20% Low Low
Delete unused PVCs/LBs 5-15% Low None
Remove unused namespaces/clusters Variable Low None
Reserved instances for baseline 30-40% on committed spend Low Medium (commitment)
Graviton/Arm instances 20% on compute Medium Low
Cross-AZ traffic reduction 5-15% Medium Low

The biggest gains are almost always VPA rightsizing (people wildly overestimate what their pods need) and spot instances (trading availability SLA for 70% cost reduction). Start there, measure, then proceed to the more complex options.

Cost optimization in Kubernetes is not a one-time project — it’s a continuous practice. The infrastructure that’s correctly sized today will be over-provisioned in six months as traffic patterns change. Build the measurement tooling (Kubecost or equivalent), automate the governance (LimitRanges, ResourceQuotas, Kyverno policies), and treat the cluster like a product that requires ongoing care.

Comments