How To

Kubernetes Cost Optimization: 6 Ways Your Cluster Leaks Money

Six places Kubernetes clusters leak money, from padded requests to orphaned volumes and static capacity, each with a detection command for EKS, GKE and AKS. Explains why the cloud bill can't show any of it: Kubernetes spends in requests, not usage.

10 min read
Cover Image for Kubernetes Cost Optimization: 6 Ways Your Cluster Leaks Money

Kubernetes Cost Optimization: 6 Ways Your Cluster Leaks Money

Pick any production cluster and compare what pods request with what they actually use. On most clusters the gap is startling: workloads requesting 2 CPU cores while averaging 150 millicores, requesting 4 GiB of memory while touching 800 MiB. The scheduler reserves what you request, the autoscaler adds nodes to satisfy those requests, and your cloud provider bills you for every one of those nodes, used or not.

That gap is where Kubernetes cost optimization starts, but it isn't the only leak. Cluster spend hides in places the monthly cloud bill never itemizes: a node that's 30% packed, a persistent volume whose pod was deleted in March, a load balancer in front of a service with zero endpoints. The bill says "EC2" or "Compute Engine". It never says "your requests are five times too big."

The pattern repeats across EKS, GKE and AKS. Below are the six leaks worth checking first: what each one is, why it happens, a detection command you can run today, and the impact range teams typically find.

Part two is a hands-on Kubernetes cost audit using only native and free tools. Part three covers automating the whole loop with an AI agent.

01Pods

Padded requests

Requests set once at launch, three to five times what the container uses.

02Nodes

Underpacked nodes

Fragmentation and blocked scale-down leave ten nodes doing six nodes of work.

03Storage

Orphaned volumes

Released PVs and cloud disks that outlive the StatefulSet or the cluster.

04Network

Empty load balancers

LoadBalancer Services with no endpoints, billing every hour.

05Namespaces

Forgotten environments

Preview and experiment namespaces holding all of the above.

06Scaling

Static capacity

No HPA, no node autoscaling, non-prod running at peak around the clock.

The six leaks this guide covers, each with a detection command you can run today.

Why the bill can't show you this

Before the leaks, the mechanism, because it explains why every one of them is invisible from the billing console. Kubernetes spends money in requests. The scheduler places pods by their requests, the Cluster Autoscaler adds nodes when pending pods can't fit by their requests, and the cloud bills nodes. Actual usage never enters the calculation:

01YAML

Request 2 CPU

A padded number copied from another deployment.

02Scheduler

Reserves it

The node is marked full whether the pod uses 2 cores or 150m.

03Autoscaler

Adds a node

Pending pods trigger scale-up against requests, not usage.

04Cloud bill

Pays for air

Every node is billed by the hour, used or not.

Requests are the unit Kubernetes spends money in. Usage never enters the calculation.

So a padded request is a reservation that turns straight into a node. The cloud bill sees the node and nothing below it. Finding waste means going under the bill, into the cluster, and comparing the numbers Kubernetes acts on with the numbers that describe reality.

1. Overprovisioned resource requests and limits

What it is. Kubernetes resource requests are a reservation, not a measurement. If a pod requests 2 CPU and 4 GiB, the scheduler carves that much out of a node whether the container uses it or not. Multiply a 3 to 5x overestimate across 400 pods and you're paying for a cluster two to four times larger than your workloads need.

Why it happens. Requests get set once, at launch, by copying another deployment's YAML or padding a guess "to be safe". Nothing forces a revisit. An oversized request never pages anyone, while an undersized one causes OOMKills at 2 a.m. Every incentive points toward overprovisioning.

How to detect it. With metrics-server installed (default on GKE and AKS, an add-on on EKS), pull live usage:

kubectl top pods -A --sort-by=cpu

Then pull what those same pods request:

kubectl get pods -A -o custom-columns=\
'NAMESPACE:.metadata.namespace,POD:.metadata.name,CPU_REQ:.spec.containers[*].resources.requests.cpu,MEM_REQ:.spec.containers[*].resources.requests.memory'

Line the two up for your ten largest deployments. A pod requesting 2000m and using 150m is a right-sizing candidate. The Kubernetes resource management docs cover the request and limit semantics if you're setting new values. One caution: kubectl top is a point-in-time sample, so a nightly batch job looks idle at 2 p.m. For recommendations based on real traffic, the Vertical Pod Autoscaler runs in a recommendation-only mode, and part two walks through it.

Typical impact. Almost always the largest item on this list. Teams that right-size requests on a cluster that's never been tuned typically cut compute spend by 20 to 40%.

2. Underpacked and idle nodes

What it is. Even with sensible requests, nodes can run half-empty. Fragmentation from mismatched pod sizes, nodeSelector and taint sprawl, overly strict PodDisruptionBudgets, and node groups that scale up but never consolidate all leave you with ten nodes doing six nodes' work. The extreme case is the fully idle node: kubelet, kube-proxy, a log shipper and nothing else, billing around the clock.

Why it happens. The Cluster Autoscaler is conservative about scale-down by design. A node must sit below the utilization threshold (50% of requests by default) for a sustained window, and pods with local storage, restrictive PDBs or certain annotations block eviction entirely. Nobody notices, because the cluster works fine. It's just bigger than it needs to be.

How to detect it. Compare requested and actual per node:

kubectl top nodes
kubectl describe nodes | grep -A 8 "Allocated resources"

Read the two together. Low percentages under "Allocated resources" across many nodes mean a bin-packing problem: the Cluster Autoscaler isn't consolidating, or you have too many small node groups. High allocation but low kubectl top usage points back to leak #1. Then look for the usual scale-down blocker, a PDB that allows no evictions at all:

kubectl get pdb -A -o json \
  | jq -r '.items[] | select(.status.disruptionsAllowed == 0)
    | "\(.metadata.namespace)/\(.metadata.name)"'

On EKS, Karpenter does active consolidation, repacking pods onto fewer, better-fitting instances, instead of only removing empty nodes.

Typical impact. Commonly 10 to 20% of node spend on clusters using default autoscaler settings or static node groups.

3. Orphaned persistent volumes and cloud disks

What it is. Delete a StatefulSet and its PVCs usually stay behind, each backed by a real EBS volume, Persistent Disk or Azure Disk that bills monthly. PVs with a Retain reclaim policy stay in Released state after their claim is deleted: still provisioned, still billing, attached to nothing.

Why it happens. Retain is the safe choice for anything stateful, and deleting storage feels irreversible, so nobody does it. Cluster teardown makes it worse. Delete a cluster without cleaning up PVCs first and the cloud disks outlive the cluster entirely, invisible to any future kubectl command.

How to detect it. Inside the cluster:

kubectl get pv -o wide | grep -w Released

Then check the cloud side for disks Kubernetes provisioned but no longer tracks. On AWS:

aws ec2 describe-volumes \
  --filters Name=status,Values=available Name=tag-key,Values=kubernetes.io/created-for/pvc/name \
  --query 'Volumes[*].{ID:VolumeId,Size:Size,Created:CreateTime}' --output table

The equivalents are gcloud compute disks list --filter="-users:*" on GKE and az disk list --query "[?diskState=='Unattached']" on AKS. Snapshot anything you're unsure about, then delete.

Typical impact. Usually 1 to 5% of the cluster bill. Small, but zero-risk money, since a Released volume has by definition no consumer.

4. Load balancers with nothing behind them

What it is. Every Service of type LoadBalancer provisions a real cloud load balancer (an NLB or classic ELB on AWS, a forwarding rule on GCP, an Azure Load Balancer rule) with a baseline cost of roughly $15 to $25 a month each, traffic or not. Ingress controllers deployed per namespace and demo services from long-dead experiments pile them up.

Why it happens. type: LoadBalancer is the path of least resistance for exposing anything, and Helm charts default to it more often than they should. Deleting the Deployment doesn't delete the Service, so the load balancer stays.

How to detect it. List every load balancer Service in the cluster:

kubectl get svc -A --field-selector spec.type=LoadBalancer

For each one, check whether anything is actually behind it:

kubectl get endpoints -n NAMESPACE SERVICE_NAME

An empty ENDPOINTS column means the load balancer can't serve a single request. While you're there, count how many of the rest could sit behind a single ingress controller or Gateway API gateway.

Typical impact. Usually $100 to $1,000 a month on mid-market clusters. Modest, but among the easiest deletions you'll ever approve, and a reliable hygiene signal.

5. Abandoned namespaces and forgotten environments

What it is. Preview environments per pull request, a loadtest-q3 namespace, a proof of concept from an engineer who left. Each abandoned namespace is a bundle of every leak above: running pods holding requests, PVCs holding disks, Services holding load balancers.

Why it happens. Namespaces are free to create and awkward to delete. Nobody is sure what's still needed, and there's no expiry mechanism unless you build one. CI systems that create preview environments but only sometimes tear them down are the classic source.

How to detect it. Rank namespaces by pod count and scan for names nobody recognizes:

kubectl get pods -A --no-headers | awk '{print $1}' | sort | uniq -c | sort -rn

Namespaces missing from that list aren't necessarily clean. They can still hold PVCs and load balancer Services. Spot-check suspects:

kubectl get pvc,svc -n SUSPECT_NAMESPACE

For attribution at scale, turn on your provider's cost views: split cost allocation for EKS, GKE cost allocation or AKS cost analysis. Part two covers all three.

Typical impact. Highly variable. Teams with per-PR preview environments and no TTL commonly find 10 to 20% of non-production spend sitting in namespaces nobody has touched in months.

6. Missing or misconfigured autoscaling

What it is. Autoscaling is the difference between paying for peak capacity 730 hours a month and paying for it only during the peak. Three layers matter: HPA scaling pods with load, the Cluster Autoscaler (or Karpenter) scaling nodes with pods, and schedules that shrink non-production outside business hours. Most clusters are missing at least one, and static node groups sized for peak are the most expensive default in Kubernetes.

Why it happens. Autoscaling needs trust that scale-up will be fast enough. One bad experience (a slow node launch during an incident) gets it disabled "temporarily", forever. And autoscaler savings never show up as a line item, so nobody misses what they never saw.

How to detect it. Check whether the layers exist at all:

kubectl get hpa -A
kubectl get deploy -A | grep -iE 'cluster-autoscaler|karpenter'

Empty HPA output on a cluster full of stateless services is a finding by itself. On managed node infrastructure, check at the provider: gcloud container node-pools describe POOL --cluster CLUSTER --format="value(autoscaling.enabled)" on GKE, and az aks nodepool show --resource-group RG --cluster-name CLUSTER --name POOL --query enableAutoScaling on AKS. The HPA docs cover pod-level setup.

Typical impact. A business-hours dev or staging cluster running 24/7 wastes roughly 65% of its compute. Adding node autoscaling to a peak-sized static cluster typically saves 15 to 30% of node spend.


The six checks on one page

#LeakFirst commandTypical impact
1Overprovisioned requestskubectl top pods against requested CPU and memory20 to 40% of compute
2Underpacked nodeskubectl describe nodes against kubectl top nodes10 to 20% of node spend
3Orphaned volumeskubectl get pv for Released, then unattached cloud disks1 to 5% of the bill
4Empty load balancerskubectl get svc of type LoadBalancer, then endpoints$100 to $1,000 a month
5Forgotten namespacesPod count per namespace, then PVCs and Services10 to 20% of non-prod
6Missing autoscalingkubectl get hpa, autoscaler presence, node pool settings15 to 30% of node spend

Why the cleanup sprint never sticks

Every team eventually runs the big cleanup: two engineers, a spreadsheet, a satisfying graph in the next cost review. Three months later spend is back where it started.

01Week 0

Cleanup sprint

Two engineers, a spreadsheet, a satisfying graph.

02Weeks 1 to 12

Regrowth

Deploys ship padded requests, PRs spawn namespaces, volumes orphan.

03Quarter end

Back to baseline

Spend returns to where it was and the next sprint gets scheduled.

These leaks are byproducts of normal operations. A cluster regenerates waste at roughly the rate you clean it.

None of these six leaks is a one-time mistake. They're byproducts of normal operations. Every deploy can ship padded requests, because requests live in YAML that nobody re-reads. Every deleted StatefulSet can orphan a volume, because Retain is the right default. Every PR can spawn a preview namespace, because CI creates more reliably than it destroys. And the person who knows whether a namespace is still needed has usually moved on.

A quarterly audit means each new leak bills for an average of six weeks before anyone looks. On an illustrative $60K-a-month cluster with 25% addressable waste, that delay alone costs thousands a month, and the audit is exactly the work that gets skipped during a heavy release cycle.

Where this series goes next

The durable fix is making detection continuous instead of heroic. Start by hand: part two turns the six checks into a full DIY audit with kubectl, metrics-server, kube-state-metrics, VPA recommendations, autoscaler logs and the EKS, GKE and AKS cost consoles.

Part three covers the step after: CloudThinker Optimize connects to your clusters and cloud accounts read-only, checks all six leak sources every day, and proposes fixes that wait for your approval. Teams working through the full list typically cut cluster spend by 30 to 50%.

Related reading

Try CloudThinker free with 100 premium credits and no card required, or continue with the hands-on DIY audit.

kubernetescostoptimizationfinopsekskubectldevops