EKS Cost Optimization: Control Plane, Nodes, Karpenter and Leftovers

Cut Amazon EKS costs: the control-plane fee, node groups vs Fargate vs Karpenter, requests and bin packing, Spot and Graviton nodes, orphaned volumes.

An EKS bill rarely has a line called “Kubernetes”. The control plane is a small, fixed charge; everything else lands under EC2, EBS, Elastic Load Balancing and data transfer, tagged (if you’re lucky) with a cluster name. This is how we go through an EKS account, from the fixed fees down to the volumes nobody remembers creating. Prices are us-east-1 list prices at the time of writing.

What EKS actually bills you for

The EKS control plane costs $0.10 per cluster per hour, about $73 a month, whether the cluster runs three pods or three thousand.

The fee becomes $0.60 per hour, six times as much, once the cluster’s Kubernetes version leaves standard support and enters extended support. Each minor version gets about 14 months of standard support after EKS releases it, then 12 months of extended support, after which EKS upgrades the cluster for you. The EKS pricing page and the Kubernetes version lifecycle have the current dates.

EKS Auto Mode adds a management fee on top of the EC2 price of every instance it runs, charged as a percentage of the instance’s hourly price; the pricing page has the rates.

Beyond that, the bill is ordinary AWS: EC2 instances or Fargate pods for the nodes, EBS for root and persistent volumes, load balancers for Services and Ingresses, data transfer between Availability Zones and through NAT gateways, and control-plane logs in CloudWatch Logs at $0.50 per GB ingested.

Start by listing the clusters and their support tier:

# Read-only: every cluster, its Kubernetes version and support type
for c in $(aws eks list-clusters --query 'clusters[]' --output text); do
  aws eks describe-cluster --name "$c" \
    --query 'cluster.[name,version,upgradePolicy.supportType,status,createdAt]' \
    --output text
done

EXTENDED in the third column means that cluster pays, or will pay, the higher rate; STANDARD means EKS auto-upgrades it instead.

Idle clusters and the extended-support tax

Two patterns show up in almost every account. The first is the dev cluster with no nodes. Somebody scaled the node group to zero and the cluster has been empty for months, still costing $73 a month, or $438 on an old version. Zero nodes, no recent API activity and a creation date from a previous team make it a deletion candidate.

The second is the cluster per developer or per branch. Namespaces exist for this. Five dev clusters are $365 a month before a single node; one cluster with five namespaces and a ResourceQuota each is $73, and shared nodes pack better too.

For clusters needed by day but not at night, scale the nodes to zero rather than the cluster. Managed node groups can sit at a minimum size of zero and Cluster Autoscaler scales them back up; Karpenter scales to zero on its own when nothing is pending.

Extended support is the one to act on quickly. Upgrading a minor version saves $365 a month per cluster, and the EKS upgrade insights list deprecated API usage before you move.

Node groups, Fargate, Karpenter and Auto Mode

How you provision nodes decides how much of each instance you actually use. Most mature clusters mix two of these four.

Option What you pay for Fits
Managed node groups EC2 instances in an Auto Scaling group, with or without pods on them Steady baseline, DaemonSets, workloads needing a specific type
Fargate vCPU-seconds and GB-seconds per pod, rounded up Bursty or isolated pods, clusters too small for a node
Karpenter EC2 instances it launches, sized to pending pods Variable workloads, Spot, mixed instance types
EKS Auto Mode EC2 instances plus a management fee Karpenter behavior without running Karpenter

Fargate’s price is the one people get wrong. It’s $0.04048 per vCPU-hour and $0.004445 per GB-hour for x86 Linux pods, with a one-minute minimum per pod. A pod asking for 2 vCPU and 8 GB costs about $0.117 an hour. An m7i.large with the same 2 vCPU and 8 GB is $0.1008 on-demand. So a perfectly packed node is about 15% cheaper than Fargate, and a node at 50% utilization is nearly twice as expensive.

Two details inflate Fargate pod cost: requests are rounded up to the nearest supported vCPU and memory combination (the Fargate pod configuration page lists them), and Fargate adds 256 MB to each pod’s memory for its Kubernetes components. A pod requesting 0.25 vCPU and 0.5 GB bills as 0.25 vCPU and 1 GB. EKS pods on Fargate also can’t use Fargate Spot (ECS only), run DaemonSets or mount EBS volumes.

Our default: a small managed node group for add-ons, Karpenter for the applications, Fargate only where a pod must be isolated.

Requests, limits and bin packing

The Kubernetes scheduler places pods by their resource requests, not by what they use. If every pod requests 1 vCPU and uses 100 millicores, your nodes are 10% busy and 100% full, and every autoscaler keeps adding nodes. This is the biggest cause of expensive clusters, ahead of instance choice and Spot.

Measure the gap on each node:

# Read-only: requested vs allocatable, then actual usage, per node
kubectl describe nodes | grep -A 7 'Allocated resources'
kubectl top nodes

Allocated resources shows requests as a share of what the node can hold; kubectl top shows real usage. A node at 90% requested and 25% used is paying for 65% air. Per workload, the Vertical Pod Autoscaler in Off mode writes a recommended request to each VPA object without changing anything.

  • Set requests near the 95th percentile of real use, not peak plus a margin someone picked in 2022.
  • Set memory limits equal to memory requests and leave CPU limits off or generous. CPU limits throttle; they don’t save money, because the node is paid for either way.
  • Right-size the small stuff. Sidecars and exporters with 500m CPU requests add up to whole nodes across a hundred pods.
  • Prefer fewer, larger nodes. The kubelet, kube-proxy, the CNI and any logging agent reserve capacity on every node.

On Fargate, the larger of a pod’s requests and limits decides which combination it bills as.

Cluster Autoscaler vs Karpenter consolidation

Cluster Autoscaler works at the node-group level. It adds nodes when pods are pending and removes a node once it has been under 50% requested for ten minutes (the --scale-down-utilization-threshold and --scale-down-unneeded-time defaults). It can’t change instance type, only pick among the groups you defined, and it won’t replace two half-empty nodes with one smaller one.

Karpenter launches the cheapest instance that fits the pending pods, from whatever types your NodePool allows. Its consolidation runs continuously: with consolidationPolicy: WhenEmptyOrUnderutilized it removes empty nodes, replaces a node with a cheaper one, or merges several nodes into fewer when the pods still fit. consolidateAfter sets how long a node must be underused first, and disruption budgets cap how many nodes it touches at once.

The difference on the bill is utilization. A Cluster Autoscaler fleet of fixed nodes tends to settle around 50-60% requested, because that’s where the scale-down rule stops. Karpenter, allowed a wide set of types, lands higher because it picks the node that fits the actual pods.

Two cautions. Pods that block eviction (no PodDisruptionBudget, or a karpenter.sh/do-not-disrupt annotation) pin their node open; kubectl get events -A --field-selector reason=Unconsolidatable names the culprit. And keep the instance set wide, or Karpenter has nothing cheaper to consolidate onto.

Spot and Graviton node pools

Spot and Graviton need code changes rather than purchase orders, and on Kubernetes both are easier than on plain EC2.

Spot sells spare capacity at up to 90% off on-demand, with a two-minute interruption notice. A Karpenter NodePool with karpenter.sh/capacity-type allowing both spot and on-demand tries Spot first and falls back when none is available. Managed node groups support Spot too, with capacityType: SPOT. Either way, give the pools at least ten instance types across every AZ, keep stateful pods on on-demand nodes with a taint, set PodDisruptionBudgets, and wire up interruption handling (Karpenter needs an SQS queue fed by EventBridge; managed node groups drain for you). Our Spot guide covers the EKS specifics.

Graviton is the quieter win. An m7g.large is $0.0816 an hour against $0.1008 for an m7i.large, about 19% less for the same vCPU and memory, and AWS puts the price-performance gain at up to 40%. Adopting it means publishing multi-architecture images and letting the NodePool pick either architecture through kubernetes.io/arch: ["amd64", "arm64"]. Pods with arm64 images land on Graviton; the rest stay on x86 while you rebuild them. Fargate’s Graviton option is around 20% below its x86 rates. Our Graviton and rightsizing guide has the compatibility checklist.

What the existing node groups run on:

# Read-only: capacity type, instance types, AMI and size of every node group
for c in $(aws eks list-clusters --query 'clusters[]' --output text); do
  for ng in $(aws eks list-nodegroups --cluster-name "$c" --query 'nodegroups[]' --output text); do
    aws eks describe-nodegroup --cluster-name "$c" --nodegroup-name "$ng" \
      --query "nodegroup.[clusterName,nodegroupName,capacityType,amiType,join(',',instanceTypes),scalingConfig.minSize,scalingConfig.desiredSize,scalingConfig.maxSize]" \
      --output text
  done
done

ON_DEMAND, one x86 type and minSize equal to desiredSize means nobody has revisited that group.

Volumes left behind by PVCs

Every PersistentVolumeClaim on EKS becomes an EBS volume (or an EFS access point), and EBS volumes outlive the things that created them. The default gp2 StorageClass has a Delete reclaim policy, so deleting the PVC deletes the volume. But deleting a StatefulSet does not delete its PVCs, and deleting a whole cluster without first deleting the workloads leaves every volume behind, because the CSI driver that would have cleaned up is gone. Volumes in the available state bill their full GB-month.

# Read-only: unattached EBS volumes and the PVC that created them, if any
aws ec2 describe-volumes --filters Name=status,Values=available \
  --query "Volumes[].[VolumeId,Size,VolumeType,CreateTime,Tags[?Key=='kubernetes.io/created-for/pvc/name'].Value|[0],Tags[?starts_with(Key,'kubernetes.io/cluster/')].Key|[0]]" \
  --output table

The EBS CSI driver tags each volume with the PVC name, namespace and PV name, so the table says which namespace in which cluster made each orphan. Snapshot anything you’re unsure about, then delete.

While you’re there, check the StorageClass. gp2 costs $0.10 per GB-month with IOPS tied to size; gp3 is $0.08 per GB-month and includes 3,000 IOPS and 125 MB/s regardless of size. A default StorageClass with type: gp3 is about 20% cheaper per GB and faster for small volumes, and existing volumes can be modified to gp3 in place.

EFS is the other side. Shared file systems mounted into pods bill $0.30 per GB-month for Standard storage, and files nobody has read in months are the norm on a shared scratch mount. Lifecycle management moves them to the Infrequent Access and Archive classes at a fraction of that price; check each file system for a policy.

A load balancer per Service

A Kubernetes Service of type LoadBalancer creates an AWS load balancer: a Classic Load Balancer at $0.025 an hour with the in-tree controller, or a Network Load Balancer at $0.0225 an hour plus capacity units with the AWS Load Balancer Controller. Either way that’s $16-18 a month per Service before traffic, so forty microservices each exposing one pay about $700 a month for forty mostly idle load balancers.

The fix is one shared entry point. The alb.ingress.kubernetes.io/group.name annotation lets many Ingresses across namespaces share one Application Load Balancer with host- and path-based rules. Or run one NLB in front of an in-cluster ingress controller and keep every other Service as ClusterIP.

To find the ownerless ones, list load balancers with aws elbv2 describe-load-balancers and aws elb describe-load-balancers, then read their tags: the Load Balancer Controller tags what it creates with elbv2.k8s.aws/cluster and the in-tree controller with kubernetes.io/cluster/<name>. A load balancer whose cluster no longer exists is pure waste; the controller couldn’t delete it because the controller was deleted first.

Data transfer between Availability Zones

Traffic between AZs costs $0.01 per GB in each direction, so a request and its response between two AZs pay twice. On a cluster this is nearly invisible: pods are spread across three AZs, Services round-robin across every endpoint regardless of zone, and a chatty microservice mesh crosses AZs on two-thirds of its calls.

Topology Aware Routing, enabled per Service with the service.kubernetes.io/topology-mode: Auto annotation, makes kube-proxy prefer endpoints in the caller’s zone when each zone has enough of them. A service mesh’s locality-aware load balancing does the same at the sidecar. For the biggest talkers, a cache or read replica per AZ avoids the hop entirely. Remember too that an EBS volume lives in one AZ, so a pod with a PVC is pinned there and everything calling it from other zones pays the cross-AZ rate.

Image pulls through a NAT gateway from private subnets are the other data-transfer line; our NAT gateway and data transfer guide covers the ECR endpoints that avoid it.

Reservations and Savings Plans on nodes

Commitments apply to the nodes, not to Kubernetes. An EC2 Instance Savings Plan or a Reserved Instance covers the instance-hours your node groups and Karpenter run, exactly as if they were plain EC2. A Compute Savings Plan covers those and Fargate pods, across families and regions. Nothing covers the control-plane fee or Spot, which already carries its own discount.

The complication is that consolidation makes your instance mix move. An EC2 Instance Savings Plan locked to m6i pays less when Karpenter decides an r7g is cheaper this week. For a cluster with active consolidation, size a Compute Savings Plan on the on-demand spend that stays flat after rightsizing and Spot migration, not before. Our Reserved Instances vs Savings Plans guide covers the baseline math; the EKS-specific rule is to commit last, after the fleet has stopped changing shape.

FAQ

How much does the EKS control plane cost?

$0.10 per cluster per hour, about $73 a month, for Kubernetes versions in standard support. Clusters on a version in extended support pay $0.60 per hour, about $438 a month.

Is Fargate cheaper than EC2 nodes on EKS?

Per unit of requested vCPU and memory, Fargate costs about 15% more than a fully used on-demand node of a current-generation type. It wins when nodes would otherwise sit half empty, and for pods that must run isolated.

Do Savings Plans cover EKS?

Compute and EC2 Instance Savings Plans cover the EC2 nodes, and Compute Savings Plans also cover Fargate pods. Neither covers the control-plane fee or Spot instances.

Why do I still have EBS volumes after deleting an EKS cluster?

The EBS CSI driver deletes volumes when PVCs are deleted. If the cluster goes first, the driver is gone and every volume stays, billed as available. Delete workloads and PVCs before the cluster, or sweep unattached volumes afterwards.

Fix the requests first

Run the node commands above and write down requested versus used for each node. If the gap is more than 40 points, no autoscaler, Spot pool or Savings Plan will fix the bill as much as cutting requests to what the pods actually use, so do that first. Then take the cheap one-offs: upgrade anything in extended support, delete empty clusters, sweep unattached volumes and ownerless load balancers. Leave commitments until the fleet has settled.

Kostrix finds the leftovers for you: its read-only idle and orphaned-resource checks list unattached EBS volumes and idle load balancers across every account, priced from the bill, and the scan repeats weekly so the next deleted cluster doesn’t leave volumes behind.