Why Kubernetes Costs Spiral Out of Control
I've seen it happen to teams that know what they're doing. A well-architected Kubernetes cluster gets stood up, workloads get deployed, and for the first few months everything looks fine. Then the cloud bill arrives and someone in finance asks why infrastructure costs doubled in a quarter. The answer is almost never a single smoking gun — it's a compounding set of problems that each seemed harmless in isolation.
The first problem is overprovisioning by default. Developers set resource requests and limits high because they're scared of OOMKilled pods. A service that needs 256Mi of memory gets 1Gi "just to be safe." Multiply that across 50 microservices and you're paying for three to four times the compute you actually consume. The cluster autoscaler happily provisions more nodes to accommodate these inflated requests, and now you're running a cluster that's 30% utilized at peak load.
The second problem is namespace sprawl without accountability. As more teams onboard to Kubernetes, you end up with dozens of namespaces — dev, staging, prod-us, prod-eu, feature-x, feature-y — and nobody has a clear picture of who's spending what. Without per-namespace cost attribution, engineers have no feedback loop. They spin up workloads, forget about them, and the costs accumulate silently.
The third problem is persistent volume waste. PVCs get created, workloads get deleted, and the PVCs stick around. Some cloud providers charge for detached persistent disks at the same rate as attached ones. I've audited clusters where 20% of storage costs were from orphaned PVCs that no running pod was mounting.
The fourth problem is cluster idle time. Dev and staging clusters often run 24/7 even though they're only needed for 8-10 hours a day. That's paying for 14+ hours of idle compute every single day. For a mid-sized engineering org, this can amount to thousands of dollars per month.
These problems compound. Overprovisioned resources mean more nodes. More nodes mean more EBS volumes, more NAT gateway traffic, more load balancer hours. What starts as a resource request misconfiguration ends up manifesting across five line items on your cloud bill.
The good news: all of these problems are solvable with the right combination of tooling, policies, and organizational practices. The rest of this guide walks through exactly how.
Namespace-Level ResourceQuota and LimitRange: The Foundation
Before you can optimize costs, you need guardrails. ResourceQuota and LimitRange are the two Kubernetes primitives that prevent any single team or workload from consuming unbounded resources. Setting them up correctly is the foundation everything else builds on.
A ResourceQuota sets hard limits on total resource consumption within a namespace. If the quota is exhausted, new pods won't schedule. Here's a production-ready example for a mid-tier application namespace:
apiVersion: v1
kind: ResourceQuota
metadata:
name: app-team-quota
namespace: team-payments
spec:
hard:
requests.cpu: "20"
requests.memory: 40Gi
limits.cpu: "40"
limits.memory: 80Gi
persistentvolumeclaims: "10"
requests.storage: 200Gi
count/pods: "50"
count/services: "20"
count/secrets: "50"
count/configmaps: "50"
The key insight here is that you're constraining both requests and limits. Constraining only limits doesn't prevent over-scheduling, because the scheduler uses requests to make placement decisions.
A LimitRange sets default values and min/max bounds per container or pod. This is what saves you from forgotten resource specifications — if a developer deploys a pod without resource requests, LimitRange injects the defaults automatically:
apiVersion: v1
kind: LimitRange
metadata:
name: default-limits
namespace: team-payments
spec:
limits:
- type: Container
default:
cpu: "500m"
memory: "512Mi"
defaultRequest:
cpu: "100m"
memory: "128Mi"
max:
cpu: "4"
memory: "8Gi"
min:
cpu: "50m"
memory: "64Mi"
- type: PersistentVolumeClaim
max:
storage: 20Gi
min:
storage: 1Gi
The defaultRequest values matter enormously for cost. Set them too high and you inflate your cluster's resource footprint. Set them too low and you'll see performance issues. A good starting point for most microservices is 100m CPU and 128Mi memory as the default request, with actual limits 2-4x higher.
One pattern I recommend is tiering your quotas by team criticality. A tier-1 team running customer-facing payments gets a higher quota than a tier-3 internal tooling team. This creates natural pressure for teams to rightsize — when you're working against a quota, you think more carefully about how much you're requesting.
Key insight: LimitRange defaults are your safety net for developers who forget to set resources. Without them, a container with no resource specs is treated as requesting zero CPU/memory by the scheduler — which means it can land on any node and potentially starve other workloads.
Autoscaling Strategy: VPA, HPA, and KEDA
Static resource allocation is the enemy of cost efficiency. The right workloads should scale dynamically, both horizontally (more pods) and vertically (larger pod size). The three tools that matter here are VPA, HPA, and KEDA — and knowing which to use where is a skill that takes time to develop.
Vertical Pod Autoscaler (VPA)
VPA analyzes historical CPU and memory usage and recommends — or automatically applies — better resource requests and limits. It's most valuable for workloads that are hard to scale horizontally (databases, stateful sets, single-instance batch jobs) and for initial rightsizing of any workload you're not sure about.
In recommendation mode, VPA observes without changing anything:
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: payments-api-vpa
namespace: team-payments
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: payments-api
updatePolicy:
updateMode: "Off" # Recommend only, don't auto-apply
resourcePolicy:
containerPolicies:
- containerName: payments-api
minAllowed:
cpu: 50m
memory: 64Mi
maxAllowed:
cpu: 2
memory: 4Gi
After running VPA in recommendation mode for two weeks, check the recommendations and you'll typically find that 60-70% of your containers are over-requesting resources by 2-5x.
Horizontal Pod Autoscaler (HPA)
HPA is right for stateless services with predictable scaling behavior tied to CPU or memory. The key to effective HPA is setting the right target utilization threshold. Most teams set CPU target at 80%, but this is often too aggressive — by the time the HPA reacts, the pod is already saturated. For latency-sensitive services, 50-60% target utilization gives you headroom for burst traffic before new pods spin up.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: payments-api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: payments-api
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 70
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Percent
value: 100
periodSeconds: 30
Note the asymmetric scaling behavior: scale up fast (100% increase per 30 seconds), scale down slow (10% decrease per 60 seconds). This prevents thrashing and protects against sudden traffic spikes.
KEDA: Event-Driven Autoscaling
KEDA (Kubernetes Event-Driven Autoscaling) fills the gap HPA leaves open. When you need to scale based on queue depth, Kafka consumer lag, Prometheus metrics, or custom business metrics, KEDA is the answer. It extends HPA with 50+ built-in scalers and the ability to scale to zero — something native HPA can't do.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: order-processor-scaler
spec:
scaleTargetRef:
name: order-processor
minReplicaCount: 0 # Scale to zero when queue is empty
maxReplicaCount: 50
pollingInterval: 15
cooldownPeriod: 60
triggers:
- type: aws-sqs-queue
metadata:
queueURL: https://sqs.us-east-1.amazonaws.com/123456789/orders
queueLength: "5" # 5 messages per replica
awsRegion: us-east-1
Scaling to zero for batch processing workloads is one of the highest-ROI cost optimizations available. A queue processor that used to run 5 replicas 24/7 can now run zero replicas at night and during weekends, with KEDA spinning it up within seconds when messages arrive.
Node Pool Optimization: Spot, ARM, and Right-Sizing
Compute costs are dominated by the type and size of nodes you run. Most teams default to on-demand instances of a single instance family — often because that's what the initial cluster setup chose. Rethinking your node pool strategy can cut compute costs by 40-60% without any changes to your application code.
Spot and Preemptible Instances
Spot instances (AWS) and preemptible nodes (GCP) offer 60-90% discounts compared to on-demand pricing. The catch is that they can be reclaimed with 2 minutes notice. For fault-tolerant, stateless workloads, this is a perfectly acceptable trade-off.
The strategy is to run critical workloads on on-demand nodes and opportunistically schedule everything else on spot. Use node taints and pod tolerations to control placement:
# Node pool configuration (EKS example via node labels/taints)
# Spot node pool taint
kubectl taint nodes -l node.kubernetes.io/instance-type=spot \
cloud.google.com/gke-spot=true:NoSchedule
# Pod toleration for spot-eligible workloads
tolerations:
- key: "cloud.google.com/gke-spot"
operator: "Equal"
value: "true"
effect: "NoSchedule"
# Node affinity to prefer spot, fall back to on-demand
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: cloud.google.com/gke-spot
operator: In
values: ["true"]
A common mistake is using spot instances for workloads that can't handle sudden termination gracefully. Make sure your pods handle SIGTERM properly and that you have enough replicas that losing one to preemption doesn't cause a service outage. PodDisruptionBudgets are essential here.
ARM-Based Nodes
AWS Graviton (ARM) nodes offer 20% better price-performance than equivalent x86 instances. GCP Tau T2A nodes offer similar advantages. If your container images support multi-arch builds (which most modern images do), migrating CPU-intensive workloads to ARM nodes is essentially free money.
Build multi-arch images with Docker buildx:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myrepo/myapp:latest \
--push .
Then configure an ARM node pool with appropriate taints and target specific workloads. CPU-bound batch processing, data transformation pipelines, and API services with high throughput requirements are all good candidates.
Instance Diversification
One of the most underused node pool strategies is instance type diversification. Instead of a single instance type, use a mix of 5-10 similar instance types. This dramatically improves spot availability because you're not competing for a single SKU, and it allows the scheduler more flexibility in bin-packing pods efficiently.
Cluster Autoscaler vs Karpenter: A Practical Comparison
The choice between Cluster Autoscaler and Karpenter is one of the most consequential infrastructure decisions you'll make. Both provision and deprovision nodes, but they take fundamentally different approaches.
Cluster Autoscaler works with predefined node groups. You define node pools with specific instance types and sizes, and the autoscaler adds or removes nodes from those pools based on scheduling pressure. It's mature, well-understood, and supported across all major cloud providers.
Karpenter is a newer, more flexible provisioner from AWS (though it's now multi-cloud). Instead of working with predefined node groups, Karpenter provisions exactly the node that fits your pending pods best. If you have a pending pod that needs 3 vCPU and 6Gi memory, Karpenter can provision a 4 vCPU / 8Gi node in about 60 seconds. With Cluster Autoscaler, you'd get whatever your smallest configured node group provides.
| Feature | Cluster Autoscaler | Karpenter |
|---|---|---|
| Provisioning speed | 3-5 minutes | 60-90 seconds |
| Instance type flexibility | Predefined node groups | Dynamic, best-fit selection |
| Bin-packing efficiency | Limited (node group bound) | High (exact-fit provisioning) |
| Spot interruption handling | Basic | Built-in interruption handling |
| Consolidation (scale-down) | Conservative, slow | Aggressive, configurable |
| Cloud provider support | All major clouds | AWS (GCP/Azure in progress) |
| Maturity | Very mature (v1) | Stable, rapidly evolving (v1) |
| Typical cost savings over CA | Baseline | 15-25% additional savings |
My recommendation: if you're on AWS and your workloads can tolerate a migration, move to Karpenter. The consolidation feature alone — which continuously looks for opportunities to pack pods onto fewer nodes and deprovision underutilized ones — pays for the migration effort within weeks. Here's a basic NodePool configuration:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64", "arm64"]
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["c", "m", "r"]
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["3"]
limits:
cpu: 1000
memory: 1000Gi
disruption:
consolidationPolicy: WhenUnderutilized
consolidateAfter: 30s
Cost Management Tools: CAST AI, Kubecost, and OpenCost Compared
Visibility is prerequisite to optimization. You cannot manage costs you cannot see, and the default Kubernetes metrics don't give you cost attribution. Three tools dominate this space: CAST AI, Kubecost, and OpenCost. They each take different approaches to the same problem.
| Capability | CAST AI | Kubecost | OpenCost |
|---|---|---|---|
| Pricing model | % of savings (SaaS) | Free tier + Enterprise | Free (OSS) |
| Autonomous optimization | Yes (AI-driven) | Recommendations only | No |
| Cost allocation granularity | Pod/namespace/label | Pod/namespace/label/team | Pod/namespace |
| Multi-cloud support | AWS, GCP, Azure | AWS, GCP, Azure, on-prem | AWS, GCP, Azure, on-prem |
| Idle/waste detection | Yes, automated remediation | Yes, with alerts | Basic metrics only |
| Showback/chargeback reports | Built-in | Built-in, exportable | Via Prometheus/Grafana |
| VPA integration | Native, automated | Recommendations | No |
| Best for | Teams wanting automation | Visibility + governance | Cost-sensitive, OSS stack |
OpenCost is the CNCF-incubated open-source option. It integrates with Prometheus and provides cost metrics through standard PromQL queries. It's the right choice if you already have a Prometheus/Grafana stack and want to avoid additional SaaS costs. The tradeoff is that you get metrics, not recommendations or automation.
Kubecost sits in the middle. The free tier covers a single cluster with 15 days of data retention. The enterprise tier adds multi-cluster, longer retention, team-level cost allocation, and budget alerts. The UI is polished and the chargeback reports are genuinely useful for organizational conversations about cost ownership.
CAST AI is the most opinionated option. It connects to your cloud account, analyzes your cluster, and then makes automated changes — replacing nodes, adjusting instance types, managing spot fleets. The pricing model (a percentage of the savings it generates) means it's free if it doesn't save you money. Teams I've seen use it typically report 20-40% cost reductions, but you're giving up some control over your infrastructure in exchange.
Storage Cost Optimization
Storage is often overlooked in Kubernetes cost discussions, but it can represent 15-25% of your total Kubernetes spend depending on your workloads. The main levers are StorageClass selection, PVC lifecycle management, and volume snapshots.
StorageClass Selection
Not all storage is created equal or priced equal. Most cloud providers offer multiple tiers:
- Standard/gp2 (AWS): General-purpose SSD, baseline performance, relatively expensive
- gp3 (AWS): 20% cheaper than gp2 with configurable IOPS — migrate all gp2 volumes
- sc1/st1 (AWS): HDD-based, 60-80% cheaper, suitable for throughput-intensive non-latency-sensitive workloads
- EFS (AWS): Shared storage, pay per GB stored, right for shared access patterns
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-ssd
provisioner: ebs.csi.aws.com
parameters:
type: gp3
iops: "3000"
throughput: "125"
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: bulk-storage
provisioner: ebs.csi.aws.com
parameters:
type: st1
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
Set WaitForFirstConsumer volume binding mode — this ensures volumes are provisioned in the same AZ as the pod that uses them, avoiding cross-AZ data transfer costs.
PVC Lifecycle Management
Orphaned PVCs are silent cost killers. Build a process to detect and clean them up:
# Find PVCs not mounted by any pod
kubectl get pvc --all-namespaces -o json | \
jq -r '.items[] | select(.status.phase=="Bound") |
.metadata.namespace + "/" + .metadata.name' | \
while read pvc; do
ns=$(echo $pvc | cut -d/ -f1)
name=$(echo $pvc | cut -d/ -f2)
mounted=$(kubectl get pods -n $ns -o json | \
jq --arg pvc "$name" '.items[].spec.volumes[]? |
select(.persistentVolumeClaim.claimName==$pvc)' | wc -l)
if [ "$mounted" -eq 0 ]; then
echo "Orphaned PVC: $pvc"
fi
done
Don't auto-delete orphaned PVCs — add them to a review queue. Sometimes a PVC is orphaned intentionally (for a pod that will be recreated). After human review, delete the confirmed orphans.
Namespace Showback and Chargeback Implementation
Showback means showing teams what they're spending. Chargeback means actually charging internal cost centers. Both require the same underlying cost attribution infrastructure, but chargeback creates real financial accountability.
The most practical approach for most organizations is to start with showback — generate weekly cost reports per team — and then move to chargeback once teams have had time to understand their spending patterns.
Label everything consistently:
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-api
labels:
app: payments-api
team: payments
cost-center: "engineering-001"
environment: production
tier: "1"
Then in Kubecost or OpenCost, group costs by the team label to generate per-team reports. With OpenCost + Prometheus, you can query cost by label directly:
# Total monthly cost for payments team workloads
sum(
node_total_hourly_cost * on(node) group_left()
kube_node_labels * 720
) by (label_team)
Organizational reality check: Chargeback only works if team managers have visibility into costs before the bill arrives and have the authority to act on that information. Surprising a team lead with a $50K invoice at the end of the month generates resentment, not cost discipline. Weekly showback reports with trend lines are far more effective.
GitOps-Based Cost Policy: Policy as Code
Manual cost reviews don't scale. As your cluster grows, you need automated enforcement of cost policies through the same GitOps workflows that manage your deployments. This is where Admission Controllers, OPA/Gatekeeper, and Kyverno come in.
Policy as Code means your cost guardrails are version-controlled, reviewable, and enforced at admission time — before a misconfigured workload ever runs. Here are the policies that have the highest cost impact:
Enforce Resource Requests on All Containers
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-resource-requests
spec:
validationFailureAction: Enforce
rules:
- name: check-resource-requests
match:
any:
- resources:
kinds:
- Pod
validate:
message: "Resource requests are required for all containers"
pattern:
spec:
containers:
- resources:
requests:
memory: "?*"
cpu: "?*"
Prevent Large Instance Requests Without Approval Label
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: limit-large-resource-requests
spec:
validationFailureAction: Enforce
rules:
- name: check-large-cpu-requests
match:
any:
- resources:
kinds: [Pod]
preconditions:
any:
- key: "{{request.object.metadata.labels.\"approved-large-instance\" || ''}}"
operator: Equals
value: ""
validate:
message: "CPU requests > 4 cores require label approved-large-instance=true"
deny:
conditions:
any:
- key: "{{request.object.spec.containers[].resources.requests.cpu}}"
operator: GreaterThan
value: "4"
Pair these policies with CI/CD pre-flight checks. Run kyverno or conftest against manifests before they hit the cluster, so developers get fast feedback during code review rather than a rejected deployment.
Real-World 30-40% Cost Reduction: A Step-by-Step Case Study
Let me walk through a real cost reduction engagement I helped with — a 200-person engineering org running about $180K/month in Kubernetes costs across three EKS clusters. Over 90 days, we brought that to $108K/month — a 40% reduction.
Week 1-2: Baselining and Waste Identification
We deployed Kubecost and ran it in observation mode. The first report was sobering: 45% of cluster capacity was unused (requested but not consumed). Development clusters were running 24/7. Eleven namespaces had no resource quotas at all. We found $28K/month in orphaned PVCs.
Week 3-4: Quick Wins
Deleted orphaned PVCs after verification: -$28K/month immediately. Scheduled dev/staging cluster scale-down to zero replicas on weeknights and weekends using KEDA: -$15K/month. Migrated all gp2 EBS volumes to gp3: -$4K/month.
Month 2: Rightsizing and Autoscaling
Deployed VPA in recommendation mode, collected two weeks of data, then applied recommendations to all non-production namespaces. Average resource request reduction: 55%. Deployed KEDA for queue-based batch processors, enabling scale-to-zero: reduced 15 always-on deployments to on-demand. Implemented HPA for all stateless services with CPU target at 65%.
Month 3: Node Pool Optimization
Migrated to Karpenter from Cluster Autoscaler. Configured spot instance preference for all non-tier-1 workloads. Added ARM Graviton node pools for data processing workloads. The combination of Karpenter's tighter bin-packing and 70% spot instance usage brought compute costs down an additional 25%.
Total reduction: $72K/month ($864K/year) — achieved with no application performance degradation. The key was addressing each layer systematically: waste removal first, then rightsizing, then infrastructure optimization.
FinOps Integration: Operationalizing Kubernetes Cost Management
FinOps (Financial Operations) is the practice of bringing financial accountability to cloud spending. For Kubernetes, integrating FinOps principles means embedding cost awareness into the engineering lifecycle, not just reacting to cloud bills after the fact.
The three-phase FinOps model — Inform, Optimize, Operate — maps directly to Kubernetes cost management:
Inform phase: Deploy OpenCost or Kubecost. Configure cost dashboards per team. Export cost metrics to your central observability platform. Send weekly cost digests to team leads automatically.
Optimize phase: Run VPA recommendations quarterly. Review namespace quotas monthly. Audit unused resources (PVCs, Services, ConfigMaps) weekly. Track efficiency score (actual resource usage / requested resources) as a KPI.
Operate phase: Embed cost policies via Kyverno/OPA. Gate resource-heavy deployments with approval workflows. Track cost-per-feature or cost-per-transaction as business metrics. Tie cost efficiency to team OKRs.
The organizational component is just as important as the technical one. The most impactful thing you can do is hold a monthly 30-minute cost review meeting where each team presents their spending trend. Making costs visible in a social context creates accountability that no automated tool can replicate.
Key Takeaways
- Namespace ResourceQuota + LimitRange are non-negotiable — deploy them on day one, not after costs spiral. They create the foundation for accountability and prevent single workloads from consuming unbounded resources.
- VPA recommendation mode reveals the truth — run it for two weeks before making any changes. In virtually every cluster I've analyzed, the data shows 40-70% over-provisioning across the workload fleet.
- KEDA's scale-to-zero is the highest single ROI optimization — if you have batch workloads, queue processors, or non-time-critical jobs, enabling scale-to-zero pays back immediately, especially for dev/staging environments.
- Karpenter outperforms Cluster Autoscaler for cost efficiency — the combination of faster provisioning, exact-fit instance selection, and aggressive consolidation typically delivers 15-25% additional savings over Cluster Autoscaler.
- Spot instances with diversification are lower risk than you think — using 5-10 instance types with pod disruption budgets and graceful termination handlers makes spot viable for 70-80% of your workload fleet.
- Storage waste is invisible until you look for it — build a regular orphaned PVC audit into your operations runbook. In mature clusters, 15-25% of storage costs are often from resources no pod is actively using.
- Policy as Code via Kyverno or OPA Gatekeeper scales cost governance — you cannot manually review every deployment for cost hygiene. Automated admission control policies that enforce resource requests and prevent oversized allocations are the only way to maintain cost discipline at scale.
Want to automate Kubernetes cost reporting? — See what I built
댓글
댓글 쓰기