The $340 EKS Fee I Didn't Know I Was Paying


I opened the AWS billing console expecting compute, storage, maybe a noisy NAT gateway. The total was $750 for the month — high for the size of the platform we were running, but not catastrophic. What was catastrophic was where the money was going.

EKS, $412. Of which $340 came from a single line item I had never seen before: USE1-AmazonEKS-Hours:extendedSupport.

I’d been paying a six-times premium on my Kubernetes control plane and never noticed, because the line item only shows up in Cost Explorer’s USAGE_TYPE breakdown — not in the default service-level summary. The dashboard just said “EKS: $412”. The fix was a one-line config change. The path to that fix had two cliffs in it.

This is the story of finding the leak, both cliffs, and the playbook I now keep pinned for the next minor-version upgrade.

The Bill

Here’s what the bill looked like, sorted by amount:

ServiceMonthly cost
Elastic Container Service for Kubernetes (EKS)$412.00
Elastic Compute Cloud (EC2 + NAT + EBS)$170.50
Elastic Load Balancing$16.28
Relational Database Service$15.26
Virtual Private Cloud$10.80
Data Transfer$6.20
Secrets Manager, Route 53, ECR, SES…< $5 each
Pre-tax total$636.25
Tax$114.53
Grand total$750.78

EC2 made sense — we run a small EKS cluster with three nodes (one On-Demand for platform services, two Spot for workloads), a single NAT gateway, and a few PVCs. RDS is a Postgres instance for Langfuse traces. ELB is one NLB for ingress. All inside a normal range.

What did not make sense was $412 for EKS. EKS standard pricing is $0.10/hour per cluster control plane. Multiplied out, that’s $73/month. We had one cluster. Where was the other $339 coming from?

The Diagnosis

The default Cost Explorer view groups by service. To see what’s actually being billed inside the service, you have to switch the grouping to Usage Type and filter on the service:

aws ce get-cost-and-usage \
  --time-period Start=2026-04-01,End=2026-05-01 \
  --granularity MONTHLY \
  --metrics UnblendedCost \
  --group-by Type=DIMENSION,Key=USAGE_TYPE \
  --filter '{"Dimensions":{"Key":"SERVICE",
            "Values":["Amazon Elastic Container Service for Kubernetes"]}}'

The result was unambiguous:

Usage typeAmount
USE1-AmazonEKS-Hours:extendedSupport$340
USE1-AmazonEKS-Hours:perCluster$72

perCluster was the expected $0.10/hour control plane charge. extendedSupport was the surprise.

What extendedSupport Actually Means

EKS supports each Kubernetes minor version through a standard support window of 14 months from the day that version becomes generally available on EKS. After 14 months, your cluster doesn’t break. It transitions automatically to extended support, which keeps the version running for another 12 months — at an additional $0.50 per hour per cluster, on top of the normal control plane fee.

That works out to about $360/month per cluster for the privilege of staying on a version AWS would prefer you upgrade. In our case the actual charge was $340 because we entered extended support partway through the month.

The crucial detail: this transition is silent. There is no email. The console shows a small banner if you go look for it, but no proactive notification. I created the cluster on K8s 1.32 in March, when 1.32 was already at the back end of its standard window. By April, we were paying the surcharge and I didn’t know it had a name.

The fix is conceptually trivial: upgrade the cluster to a version that’s still in standard support. Each minor version bump renews the 14-month clock. EKS lets you bump exactly one minor version at a time.

The Fix in Theory

Our EKS module is small. The relevant Terraform variables look like this:

variable "kubernetes_version" {
  description = "EKS Kubernetes version"
  type        = string
  default     = "1.32"
}

variable "kube_proxy_version" {
  description = "EKS kube-proxy addon version (must match kubernetes_version)"
  type        = string
  default     = "v1.32.13-eksbuild.5"
}

Bump those two strings, run tofu apply, wait. EKS rolls the control plane in place (HA, no API downtime), and because our worker-node launch template references the cluster’s K8s version through an SSM parameter, the workload node group rolls automatically when the launch template’s AMI ID changes. Roughly 45 minutes end to end for a clean run.

We were not going to get a clean run.

Cliff One: A Single-Replica minAvailable: 1

The control plane upgraded fine — about half an hour, no incident, kubectl never paused. Then the workload node group started rolling. EKS launched a new 1.33 node, drained the first old node onto it, and tried to drain the second old node. After fifteen minutes of retries, terraform errored out:

Error: waiting for EKS Node Group version update:
unexpected state 'Failed', wanted 'Successful'.
last error: ip-10-0-47-74.ec2.internal:
PodEvictionFailure: Reached max retries while
trying to evict pods from nodes

At first I thought this was a stuck PVC. It wasn’t — it was a PodDisruptionBudget we’d added months ago and forgotten about. Look at it on its own and it seems sensible:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: mongodb-pdb
  namespace: kids-games
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: mongodb

“At least one MongoDB pod must always be available.” Reasonable for a multi-replica StatefulSet. The problem is that our MongoDB was a single-replica StatefulSet — fine for an early-stage platform, not fine combined with minAvailable: 1.

kubectl get pdb makes the bug visible:

NAME          MIN AVAILABLE   ALLOWED DISRUPTIONS
mongodb-pdb   1               0

ALLOWED DISRUPTIONS: 0 means no voluntary eviction can ever succeed. Not just for node upgrades — for any controller-initiated drain. Every cluster maintenance operation we’d ever try would silently deadlock here, because the only pod the budget protects is also the only pod that exists. There’s no way for the API server to evict it without dropping below minAvailable.

For a single-replica StatefulSet, the only PDB shapes that actually permit maintenance are:

  • maxUnavailable: 1 — explicit: “the one pod is allowed to bounce”
  • delete the PDB entirely — honest: a 1-replica setup has nothing to protect

We changed the manifest to maxUnavailable: 1 and applied it:

spec:
  # 1-replica StatefulSet: minAvailable:1 blocks ALL voluntary disruption
  # (node upgrades, AMI patches). maxUnavailable:1 lets node maintenance
  # proceed with brief Mongo unavailability while EBS detaches/reattaches.
  maxUnavailable: 1

Then we re-triggered the rolling update with the AWS CLI rather than terraform — when an EKS managed node-group version update fails, the nodegroup’s pointer to the launch-template version rolls back. Terraform thinks everything is fine because state matches its desired config; but the live nodegroup is referencing the previous launch template. The retry has to come from outside terraform:

aws eks update-nodegroup-version \
  --cluster-name aws-platform \
  --nodegroup-name aws-platform-workloads-v2 \
  --launch-template id=lt-09c4ffeabc3ea0c52,version=4

This time eviction succeeded. MongoDB was unavailable for about 90 seconds while its EBS volume detached from the old node and reattached to the new one — the pod came back up cleanly, no data loss. The cluster was on 1.33. The $340/month bill stopped that minute.

Cliff Two: The Spot Pool Has a Bad Day

We then went immediately for 1.33 → 1.34. Same procedure, same expected runtime. K8s 1.33 is itself only good through about July 2026 before it enters extended support of its own; one stop-gap upgrade wasn’t enough. Locking in 1.34 buys us through November.

The control plane upgrade was clean again. The first workload node rolled cleanly. The second workload node — different cliff:

Error: NodeCreationFailure: Couldn't proceed with upgrade process
as new nodes are not joining node group aws-platform-workloads-v2

This one was AWS, not us. ASG activity log:

StatusCode: Failed
StatusMessage: Could not launch Spot Instances.
UnfulfillableCapacity - Unable to fulfill capacity due to your
request configuration. Please adjust your request and try again.

When EKS rolls a managed node group, it temporarily surges the ASG’s max size to bring up replacement nodes before draining the old ones. We run our workloads on Spot, which means the ASG asks the Spot pool for capacity, and on a bad day the Spot pool says no. AWS gives back a generic UnfulfillableCapacity and the rolling update fails.

The workaround is dull: wait a few minutes and try again. Spot pricing fluctuates throughout the day, and pools fill up and empty on the order of minutes. The same update-nodegroup-version call we used for the PDB recovery, ten minutes later, completed in 14 minutes with no further drama.

If a long-term fix were needed, the lever is in the launch template’s instance_types list — adding a wider variety of compatible families (we have t3.medium, t3a.medium, t3.large; we could add t3a.large, t2.large) gives the ASG more pools to draw from. For our use case, the transient hit didn’t justify the change.

Where We Ended Up

Two cluster upgrades and one PDB fix later, the bill picture changed materially:

Line itemBeforeAfter
EKS control plane$412$72
Everything else$338$338
Pre-tax total$636$405

A 36% cut on the pre-tax total from a single afternoon’s work, and the savings stay locked in for the next six months — until 1.34 starts approaching its own standard-support window in late 2026, at which point the same upgrade procedure renews the runway again.

The Runbook

For anyone running EKS on a small team, the takeaways:

Do this every few months, not just when something breaks. Open Cost Explorer, switch the grouping to Usage Type on EKS specifically, and look for extendedSupport. If you see it, you’re paying the premium.

Treat Kubernetes versioning as a billing decision. Standard support lasts 14 months per minor version. Plan your upgrades on a calendar — not in response to incidents.

Audit your PodDisruptionBudgets separately from your replica counts. minAvailable: 1 on a 1-replica deployment is not protection — it’s a deadlock. Every voluntary eviction will fail. The two safe shapes for single-replica workloads are maxUnavailable: 1 or no PDB at all.

Know how to recover from a failed managed node-group update. When the rolling update fails, the nodegroup’s launch-template reference rolls back. Terraform won’t notice; you have to nudge EKS yourself with aws eks update-nodegroup-version. Keep that command in your toolbox.

Spot capacity failures are transient. UnfulfillableCapacity almost always clears in five to fifteen minutes. Retry before redesigning. If it persists, widen the instance-type pool in your launch template.

The version dance never stops. Each upgrade buys 12–14 months. The clock starts the moment EKS GAs the next minor. Put a calendar reminder for two months before standard support ends.

A Small Note on Tooling

The day’s work was mostly Terraform — well, OpenTofu, since Hashicorp’s terraform v1.5.7 can’t satisfy our >= 1.6 requirement and tofu is a drop-in replacement that does. None of the AWS-side commands cared which one we used. State backend stays on S3, locking on DynamoDB, providers from the OpenTofu registry. If you’ve been holding off on switching from Terraform to OpenTofu, the bottom line is: it’s the same workflow with different binaries.

The harder thing to get right was knowing when to hand work off to the AWS CLI and when to keep it in Tofu. Tofu is great for stating intent; the EKS managed update API is what you actually need when intent has already been recorded but reality didn’t catch up.

Closing

The thing that nags me about this story isn’t the bill. $340 a month is real money, but it’s not the kind of number that justifies a war room. The thing that nags me is that I had no instrumentation that would have caught it. Cost Explorer’s default view aggregates EKS into a single line; my own dashboards looked at compute, not control-plane premiums; AWS sent no notification. The cluster itself worked fine. The only signal was the bill, after the fact.

I now have an alarm that screams if Hours:extendedSupport ever appears on the daily costs report. It’s not a clever check — it’s a one-line filter on the same Cost Explorer query at the top of this post. But it would have caught the leak the day it started, instead of a month later.

The check in your environment costs nothing to add and will almost certainly never fire. That’s the point.