Kubernetes Cost Optimization: 6 Ways Your Cluster Leaks Money
Pick any production cluster and compare what pods request against 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 fronting 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, one detection command you can run today, and the impact range teams typically find.
This is part one of a three-part series. 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.
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–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 ever 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/limit semantics if you're setting new values. For systematic recommendations, the Vertical Pod Autoscaler runs in a recommendation-only mode — 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–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 broad 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 24/7.
Why it happens. The default Cluster Autoscaler is conservative about scale-down: 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 versus actual per node:
kubectl top nodes
kubectl describe nodes | grep -A 8 "Allocated resources"
Read the two together. Low percentages in "Allocated resources" across many nodes means 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. On EKS, Karpenter does active consolidation — repacking pods onto fewer, better-fitting instances — rather than only removing empty nodes.
Typical impact. Commonly 10–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 one backed by a real EBS volume, Persistent Disk, or Azure Disk that bills monthly. PVs with a Retain reclaim policy stick around 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: 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–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–25/month each, traffic or not. Ingress controllers deployed per-namespace and demo services from long-dead experiments accumulate them steadily.
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 cannot serve a single request — pure waste. While you're there, count how many of the remaining ones could collapse behind a single ingress controller or Gateway API gateway.
Typical impact. Usually $100–1,000/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 a departed engineer. 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 absent 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, enable your provider's cost views: split cost allocation for EKS, GKE cost allocation, or AKS cost analysis — all covered in part two.
Typical impact. Highly variable; teams with per-PR preview environments and no TTL commonly find 10–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 single most expensive default in Kubernetes.
Why it happens. Autoscaling requires trust that scale-up will be fast enough, and one bad experience — a slow node launch during an incident — gets it disabled "temporarily," forever. Cluster autoscaler cost savings also 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, verify at the provider: gcloud container node-pools describe POOL --cluster CLUSTER --format="value(autoscaling.enabled)" on GKE, 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/staging cluster running 24/7 wastes roughly 65% of its compute; adding node autoscaling to a peak-sized static cluster typically saves 15–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 — because none of these six leaks is a one-time mistake. They're byproducts of normal operations. Every deploy can ship padded requests. Every deleted StatefulSet can orphan a volume. Every PR can spawn a preview namespace. The cluster regenerates waste at roughly the rate you clean it.
A quarterly audit means each new leak bills for an average of six weeks before anyone looks. On a $60K/month cluster with 25% addressable waste, that latency alone costs thousands per month — and the audit is exactly the work that gets skipped during a heavy release cycle.
The durable fix is making detection continuous rather than heroic. Start manual: part two of this series turns the six checks above into a full DIY audit using only kubectl, metrics-server, VPA recommendations, and the EKS/GKE/AKS cost consoles.
Make the checks continuous
Everything in this guide can run on autopilot. CloudThinker's CostOps agent connects to your cluster and cloud accounts with read-only access, scans all six leak sources continuously, and surfaces findings the day they appear — remediation stays gated behind your approval. Teams working through the full list typically cut cluster spend by 30–50%. Try CloudThinker free — 100 premium credits, no card required — or continue with the hands-on DIY audit.
