Skip to main content
Cloud DevOps Solutions
Get a quote Talk to an engineer
Cloud Cost 2025-09-22 14 min read

EKS Cost Optimization: 12 Proven Ways to Cut Your Kubernetes Bill

Saddique Khan

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.
#eks #cost #kubernetes #karpenter #spot
Found this useful? Share it with your team or link to it from your documentation.

Need help implementing this?

Our certified engineers can handle the setup, migration, or ongoing operations for you.