EKS Cost Optimization: 12 Proven Ways to Cut Your Kubernetes Bill
DevOps / SRE Engineer & CEO, Cloud DevOps Solutions
EKS bills can spiral quickly. Control plane ($73/month), nodes, storage, data transfer, plus the invisible costs: oversized nodes, idle workloads, and cross-AZ traffic. Here are 12 techniques we apply on every engagement, roughly ordered by impact.
1. Right-size your nodes
The most common finding: teams pick m5.2xlarge (8 vCPU, 32GB) because it “feels safe” when m5.xlarge (4 vCPU, 16GB) would handle the actual workload. The difference is $0.192/hr vs $0.384/hr, 50% more for the same pod density.
# See actual CPU/memory usage vs requests
kubectl top nodes
kubectl describe nodes | grep -A5 "Allocated resources"
# Use Goldilocks for VPA recommendations
helm install goldilocks fairwinds-stable/goldilocks -n goldilocks --create-namespace
kubectl label namespace my-app goldilocks.fairwinds.com/enabled=true
Target: nodes should be 60-80% utilised during normal traffic. Below 40% is waste.
2. Use Spot instances with Karpenter
Spot can save 60-80% vs On-Demand. Karpenter makes Spot reliable by diversifying across instance types.
# NodePool with Spot + fallback to On-Demand
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: node.kubernetes.io/instance-type
operator: In
values: ["m5.xlarge", "m5a.xlarge", "m4.xlarge", "m5d.xlarge"]
nodeClassRef:
name: default
disruption:
consolidationPolicy: WhenUnderutilized
consolidateAfter: 30s
Key principle: spread across 5+ instance types so Spot interruptions are rare and handled gracefully.
3. Set resource requests and limits
Without requests, the scheduler can’t bin-pack efficiently. Pods land on near-empty nodes and you spin up more than needed.
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
Use Vertical Pod Autoscaler in recommendation mode to get data-driven request values:
kubectl apply -f https://github.com/kubernetes/autoscaler/releases/latest/download/vertical-pod-autoscaler.yaml
# VPA in recommendation mode (no automatic changes)
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: my-app-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: my-app
updatePolicy:
updateMode: "Off" # recommendation only
4. Enforce namespace resource quotas
Without quotas, one team’s runaway deployment can exhaust cluster capacity.
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-quota
namespace: team-a
spec:
hard:
requests.cpu: "16"
requests.memory: "32Gi"
limits.cpu: "32"
limits.memory: "64Gi"
count/pods: "50"
5. Delete idle resources
Idle Deployments with 0 traffic, completed Jobs, orphaned PVCs, and unused LoadBalancers all cost money.
# Find idle deployments (0 replicas or 0 CPU usage)
kubectl get deployments -A | grep " 0/"
# List PVCs not bound to any pod
kubectl get pvc -A | grep -v Bound
# Find Services of type LoadBalancer
kubectl get svc -A | grep LoadBalancer
A single unused ALB costs ~$16/month. Audit monthly.
6. Use Savings Plans or Reserved Instances for base capacity
If you run a stable baseline (say 3 On-Demand m5.xlarge always running), Compute Savings Plans give 25-40% off with no instance-type commitment.
- 1-year no-upfront Compute Savings Plan: ~25% off
- 3-year no-upfront: ~35% off
- Reserved Instance (specific type): up to 57% off
Buy Savings Plans to cover your predictable baseline. Use Spot for burst.
7. Eliminate cross-AZ data transfer
AWS charges $0.01/GB for data crossing AZ boundaries. In a busy microservices cluster this adds up fast.
# Topology-aware routing, prefer pods in same AZ
apiVersion: v1
kind: Service
metadata:
name: my-service
annotations:
service.kubernetes.io/topology-mode: "Auto"
Also set topologySpreadConstraints to spread pods evenly across AZs (reduces AZ-crossing traffic):
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: my-app
8. S3 lifecycle policies for ECR and logs
ECR image storage is $0.10/GB/month. Old images accumulate fast.
# Lifecycle policy: keep only last 10 images per repo
aws ecr put-lifecycle-policy \
--repository-name my-repo \
--lifecycle-policy-text '{
"rules": [{
"rulePriority": 1,
"description": "Keep last 10 images",
"selection": {"tagStatus": "any", "countType": "imageCountMoreThan", "countNumber": 10},
"action": {"type": "expire"}
}]
}'
For CloudWatch Logs from EKS, set 30-day retention instead of the default “never expire”:
aws logs put-retention-policy \
--log-group-name /aws/eks/prod-cluster/cluster \
--retention-in-days 30
9. Use Fargate only for appropriate workloads
Fargate pricing ($0.04048/vCPU-hour, $0.004445/GB-hour) is higher than equivalent EC2 Spot for steady workloads but lower for infrequent batch jobs.
Good Fargate use cases: CI jobs, nightly reports, one-off data processing. Bad Fargate use cases: always-on web services, databases, anything needing DaemonSets.
10. Install Kubecost for visibility
You can’t optimise what you can’t see.
helm repo add kubecost https://kubecost.github.io/cost-analyzer/
helm install kubecost kubecost/cost-analyzer \
-n kubecost --create-namespace \
--set kubecostToken="your-token"
Kubecost breaks down spend by namespace, label, controller, and pod. Point it at your AWS Cost Explorer for cloud cost allocation.
11. Scale down non-production clusters overnight
Dev and staging clusters don’t need to run 24/7.
# Scale down all deployments in non-prod (run at 7pm)
kubectl get deployments -n staging -o name | \
xargs -I{} kubectl scale {} --replicas=0 -n staging
# Scale back up (run at 8am)
# Store original replica counts in annotations first
Or use kube-downscaler for automation:
helm install kube-downscaler deliveryhero/kube-downscaler \
--set extraArgs='{--default-downtime=Mon-Fri 20:00-08:00 Europe/London}'
Saving 12 hours/day on staging = 50% off those nodes.
12. Tune HPA to avoid over-provisioning
Default HPA scaling is conservative, it scales up quickly and down slowly, leaving spare capacity running.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: my-app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-app
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # wait 5min before scaling down
policies:
- type: Percent
value: 25
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 30
Key takeaways
- Right-sizing nodes and setting accurate resource requests are the highest-ROI changes, do these first.
- Karpenter with Spot diversification across 5+ instance types cuts compute costs 60-70% safely.
- Cross-AZ data transfer is invisible but expensive; use topology-aware routing.
- Fargate is more expensive than Spot for steady workloads, use it only for burst/batch.
- Install Kubecost before optimising; without cost visibility you’re guessing.
- Scale down non-production clusters on a schedule, it’s low effort with 50% savings.