Kubernetes Cost Optimization: Cutting Pod and Node Spend

A practical Kubernetes cost optimization guide: overprovisioning fixes, HPA vs VPA vs Cluster Autoscaler, spot instance pools and resource tuning with YAML.

Xplorr team

The people who build Xplorr

Updated 10 min read
Kubernetes Cost Optimization: Cutting Pod and Node Spend
In this post
  1. Why do Kubernetes clusters cost more than they use?
  2. What does that gap cost in nodes?
  3. How do you measure actual pod usage?
  4. Which query shows the waste?
  5. How do you right-size resource requests?
  6. HPA vs VPA vs Cluster Autoscaler: which should you use?
  7. When should you use the Horizontal Pod Autoscaler?
  8. When should you use the Vertical Pod Autoscaler?
  9. How do you configure the Cluster Autoscaler for cost?
  10. Which workloads can run on Spot instances?
  11. How do you set up mixed node pools on EKS?
  12. How do you pin workloads to the right pool?
  13. Why do you need a Pod Disruption Budget?
  14. How much does scheduling dev clusters save?
  15. What does the whole change add up to?
  16. Which Kubernetes cost mistakes are most common?
  17. What should you do first?

Kubernetes cost is set by the CPU and memory requests in your YAML, not by traffic. The scheduler packs pods against requests, the cluster autoscaler buys nodes until every request fits, and you pay for nodes. So the highest-leverage change is measuring real usage and lowering requests to match, then autoscaling, then Spot, then switching non-production off overnight. In that order.

Requests are almost always written from fear rather than from measurement. This guide walks through each change, with the YAML to make it and the arithmetic to price it against published us-east-1 list rates.

Infographic titled Kubernetes cost is set in YAML. A worked example of 28 pods on m5.xlarge nodes: 8 nodes and $1,121.28 a month while requesting 1000m each, 2 nodes after setting requests from P95 usage, and about $196 a month with one node on Spot at an assumed 60% discount. A five step order (measure, right-size, autoscale nodes, Spot, schedule dev) and a table of five deployments whose CPU requests fall from 4,250m to 1,030m (76% less) and memory from 8.5Gi to 2.3Gi (73% less). A sidebar of common mistakes.
Same pods, fewer nodes: right-size requests first, then everything else.

Why do Kubernetes clusters cost more than they use?

A cluster’s bill tracks what pods request, not what they use. Actual consumption never enters the calculation.

Requests get set high for a completely rational reason: nobody wants to be the person whose pod got OOMKilled in production at 2 AM. So engineers set requests high, then set limits higher, and the autoscaler dutifully buys the hardware to back the request. The gap between requested and used is the bill you did not intend to pay. Xplorr’s Kubernetes view reads that gap per workload from OpenCost and suggests a lower request based on the observed peak.

Here’s what that looks like on a typical API service deployment:

# Before: the "better safe than sorry" configuration
resources:
  requests:
    cpu: "1000m"
    memory: "2Gi"
  limits:
    cpu: "2000m"
    memory: "4Gi"

Suppose kubectl top pods shows this container averaging 120m CPU and 340Mi memory. That is 12% CPU and 17% memory utilization against requests. The scheduler reserved a full core and 2 GiB for a pod using a fraction of it.

What does that gap cost in nodes?

Multiply it out. Twenty-eight pods requesting 1000m each is 28 vCPU of requests. An m5.xlarge EKS node has 4 vCPU, of which roughly 3.92 are allocatable once the kubelet and system reservations are taken out, so the autoscaler needs 8 nodes to fit them.

At the $0.192/hr on-demand list price, that is 8 x 0.192 x 730 = $1,121.28/month to schedule pods that between them consume about 3.4 vCPU. The same 3.4 vCPU of real work fits comfortably on one node. Seven of the eight exist only because of numbers in YAML.

How do you measure actual pod usage?

You cannot optimize what you haven’t measured. Before touching any resource specs, collect at least 7 days of actual usage, ideally 14 to capture weekly traffic patterns. If you are choosing between a cluster only tool and one that also covers the cloud bill around it, Xplorr vs Kubecost sets the two side by side.

# Current resource requests vs actual usage
kubectl top pods -n production --containers

# Node-level utilization
kubectl top nodes

That gives you a snapshot. For historical data you need Prometheus.

Metric What it tells you
container_cpu_usage_seconds_total Actual CPU consumed per container
container_memory_working_set_bytes Real memory in use (not cache)
kube_pod_container_resource_requests What the scheduler reserved
kube_pod_container_resource_limits The ceiling before throttling or OOMKill
node_cpu_seconds_total Node-level CPU consumption

Which query shows the waste?

The ratio of requested CPU to consumed CPU is the single number worth watching:

# CPU request efficiency (higher = more waste)
sum(kube_pod_container_resource_requests{resource="cpu"}) by (namespace)
/
sum(rate(container_cpu_usage_seconds_total{container!=""}[5m])) by (namespace)

If this ratio is above 3, you’re paying for 3x the CPU you’re using. Run it per namespace rather than cluster-wide: the average hides which team’s manifests are driving the node count, and the fix is always a pull request against one team’s charts rather than a cluster-level setting.

How do you right-size resource requests?

This is where most of the savings come from. Not autoscaling, not Spot instances. Just setting requests to match measured usage with a reasonable buffer.

The formula: set requests to the P95 of actual usage plus a 20% buffer. Set limits to 2x requests for CPU, which allows bursting, and 1.5x for memory, which catches runaway leaks without being so tight that normal operation trips it.

Here’s the shape of the change across five deployments of the kind that dominate most clusters:

Service CPU request (before) CPU request (after) Memory request (before) Memory request (after)
api-gateway 1000m 200m 2Gi 512Mi
auth-service 500m 100m 1Gi 256Mi
worker-processor 2000m 500m 4Gi 1Gi
web-frontend 500m 150m 1Gi 384Mi
notification-svc 250m 80m 512Mi 192Mi

Those five go from 4,250m of CPU requests to 1,030m, a 76% reduction, and from 8.5Gi of memory requests to 2.3Gi, a 73% reduction. Nothing about the workloads changed. Only the numbers the scheduler reads did.

# After: based on actual measured usage
resources:
  requests:
    cpu: "200m"
    memory: "512Mi"
  limits:
    cpu: "400m"
    memory: "768Mi"

HPA vs VPA vs Cluster Autoscaler: which should you use?

They solve different problems and are not alternatives to each other. HPA changes how many pods you run. VPA changes how big each pod is. The Cluster Autoscaler changes how many nodes exist to hold them.

Autoscaler Changes Use it for Avoid when
HPA Replica count Stateless services that scale out Single-instance or slow-starting workloads
VPA Requests and limits Workloads whose right size is hard to guess You already run HPA on the same metric
Cluster Autoscaler Node count Turning freed requests into fewer nodes Never, if you want the savings to land

Only the Cluster Autoscaler actually removes cost. HPA and VPA change what you request; the Cluster Autoscaler is what converts a smaller request total into a smaller bill. Right-sizing without it just leaves half-empty nodes running.

When should you use the Horizontal Pod Autoscaler?

Use HPA when your workload handles more load by running more replicas: stateless web servers, API services, queue consumers. Avoid it for single-instance databases, stateful services that can’t scale horizontally, and anything that takes five minutes to start.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-gateway-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-gateway
  minReplicas: 2
  maxReplicas: 8
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 25
          periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 30
      policies:
        - type: Percent
          value: 50
          periodSeconds: 60
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 75

Four decisions in that config carry the cost impact:

  • minReplicas: 2, not 1. You need at least 2 for availability during rolling deploys.
  • scaleDown.stabilizationWindowSeconds: 300 waits five minutes before scaling down, which prevents flapping during brief traffic dips.
  • scaleUp is faster than scaleDown. React quickly to load increases, be cautious about removing capacity.
  • Target utilization at 70%, not 50%. A 50% target means you permanently run double the pods you need.

When should you use the Vertical Pod Autoscaler?

Use VPA when the right resource request is hard to guess: batch jobs with variable needs, services with unpredictable memory profiles. Do not use it alongside HPA on the same metric, because VPA and HPA on CPU will fight each other.

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: worker-processor-vpa
  namespace: production
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: worker-processor
  updatePolicy:
    updateMode: "Auto"
  resourcePolicy:
    containerPolicies:
      - containerName: worker
        minAllowed:
          cpu: "100m"
          memory: "128Mi"
        maxAllowed:
          cpu: "2000m"
          memory: "4Gi"
        controlledResources: ["cpu", "memory"]

VPA in Auto mode restarts pods to apply new resource values. In production, start with updateMode: "Off", which gives you recommendations without applying them. Review them, then switch to Auto once you trust them.

# Check VPA recommendations
kubectl describe vpa worker-processor-vpa -n production

How do you configure the Cluster Autoscaler for cost?

The Cluster Autoscaler adds or removes nodes based on pending pods. A pod can’t be scheduled, so a new node spins up. A node is underutilized, so it gets drained and removed. Every time you right-size requests or scale down replicas, it is what consolidates the workloads onto fewer nodes.

# cluster-autoscaler deployment args
- --scale-down-utilization-threshold=0.5
- --scale-down-delay-after-add=10m
- --scale-down-delay-after-delete=1m
- --scale-down-unneeded-time=5m
- --skip-nodes-with-local-storage=false
- --balance-similar-node-groups=true
  • scale-down-utilization-threshold=0.5 removes a node once it drops below 50% utilized. That is also the default and it is reasonable. Setting it aggressively (0.3) churns nodes constantly.
  • scale-down-delay-after-add=10m stops a just-added node from being removed immediately, giving workloads time to stabilize.

Which workloads can run on Spot instances?

Spot instances (AWS), Spot VMs (Azure) and Spot VMs (GCP) run up to 90% below on-demand. The catch: the provider can reclaim them with two minutes notice.

Suits Spot:

  • CI/CD runners
  • Batch processing jobs
  • Dev and staging environments
  • Stateless workers behind a queue, where a dead pod means another picks up the message
  • HPA-managed services with enough replicas that losing one or two pods is fine

Should stay on-demand:

  • Databases and stateful services
  • Services with only one or two replicas, where losing one means downtime
  • Anything with a startup time over five minutes

How do you set up mixed node pools on EKS?

# managed node group with mixed instances
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
  name: production-cluster
  region: us-east-1

managedNodeGroups:
  - name: on-demand-critical
    instanceType: m5.large
    minSize: 2
    maxSize: 4
    labels:
      workload-type: critical
    taints:
      - key: dedicated
        value: critical
        effect: NoSchedule

  - name: spot-workers
    instanceTypes:
      - m5.large
      - m5a.large
      - m5d.large
      - m4.large
    spot: true
    minSize: 0
    maxSize: 10
    labels:
      workload-type: spot-tolerant

Diversifying instance types in the Spot pool is the part people skip. If you bid on one instance type and that pool gets reclaimed, all your Spot nodes go away at once. Four or more types from different families spreads that risk.

How do you pin workloads to the right pool?

Node affinity and tolerations control placement. Critical work gets a hard requirement plus a toleration for the taint:

# For critical workloads, on-demand only
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - key: workload-type
                operator: In
                values:
                  - critical
  tolerations:
    - key: dedicated
      operator: Equal
      value: critical
      effect: NoSchedule

Spot-tolerant work gets a soft preference, so it still schedules if the Spot pool is empty:

# For spot-tolerant workloads
spec:
  affinity:
    nodeAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 100
          preference:
            matchExpressions:
              - key: workload-type
                operator: In
                values:
                  - spot-tolerant

Why do you need a Pod Disruption Budget?

When a Spot node is reclaimed, Kubernetes drains it. Without a budget, a drain can take out every replica of a service at once. A PodDisruptionBudget sets the floor:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-gateway-pdb
  namespace: production
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: api-gateway

This guarantees at least two api-gateway pods are running at all times, including during node drains. Set one for every service you put on a Spot pool.

How much does scheduling dev clusters save?

Dev and staging clusters running 24/7 is pure waste. A dev cluster up from 7 AM to 8 PM on weekdays runs about 283 of the 730 hours in a month, so it costs roughly 61% less than one running around the clock.

For EKS, scale the node group to zero outside working hours:

# Scale down at 8 PM
aws eks update-nodegroup-config \
  --cluster-name dev-cluster \
  --nodegroup-name dev-nodes \
  --scaling-config minSize=0,maxSize=3,desiredSize=0

# Scale up at 7 AM
aws eks update-nodegroup-config \
  --cluster-name dev-cluster \
  --nodegroup-name dev-nodes \
  --scaling-config minSize=1,maxSize=3,desiredSize=2

Automate it with a CronJob or a Lambda on an EventBridge schedule. Note that the EKS control plane bills whether or not any nodes exist, so scaling to zero cuts the node line and not the cluster fee.

What does the whole change add up to?

Take the 28-pod cluster from the start of this post back through each step, on m5.xlarge nodes in us-east-1 with about 3.92 allocatable vCPU each.

Item List price Per node per month
m5.xlarge on-demand $0.192/hr $140.16
m5.xlarge on Spot, at a 60% discount up to 90% off on-demand $56.06

Prices checked September 2026, us-east-1. Spot rates float with capacity, so 60% is a conservative working assumption rather than a quoted rate.

Starting point. 28 pods x 1000m requested = 28 vCPU. At 3.92 allocatable per node, that needs 8 nodes.

8 nodes x $140.16 = $1,121.28/month

After right-sizing requests. Set requests from measured P95 plus a buffer and the same pods request 200m each: 28 x 200m = 5.6 vCPU. That fits on 2 nodes.

2 nodes x $140.16 = $280.32/month
Saving:             $840.96/month (75%)

No pod moved, no service changed, nothing scaled down. The autoscaler removed six nodes because six nodes’ worth of reservation stopped existing.

After moving batch workers to Spot. If one of the two remaining nodes carries interruption-tolerant work and moves to a Spot pool at a 60% discount, its $140.16 becomes about $56.06, taking the cluster to roughly $196/month.

The order matters. Right-sizing first is what makes the node count small. Buying Spot or reservations first just locks in a discount on hardware you did not need.

Which Kubernetes cost mistakes are most common?

Setting requests equal to limits. This is the “Guaranteed” QoS class. It means no bursting and no flexibility: every pod gets exactly what it requests even when it uses 10% of it. Reserve Guaranteed QoS for your most latency-sensitive services.

Autoscaling on the wrong metric. HPA on CPU is fine for compute-bound services. If your service is I/O-bound, waiting on database queries or external APIs, CPU stays low even when the service is overloaded. Scale on request latency or queue depth instead.

Using container_memory_usage_bytes. It includes filesystem cache, which inflates memory numbers and leads you to over-request. Always right-size against container_memory_working_set_bytes, which reflects actual application memory.

Skipping namespace resource quotas. Without quotas, one team can claim all cluster resources by accident:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-api-quota
  namespace: team-api
spec:
  hard:
    requests.cpu: "4"
    requests.memory: "8Gi"
    limits.cpu: "8"
    limits.memory: "16Gi"

What should you do first?

Run kubectl top pods --containers across your namespaces right now and compare the output to your resource requests. The gap between those two numbers, multiplied by your node price, is what you’re overpaying.

Closing it is configuration work rather than architecture. Tedious, unglamorous configuration work, with a node count on the other side of it that you can price directly against the table above.

If you want continuous visibility into that gap without manually querying Prometheus, Xplorr tracks resource efficiency across your clusters and flags overprovisioned workloads automatically. But even without tooling, start measuring.


Keep reading

See how Xplorr helps → Features


Xplorr shows your AWS, Azure and GCP spend in one place, with anomaly detection, budgets and rightsizing recommendations. Get started free.

ShareLinkedInX

Written by

Xplorr team

The people who build Xplorr

Written together by the engineers who build Xplorr: the AWS, Azure, GCP and Kubernetes collectors, the console, and the alerting behind them.

About Xplorr

Related posts

All articles

Free plan, no credit card

See your AWS, Azure and GCP spend in one place

Connect a cloud account and find what is driving the bill. Start on the Free plan for 1 cloud account, no credit card required.