Interactive · Amazon EKS · data-plane scaling
New here? Step through with Next → / ← Back, or press ▶ Play to autoplay — each step lights up one hop in the scaling loop.
The chain from a climbing metric to a Pending pod is identical in all three columns. Everything diverges at one point: who watches that unschedulable pod, and how it turns into an EC2 instance.
| Cluster Autoscaler | Karpenter | EKS Auto Mode | |
|---|---|---|---|
| Where it runs | Deployment pod in your cluster (leader-elected) | Controller pod in your cluster | AWS-managed — inside the EKS control plane; no pod you run or see |
| Reacts to Unschedulable | 10s scan of api-server + simulated scheduling | event-driven watch, bin-pack | event-driven watch (managed) |
| CREATE call | autoscaling:SetDesiredCapacity → ASG → EC2 | ec2:CreateFleet directly (NodeClaim, no ASG) | AWS-managed Karpenter → EC2 managed instance |
| DELETE call | autoscaling:TerminateInstanceInAutoScalingGroup | delete NodeClaim → ec2:TerminateInstances | AWS-managed terminate + 21-day cycle-out |
| Node / AMI | your EKS-optimized AMI, you patch | your AMI via EC2NodeClass, you patch | managed Bottlerocket, no SSH/SSM, AWS patches |
| Key constraint | homogeneous groups; ASG-per-AZ pattern | you run & pay for the controller | 21-day max node lifetime; from Apr 22 2026 instances hidden by default from EC2 console/API list ops |
The pod-scaling loop. Metrics only ever move the replica count — never a node directly.
| Loop | control loop, --horizontal-pod-autoscaler-sync-period 15s |
|---|---|
| Algorithm | desiredReplicas = ceil[ currentReplicas × (currentMetric / targetMetric) ] |
| Tolerance | skip if the ratio is within 0.1 of 1.0 |
| Scale-up stabilization | 0s — immediate |
| Scale-down stabilization | 300s (--horizontal-pod-autoscaler-downscale-stabilization; takes the max recommendation over the window) |
| Metrics source | metrics.k8s.io via the metrics-server add-on (+ custom / external metrics APIs) |
| Object changed | the target Deployment's .spec.replicas (scale subresource) |
| Mechanism | Behaviour |
|---|---|
| PodDisruptionBudget | All three provisioners honour PDBs — a restrictive PDB can pin an underutilized node open indefinitely (drain cannot evict). |
| Per-pod opt-out | CAS: cluster-autoscaler.kubernetes.io/safe-to-evict. Karpenter / Auto Mode: karpenter.sh/do-not-disrupt. |
| CAS scale-down | node unneeded when requests < 0.5 of allocatable for 10m, then cordon + drain, then terminate. |
| Karpenter / Auto | consolidation (WhenEmptyOrUnderutilized), throttled by disruption budgets; cordon + drain, then delete. |
(maxENIs × (IPsPerENI − 1)) + 2. Hit it and new pods stay Pending / ContainerCreating on a Ready node — Ready is not the finish line. Mitigate with prefix delegation (~16× density).Deep dive — skip if you just wanted the loop. The interactive shows the trigger → create → delete cycle; this is the mechanics underneath it.
The image is registry.k8s.io/autoscaling/cluster-autoscaler:vX.Y.Z, and the tag is pinned to your cluster's Kubernetes minor version — a 1.30 cluster runs the 1.30 image. Kubernetes SIG Autoscaling builds and publishes it from github.com/kubernetes/autoscaler (the cluster-autoscaler/ directory); you pull a prebuilt static Go binary and never compile it. The algorithm is Go in that repo: scale-up simulates scheduling the pending pods (the U3 Unschedulable set) against each node group's template and an expander picks which group to grow; scale-down marks a node unneeded when utilization drops below --scale-down-utilization-threshold (0.5) for --scale-down-unneeded-time (10m) — --scale-down-unready-time is 20m for NotReady nodes — then simulates whether its pods fit elsewhere before removing it. The AWS-specific calls live in cluster-autoscaler/cloudprovider/aws, which controls the DesiredReplicas field of your EC2 Auto Scaling Groups. CAS runs as a single leader-elected replica — it is not itself horizontally scaled.
# tag is pinned to the cluster's K8s minor (1.30 cluster -> the 1.30 image) registry.k8s.io/autoscaling/cluster-autoscaler:v1.30.0 # prebuilt static Go binary -> github.com/kubernetes/autoscaler (cluster-autoscaler/) # AWS provider -> cluster-autoscaler/cloudprovider/aws -> ASG DesiredReplicas
Install via the Helm chart autoscaler/cluster-autoscaler (or the upstream manifest); the image tag must match your K8s minor. Permission flows through IRSA: a ServiceAccount is annotated with eks.amazonaws.com/role-arn, the pod receives a projected service-account token, and the AWS SDK exchanges it at STS via AssumeRoleWithWebIdentity for temporary credentials. The IAM role grants the write actions autoscaling:SetDesiredCapacity and autoscaling:TerminateInstanceInAutoScalingGroup (scoped by ASG-tag conditions) plus a set of read actions. Two flags wire it to AWS: --cloud-provider=aws selects the provider, and --node-group-auto-discovery finds the ASGs it may manage by tag. eksctl and the Terraform EKS module apply those tags for managed node groups.
# the actions the EKS docs grant (writes scoped by ASG-tag conditions)
"Action": [
"autoscaling:SetDesiredCapacity",
"autoscaling:TerminateInstanceInAutoScalingGroup",
"autoscaling:DescribeAutoScalingGroups",
"autoscaling:DescribeAutoScalingInstances",
"autoscaling:DescribeLaunchConfigurations",
"autoscaling:DescribeScalingActivities",
"autoscaling:DescribeTags",
"ec2:DescribeInstanceTypes",
"ec2:DescribeLaunchTemplateVersions",
"ec2:GetInstanceTypesFromInstanceRequirements",
"eks:DescribeNodegroup"
]
# IRSA: projected SA token -> STS AssumeRoleWithWebIdentity -> temp creds apiVersion: v1 kind: ServiceAccount metadata: name: cluster-autoscaler annotations: eks.amazonaws.com/role-arn: arn:aws:iam::<acct>:role/<role> # discovery flags on the CAS container --cloud-provider=aws --node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled,k8s.io/cluster-autoscaler/<cluster-name>
With 0 live nodes there is nothing to inspect, so CAS reads template tags on the ASG to synthesize a fake node for its scheduling simulation — labels, resources, and taints each have their own tag prefix. Without these tags a scaled-to-zero group is invisible to the simulation and never grows.
# scale-from-zero: CAS builds a phantom node from ASG template tags
k8s.io/cluster-autoscaler/node-template/label/<key> = <value>
k8s.io/cluster-autoscaler/node-template/resources/cpu = 4
k8s.io/cluster-autoscaler/node-template/resources/memory = 16Gi
k8s.io/cluster-autoscaler/node-template/taint/<key> = <value>:NoSchedule
The node's user-data bootstrap (bootstrap.sh on AL2, nodeadm on AL2023) starts the kubelet with the node's labels and taints; managed node groups propagate the labels you configured and add the eks.amazonaws.com/* labels. So the identity CAS assumed from the template tags in Q3 is the identity the real node actually boots with at U6.
# user-data (bootstrap.sh / nodeadm) -> kubelet starts with labels + taints # managed node group also injects the eks.amazonaws.com/* labels kubelet --node-labels=<your-labels>,eks.amazonaws.com/nodegroup=<ng> ...
A managed node group is an EKS abstraction that provisions your nodes as part of an EC2 Auto Scaling group managed for you by Amazon EKS. The crux: CAS only ever talks to the underlying ASG (SetDesiredCapacity / TerminateInstanceInAutoScalingGroup), never the EKS Nodegroup API; EKS re-syncs its own view via DescribeNodegroup. "Managed" means lifecycle ops: provisioning, AMI and security patching (rolled as new launch-template versions), graceful cordon + drain on updates and scale-down, and node auto-repair. It does not autoscale on its own — the desired count still comes from CAS (or you). A plain managed node group will never add or remove nodes for load by itself. A self-managed node group is a raw ASG you create and own. Terraform's terraform-aws-modules/eks creates managed node groups and can apply the CAS discovery tags. A mixed-instances policy lets one ASG blend instance types and On-Demand/Spot, but CAS needs the types to be "the same shape for CPU, Memory, and GPU" — it simulates using the first type.
Karpenter runs as a controller (registry public.ecr.aws/karpenter/, Helm charts karpenter + karpenter-crd) with no ASG and no node group. It watches unschedulable pods, computes a best-fit instance for exactly the pending pods, and calls ec2:CreateFleet directly (also RunInstances / RequestSpotInstances), removing nodes via ec2:TerminateInstances. Configuration is Kubernetes-native CRDs: a NodePool (allowed instance types, zones, capacity types, limits, disruption) and an EC2NodeClass (AMI, IAM role, subnets, security groups); each provisioning decision materializes a NodeClaim. Consolidation actively replaces and removes nodes to pack cheaper (consolidationPolicy: WhenEmpty / WhenEmptyOrUnderutilized, via Delete and Replace) — not just idle scale-down. For Spot it diversifies across many types and pools (Price-Capacity-Optimized) and drains gracefully on the Spot interruption / rebalance notice delivered through an SQS --interruption-queue.
apiVersion: karpenter.sh/v1
kind: NodePool
spec:
template:
spec:
requirements:
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["c","m","r"]
nodeClassRef:
kind: EC2NodeClass
name: default
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
---
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
spec:
amiFamily: AL2023
role: KarpenterNodeRole
subnetSelectorTerms:
- tags: { karpenter.sh/discovery: <cluster-name> }
securityGroupSelectorTerms:
- tags: { karpenter.sh/discovery: <cluster-name> }
Who owns which decision — same trigger (an Unschedulable pod at U3), three different levers.
| Provisioner | Owns the decision | How it acts |
|---|---|---|
| Cluster Autoscaler | the node count | dials an ASG you pre-shaped (SetDesiredCapacity) |
| Karpenter | the instance itself | calls EC2 directly (CreateFleet, no ASG) |
| EKS Auto Mode | hands it off | AWS runs Karpenter for you inside the control plane |