Scaling AKS: Cluster Autoscaler and HPA
Why this matters
You already know the Horizontal Pod Autoscaler (HPA) from the Kubernetes track — it adds/removes pod replicas based on load. But on a small, fixed-size AKS node pool, the HPA can want more pods than the existing nodes can physically fit. AKS's Cluster Autoscaler solves the other half of the problem: adding/removing nodes based on demand. Combining both is how a real cluster elastically handles load without you manually resizing anything — and without paying for idle capacity when load drops.
Concepts
HPA scales pods; Cluster Autoscaler scales nodes. These operate at
different layers and you typically want both, together, for a workload
that needs to handle variable traffic: HPA reacts first (fast, seconds to
a couple minutes) by changing replica count against a metric (CPU%,
memory, or a custom metric); if the extra replicas don't fit on existing
nodes, they go Pending, and Cluster Autoscaler notices unschedulable
pods and adds a node to your pool (slower, a few minutes, since it's
provisioning a real VM) so they can schedule.
Cluster Autoscaler is per-node-pool, with a min/max range. You enable it with a floor and ceiling node count; Azure adds nodes up to the max when pods can't be scheduled, and removes nodes back down toward the min when they've been underutilized for a while (it's conservative about scaling down — it won't remove a node that has pods on it it can't safely evict, e.g. pods without a controller, or ones violating a PodDisruptionBudget).
Why the max matters for cost. Cluster Autoscaler's ceiling is also your cost ceiling — every additional node it's allowed to add is another billable VM. For a learning cluster, keep the max small (e.g. 3-4) so an autoscaling experiment (or an HPA misconfiguration) can't accidentally scale you into a large, expensive fleet of nodes overnight.
HPA still needs metrics-server, exactly as on your local cluster —
already present on AKS by default (you confirmed this in module 02 with
kubectl top).
What AKS manages vs. what you own: Azure runs the Cluster Autoscaler controller for you (you don't install or operate it) and handles the actual VM provisioning/deprovisioning when it decides to scale. You still own: the min/max bounds you set (cost/capacity tradeoff), your HPA target metrics and thresholds, and your Pods' resource requests (Cluster Autoscaler's scheduling-fit decisions are driven by requests, same as the scheduler itself — under-requesting starves pods, over-requesting triggers unnecessary node adds).
Command reference
| Command | What it does | Example |
|---|---|---|
az aks nodepool update --enable-cluster-autoscaler |
Turns on the Cluster Autoscaler for a node pool with min/max bounds | az aks nodepool update --resource-group rg-aks-learn --cluster-name aks-learn --name nodepool1 --enable-cluster-autoscaler --min-count 1 --max-count 3 |
az aks nodepool update --disable-cluster-autoscaler |
Turns it off | az aks nodepool update --resource-group rg-aks-learn --cluster-name aks-learn --name nodepool1 --disable-cluster-autoscaler |
az aks nodepool update --update-cluster-autoscaler |
Changes min/max bounds without disabling/re-enabling | az aks nodepool update --resource-group rg-aks-learn --cluster-name aks-learn --name nodepool1 --update-cluster-autoscaler --min-count 1 --max-count 4 |
az aks nodepool show |
Shows a node pool's config including autoscaler state | az aks nodepool show --resource-group rg-aks-learn --cluster-name aks-learn --name nodepool1 --output table |
kubectl autoscale deployment |
Creates an HPA imperatively | kubectl autoscale deployment demo-app --cpu-percent=50 --min=1 --max=10 |
kubectl get hpa -w |
Watches HPA current/target metrics and replica counts | kubectl get hpa -w |
kubectl describe hpa |
Shows HPA scaling events/decisions | kubectl describe hpa <name> |
kubectl get nodes -w |
Watches node count change as Cluster Autoscaler acts | kubectl get nodes -w |
kubectl top pods |
Shows live CPU/memory per pod, useful to watch HPA's input metric | kubectl top pods |
Hands-on exercises
Enable Cluster Autoscaler on your node pool. Find its name (
az aks nodepool list --resource-group rg-aks-learn --cluster-name aks-learn --output table), then run:az aks nodepool update --resource-group rg-aks-learn --cluster-name aks-learn \ --name nodepool1 --enable-cluster-autoscaler --min-count 1 --max-count 3Verify:
az aks nodepool show --resource-group rg-aks-learn --cluster-name aks-learn --name nodepool1 --output tableshows autoscaling enabled with the min/max you set.Deploy a CPU-hungry test app with an HPA. Deploy a small Deployment with a modest CPU request (e.g.
100m) running something that can be made to burn CPU (a simple busy-loop app, orstressin a container). Create an HPA targeting it:kubectl autoscale deployment <name> --cpu-percent=50 --min=1 --max=10. Verify:kubectl get hpashows the HPA with a real (not<unknown>) current CPU percentage after a minute.Generate load and watch the HPA react. Drive CPU load against your app (e.g. run several
kubectl execbusy-loops, or a load tool from inside the cluster). Watchkubectl get hpa -w— verifyREPLICASclimbs as the current CPU% exceeds the 50% target.Watch Cluster Autoscaler kick in. As HPA pushes replica count up past what your existing nodes can fit, some new pods will go
Pending(check withkubectl get pods -o wide). Watchkubectl get nodes -w— verify a new node appears (this can take a few minutes, since it's a real VM provisioning) and the previouslyPendingpods move toRunningonce it's ready.Stop the load and watch it scale back down. Remove the load generator. Watch
kubectl get hpa -w— verify replica count drops back down after HPA's stabilization window. Then watchkubectl get nodes -wover a longer period (Cluster Autoscaler is conservative — scale-down typically waits several minutes of sustained low utilization before removing a node) — verify the node count eventually returns toward your--min-count.Check the cost-relevant ceiling. Run
az aks nodepool show --resource-group rg-aks-learn --cluster-name aks-learn --name nodepool1 --query "[autoScalerProfile,maxCount,minCount]"(or inspect the fullshowoutput) and confirm your--max-countis still the small number you set — this is your safety ceiling against a runaway scale-out.Diagnose and fix: HPA shows
<unknown>for targets. Deliberately remove the CPU request from your test Deployment's container spec (resources.requests.cpu) and re-apply. Runkubectl describe hpa <name>— verify it reports something like "missing request for cpu" or fails to compute a percentage, because HPA's CPU-percentage target is relative to the pod's request, not an absolute number. Fix it by restoring a CPU request and re-applying; confirmkubectl get hpashows a real percentage again within a minute.Clean up. Delete the test HPA, Deployment, and any load-generator pods:
kubectl delete hpa <name>,kubectl delete deployment <name>. Consider lowering--max-countback to1, or disabling the autoscaler entirely if you're not using it further this session:az aks nodepool update --resource-group rg-aks-learn --cluster-name aks-learn --name nodepool1 --disable-cluster-autoscaler, then manually scale back to your usual 2 nodes withaz aks scaleif Cluster Autoscaler left you at a different count. If you're done for the day, stop or delete the cluster per module 01.
Independent challenge
No commands given here — figure it out yourself using what you know from this module and earlier ones.
Task: Engineer a situation where you can watch both scaling layers act in sequence, safely and cheaply. Enable the Cluster Autoscaler on your node pool with a deliberately tight ceiling so a runaway can't spend much, put an HPA on a CPU-burning workload, and then drive enough load that the HPA wants more replicas than your current nodes can hold. Watch the handoff: the HPA adds replicas, some go Pending because they don't fit (the same insufficient-resources scheduling reality from module 02, conceptually building on that module), and the Cluster Autoscaler then adds a node so they can run. Stop the load and observe both layers retreat. The critical discipline here: before you walk away, confirm the load generator is truly gone and the node count has settled back toward your minimum — an autoscaler left scaled out on a forgotten load test is a real bill. Tear down the test workload and reset the pool when done.
Stuck? One hint
The HPA's scale target is driven by the pod's CPU request, so give the workload a small request and set --cpu-percent low enough that load pushes it over; watch kubectl get hpa -w, kubectl get pods -o wide, and kubectl get nodes -w side by side to see the layers act.
Common mistakes & troubleshooting
- Setting
--max-counttoo high "to be safe." Every extra node Cluster Autoscaler is allowed to add is a node it might add and bill you for, if load (or a misconfigured HPA) demands it. Keep the ceiling tight for a learning cluster. - Forgetting CPU requests on the scaled Deployment. Both HPA CPU-percent targets and Cluster Autoscaler's scheduling-fit decisions are driven by requests, not real-time usage alone. No request means broken or unpredictable scaling in both places.
- Expecting instant node scale-down. Cluster Autoscaler intentionally waits out a stabilization period before removing nodes, to avoid flapping. Don't assume it's broken if node count doesn't drop the moment load ends — give it several minutes.
- Confusing HPA min/max with Cluster Autoscaler min/max. They bound
different things (pod replicas vs. node count) and are configured with
entirely different commands (
kubectl autoscale/HPA manifest vs.az aks nodepool update). Mixing them up leads to debugging the wrong layer. - Cost pitfall: leaving Cluster Autoscaler enabled with a generous max
and forgetting a load test is still hammering the cluster. Always
confirm your load generator is actually stopped (
kubectl get pods— no stray busy-loop pods) before walking away, and re-check node count before ending a session.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What's the difference between what HPA scales and what Cluster Autoscaler scales?
- Why does an HPA-driven scale-up sometimes require Cluster Autoscaler
to also act, and what's the visible sign in
kubectl get podswhile waiting? - Why is Cluster Autoscaler's
--max-counta cost control, not just a capacity control? - Why does HPA need CPU requests set on the target Deployment to compute a CPU percentage?
- Why is Cluster Autoscaler's scale-down intentionally slower/more conservative than its scale-up?
- What command changes Cluster Autoscaler's min/max bounds without fully disabling and re-enabling it?
Show answers
- HPA scales the number of pod replicas for a workload based on a metric (e.g. CPU%). Cluster Autoscaler scales the number of nodes in a node pool based on whether pods can be scheduled.
- Because HPA can create more replicas than existing nodes have room
for; those extra pods sit
Pendinguntil Cluster Autoscaler notices and provisions a new node for them to schedule onto. The visible sign is pods inPendingstate inkubectl get pods -o widewith no node assigned. - Because every additional node the autoscaler is permitted to add is a real, billable Azure VM — the max is effectively the most you're willing to let automatic scaling spend, independent of whether the load turns out to be real traffic or a misconfiguration.
- Because HPA's
--cpu-percenttarget is calculated as (current usage) / (requested CPU) — with no request, there's no denominator, so HPA can't compute a meaningful percentage and reports the target as unknown. - To avoid "flapping" — rapidly adding and removing nodes in response to short-lived dips in load, which would be both disruptive (evicting pods) and not actually cost-effective given node provisioning isn't instantaneous either.
az aks nodepool update --update-cluster-autoscalerwith new--min-count/--max-countvalues.
Next
06-monitoring-aks-azure-monitor-container-insights
— see what's happening across your cluster with real observability
tooling instead of ad hoc kubectl commands.