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.
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.
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
| # | Leak | First command | Typical impact |
|---|---|---|---|
| 1 | Overprovisioned requests | kubectl top pods against requested CPU and memory | 20 to 40% of compute |
| 2 | Underpacked nodes | kubectl describe nodes against kubectl top nodes | 10 to 20% of node spend |
| 3 | Orphaned volumes | kubectl get pv for Released, then unattached cloud disks | 1 to 5% of the bill |
| 4 | Empty load balancers | kubectl get svc of type LoadBalancer, then endpoints | $100 to $1,000 a month |
| 5 | Forgotten namespaces | Pod count per namespace, then PVCs and Services | 10 to 20% of non-prod |
| 6 | Missing autoscaling | kubectl get hpa, autoscaler presence, node pool settings | 15 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.
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
- What is continuous rightsizing?
- Manual vs continuous rightsizing
- AI agent for Kubernetes
- AWS cost optimization: 7 places your bill is leaking
Try CloudThinker free with 100 premium credits and no card required, or continue with the hands-on DIY audit.
