Kubernetes Cost Traps: 3 Misconfigs Draining Your Cloud Bill
Most teams believe their Kubernetes bill is roughly proportional to what they're actually using. It isn't. The gap between provisioned capacity and consumed capacity on a typical production cluster sits between 40% and 70% — not because engineers are lazy, but because Kubernetes defaults were designed for availability, not frugality. Nobody went back to tune them once the system was running.
The pattern is consistent across every production environment I've touched: the engineering team nails the hard parts — HA, failover, observability — and leaves the cost lever untouched because it doesn't break anything. Until the bill lands.
This article covers three specific misconfigurations that account for the majority of recoverable waste in production Kubernetes environments, plus a decision framework for prioritizing the fixes.
Overconfigured Requests: The Silent Reservation Bomb
Kubernetes schedules pods based on requests, not actual usage. A node reports "full" to the scheduler when requests sum to its capacity — even if real CPU utilization is 8%. This means your cluster autoscaler spins up new nodes not because workloads need compute, but because requests say they do.
The typical misconfiguration looks like this: a team sets requests equal to limits during initial deployment (a reasonable starting point), runs load tests, bumps limits to handle peaks, and never adjusts requests back down. Six months later, every pod in the namespace is requesting 2 CPU and 4Gi when it actually idles at 150m CPU and 512Mi.
yaml# What most teams have in production resources: requests: cpu: "2" memory: "4Gi" limits: cpu: "4" memory: "8Gi" # What the workload actually needs at p95 resources: requests: cpu: "300m" memory: "768Mi" limits: cpu: "2" memory: "4Gi"
In a cluster with 30 microservices each over-provisioned like this, the scheduler's view of capacity can be 4–6x the real consumption. That directly inflates your node count — and your bill.
The fix isn't manual tuning — that doesn't scale. Use the Vertical Pod Autoscaler (VPA) in recommendation mode first. It watches real usage for 24–72 hours and surfaces right-sized request values without touching your pods. Only apply VPA in auto mode for stateless workloads you're comfortable restarting. For stateful services, take the recommendation and apply it manually after a review.
bash# Deploy VPA in recommendation-only mode kubectl apply -f https://github.com/kubernetes/autoscaler/releases/latest/download/vertical-pod-autoscaler.yaml # Create a VPA object for your deployment cat <<EOF | kubectl apply -f - apiVersion: autoscaling.k8s.io/v1 kind: VerticalPodAutoscaler metadata: name: my-service-vpa spec: targetRef: apiVersion: apps/v1 kind: Deployment name: my-service updatePolicy: updateMode: "Off" # Recommendation only — review before applying EOF # Check recommendations after 48h kubectl describe vpa my-service-vpa
Once you right-size requests, your cluster autoscaler will consolidate nodes aggressively. The most visible impact comes in environments with dozens of microservices, each slightly over-provisioned, running 24/7 — the compounding effect across services is where the real recovery lives.
Always-On Node Groups: Paying for Headroom You Don't Need
Most clusters run a static node pool that's sized for peak load — always. The argument is usually "we need it ready for traffic spikes." The reality is that for the majority of workloads, the spike window is 4–8 hours per day. You're paying for 24.
The right architecture separates your node groups by workload type:
| Node Group | Instance Type | Scaling | Use Case |
|---|---|---|---|
| Core (always-on) | On-Demand, 2–4 nodes | Fixed min | Critical services, PDBs |
| Burst (on-demand) | On-Demand | 0 to N, fast scale | Latency-sensitive peaks |
| Batch | Spot/Preemptible | 0 to N | AI inference jobs, ETL, async |
| GPU (if needed) | GPU Spot | 0 to N | Model training, heavy inference |
The engineering move most teams skip: set the minimum on your burst node group to zero and configure Cluster Autoscaler with scale-down enabled at a 10-minute threshold. The default scale-down delay is 10 minutes — aggressive enough to reclaim capacity without thrashing.
yaml# cluster-autoscaler deployment flags - --scale-down-delay-after-add=10m - --scale-down-unneeded-time=10m - --scale-down-utilization-threshold=0.5 - --expander=least-waste
For batch workloads specifically — nightly ETL pipelines, async processing queues, model inference jobs — the pattern is: provision when work arrives, process, release. Spot/Preemptible instances make this genuinely cheap, but only if your jobs are built to tolerate interruption. Checkpoint state, use job retries, and set terminationGracePeriodSeconds long enough for a clean shutdown. Imagine a team running a 100k-record nightly pipeline on On-Demand nodes, holding capacity all day for a 2 AM job — that's a straightforward Spot migration with meaningful cost reduction, and the engineering work to make the job interruptible is typically a day or two, not a sprint.
This is engineering discipline, not a Kubernetes trick — but it's what separates teams that actually run on Spot from teams that tried once and gave up.
Namespace Resource Quotas: The Missing Guardrail That Lets One Team Sink the Cluster
This is the one that surprises people the most. Most multi-team clusters have no hard resource quotas at the namespace level. A single misconfigured deployment — a forgotten load test, a bad HPA configuration, a rogue job — can consume the entire cluster's capacity. The autoscaler responds by provisioning more nodes. The bill spikes. Nobody notices until the monthly invoice.
The fix is simple to implement but organizationally heavy: every namespace gets a ResourceQuota and a LimitRange. The quota sets the ceiling; the LimitRange sets defaults so that pods without explicit resource specs still get reasonable values.
yaml# ResourceQuota — hard ceiling per team namespace apiVersion: v1 kind: ResourceQuota metadata: name: team-quota namespace: team-payments spec: hard: requests.cpu: "20" requests.memory: 40Gi limits.cpu: "40" limits.memory: 80Gi count/pods: "50" --- # LimitRange — defaults for pods without explicit specs apiVersion: v1 kind: LimitRange metadata: name: team-defaults namespace: team-payments spec: limits: - default: cpu: "500m" memory: "512Mi" defaultRequest: cpu: "100m" memory: "256Mi" type: Container
The organizational resistance here is real. Teams push back on quotas because they feel like bureaucracy. The right framing: quotas aren't a constraint on what you can build, they're protection against one team's bug taking down everyone else's service. That argument lands fast in any engineering culture that's experienced a shared-cluster incident.
Tag namespaces with cost center labels from day one. Then wire those labels into your cloud billing dashboards. Most cloud providers (AWS, GCP, Azure) support cost allocation tags that flow through to the billing console — but only if you're consistent about applying them. This turns "the Kubernetes bill" into "the payments team's Kubernetes bill" and changes the conversation entirely.
The Prioritization Framework: Where to Start
You won't fix all three misconfigurations at once. Here's the decision rule:
If your cluster has 10+ services and no VPA recommendations: Start with request right-sizing. It's the highest-leverage single change — your scheduler efficiency improves immediately, and node consolidation follows.
If your cluster has persistent bursty workloads or batch jobs: Fix node group architecture next. Spot instances for batch are genuinely transformative on a variable-cost basis once you've built interruption-safe job patterns.
If your cluster is multi-team and has no quotas: Do this in parallel with the above, even if only in warning mode first. The risk isn't theoretical — a single bad deployment on a quota-free cluster will ruin your cost analysis by spiking the baseline.
A quick audit checklist before you start:
- Run
kubectl top pods --all-namespacesand compare against requests — gaps > 5x are immediate VPA candidates - Check cluster autoscaler logs for repeated scale-up events on the same node group
- Confirm Spot/Preemptible nodes are being used for any batch or async workloads
- Verify every namespace has a
ResourceQuotaapplied - Confirm cost allocation tags are consistent across all node groups and namespaces
- Review HPA min replicas — a common trap is
minReplicas: 3on low-traffic services that never scale below that floor
Kubernetes Defaults Optimize for Reliability, Not Cost — Treat Them Differently
Kubernetes ships with sensible defaults for keeping your application running. It has no defaults for keeping your bill predictable. Those are genuinely different engineering goals, and conflating them is how teams end up with clusters that are rock-solid and ruinously expensive.
The cluster that never goes down and the cluster that never surprises you on the invoice require different configuration choices — and most teams only made one of them.
This distinction matters even more if you're building agentic AI systems on top of this infrastructure. The same discipline that right-sizes Kubernetes requests applies to controlling LLM token spend — measure actual consumption, set budgets at the team level, build feedback loops. Bad Kubernetes hygiene plus unbounded agent loops is a billing emergency waiting to happen, and the two cost surfaces compound each other in ways that are painful to untangle after the fact.
What to Actually Do
-
Run VPA in recommendation mode this week. No cluster changes required, just data. Give it 48–72 hours and look at what it surfaces. The numbers will be uncomfortable.
-
Identify every workload running on On-Demand nodes that could tolerate interruption. Batch jobs, async pipelines, model inference with retry logic — all of these are Spot candidates. Move the first one as a proof of concept.
-
Apply ResourceQuota to your highest-traffic namespace first. Start with a ceiling that's 2x the current peak usage — it won't block anything, but it will catch runaway autoscalers before they become invoice surprises.
-
Wire namespace labels to your billing dashboard. If you can't see cost per team or per service, you're managing a black box. Visibility precedes optimization.
-
Set a calendar reminder to review HPA and VPA recommendations monthly. Workloads change. A service that needed 2 CPU at launch might need 200m six months after the initial traffic spike normalized. Treat resource configuration as a living artifact, not a one-time decision.
The cluster isn't going to fix itself. But none of these fixes require more than a few days of engineering time — the blocker is always prioritization, not complexity.
Working on something like this? I take on a few fractional-CTO and AI engagements at a time.
Get my AI playbooks — straight to your inbox
Practical notes on shipping production AI, scaling teams, and the calls a CTO actually has to make. A few times a month. No spam, no fluff.