Back to CourseAdvance5 min read
Azure /

AKS Scaling, Upgrades and Automatic Mode

Scale AKS with the cluster autoscaler, node autoprovisioning and KEDA, and keep the cluster upgraded without downtime.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: October 11, 2026
Last updated: September 7, 2026
Advance Topic

Three levels of scaling

Horizontal Pod Autoscaler  more pods, based on metrics
Cluster autoscaler         more nodes, when pods cannot be scheduled
Node autoprovisioning      the right node types, chosen automatically

They work together. HPA adds pods, and when no node has room the cluster autoscaler adds a node.

Horizontal Pod Autoscaler

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: shop-api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: shop-api
  minReplicas: 3
  maxReplicas: 30
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 50
          periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 30

averageUtilization: 70 is a percentage of the pod’s CPU request, not of the node. A pod requesting 200m scales when it averages 140m. Without a request, HPA cannot calculate anything and does nothing.

The behavior block is worth setting. Scaling up immediately and down slowly, over a five minute stabilisation window, avoids the flapping that default settings produce.

Cluster autoscaler

az aks nodepool update \
  --cluster-name aks-shop --resource-group rg-shop-prod-uks \
  --name userpool \
  --enable-cluster-autoscaler --min-count 3 --max-count 20

az aks update --name aks-shop --resource-group rg-shop-prod-uks \
  --cluster-autoscaler-profile \
    scale-down-delay-after-add=10m \
    scale-down-unneeded-time=10m \
    max-graceful-termination-sec=600 \
    skip-nodes-with-local-storage=false

It adds a node when a pod is Pending because nothing has room, and removes a node when its pods can fit elsewhere.

It reacts to pending pods, not to load. A pod that fits on an existing node never triggers a node addition, however busy that node is. This is why resource requests must be honest: understated requests mean the cluster looks like it has room when it does not.

Node autoprovisioning

az aks update --name aks-shop --resource-group rg-shop-prod-uks \
  --node-provisioning-mode Auto

Based on Karpenter. Instead of pre defining node pools and sizes, it looks at pending pods and creates nodes matching what they actually need.

A pod needing a GPU gets a GPU node. A batch of small pods gets one large node rather than several small ones, if that is cheaper. It also consolidates: when nodes are underused, workloads are packed onto fewer nodes and the rest are removed.

This is on by default in AKS Automatic, and it removes most node pool sizing decisions.

KEDA

az aks update --name aks-shop --resource-group rg-shop-prod-uks --enable-keda
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: shop-worker
  namespace: shop
spec:
  scaleTargetRef:
    name: shop-worker
  minReplicaCount: 0
  maxReplicaCount: 50
  pollingInterval: 15
  cooldownPeriod: 300
  triggers:
    - type: azure-servicebus
      metadata:
        queueName: orders
        namespace: sb-shop-prod
        messageCount: "20"
      authenticationRef:
        name: keda-servicebus-auth

KEDA scales on external signals: queue depth, event hub lag, a Prometheus query, a cron schedule. minReplicaCount: 0 means the deployment scales to zero when the queue is empty, which HPA alone cannot do.

For queue based workers this is the right tool. CPU is a poor proxy for “there is work waiting”.

Upgrades

Two separate things, and both matter.

az aks get-upgrades --name aks-shop --resource-group rg-shop-prod-uks --output table

# control plane first
az aks upgrade --name aks-shop --resource-group rg-shop-prod-uks \
  --kubernetes-version 1.32.1 --control-plane-only

# then each node pool
az aks nodepool upgrade --cluster-name aks-shop --resource-group rg-shop-prod-uks \
  --name userpool --kubernetes-version 1.32.1 --max-surge 33%

You cannot skip minor versions. Going from 1.30 to 1.32 means going through 1.31. Each hop is a separate upgrade, so falling behind makes catching up slow.

--max-surge 33% adds temporary nodes during the upgrade so capacity is maintained while nodes are drained. Without it, capacity drops during the upgrade. Higher surge is faster and costs more briefly.

Node image upgrades

az aks nodepool upgrade --cluster-name aks-shop --resource-group rg-shop-prod-uks \
  --name userpool --node-image-only

Separate from Kubernetes version upgrades, and this is where OS security patches arrive. Do it regularly, even when not changing Kubernetes version.

Automatic upgrades

az aks update --name aks-shop --resource-group rg-shop-prod-uks \
  --auto-upgrade-channel stable \
  --node-os-upgrade-channel NodeImage

az aks maintenanceconfiguration add \
  --cluster-name aks-shop --resource-group rg-shop-prod-uks \
  --name aksManagedAutoUpgradeSchedule \
  --schedule-type Weekly --day-of-week Sunday \
  --start-time 02:00 --duration 4 --utc-offset +00:00
none      you upgrade manually, and eventually you will forget
patch     patch versions within the current minor
stable    the second newest supported minor version
rapid     the newest supported minor version
node-image  node images only

stable with a maintenance window is the setting most production clusters should have. Kubernetes releases three times a year and supports each version for about a year, so manual upgrading becomes a recurring scramble.

The maintenance window means it happens when you choose, not at random.

Draining safely

kubectl drain aks-userpool-12345678-vmss000001 \
  --ignore-daemonsets --delete-emptydir-data

Upgrades drain nodes automatically. Three things make that safe:

Pod disruption budgets, so not every replica is evicted at once.

Multiple replicas, since a single replica deployment always has downtime.

Graceful shutdown, so the application handles SIGTERM and finishes in flight requests.

builder.Services.Configure<HostOptions>(options =>
{
    options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});

Kubernetes sends SIGTERM, waits for the termination grace period, then sends SIGKILL. An application that ignores SIGTERM drops requests on every upgrade and every scale in.

Long term support

az aks update --name aks-shop --resource-group rg-shop-prod-uks \
  --tier premium --k8s-support-plan AKSLongTermSupport

Premium tier extends support for a version to two years. It costs more and it is not a substitute for upgrading, since you eventually face a larger jump.

Watching it

kubectl get hpa -A
kubectl describe hpa shop-api
kubectl get nodes -o custom-columns=NAME:.metadata.name,VERSION:.status.nodeInfo.kubeletVersion
kubectl get events --field-selector reason=TriggeredScaleUp
KubeNodeInventory
| where TimeGenerated > ago(7d)
| summarize NodeCount = dcount(Computer) by bin(TimeGenerated, 1h)
| render timechart

If node count never changes, the autoscaler is not doing anything, and that usually means requests are set high enough that nothing is ever pending, or the maximum is already reached.

Quick recap

  • HPA scales pods on a percentage of the pod’s request, so without requests it does nothing.
  • The cluster autoscaler reacts to pending pods, not to load, so honest requests matter.
  • Node autoprovisioning picks node types from what pods actually need, and consolidates.
  • KEDA scales on queue depth and can reach zero, which HPA cannot.
  • You cannot skip minor versions, so falling behind makes catching up slow.
  • Use --max-surge so capacity is maintained while nodes drain.
  • Set the stable upgrade channel with a maintenance window, and handle SIGTERM properly.
deploy.sh

# THREE LEVELS, WORKING TOGETHER:
#   HPA                  more pods, from metrics
#   cluster autoscaler   more nodes, when pods cannot be scheduled
#   node autoprovisioning  the right node TYPES, chosen automatically
# HPA's averageUtilization is a PERCENTAGE OF THE POD'S REQUEST, not of the
# node. WITHOUT A REQUEST, HPA CANNOT CALCULATE ANYTHING and does nothing.
# Set the behavior block: up immediately, down over a 5 minute window, or
# the defaults flap.
az aks nodepool update --cluster-name aks-shop --resource-group rg-shop-prod-uks \
  --name userpool --enable-cluster-autoscaler --min-count 3 --max-count 20
# THE CLUSTER AUTOSCALER REACTS TO PENDING PODS, NOT TO LOAD. A pod that
# FITS never triggers a node, however busy that node is. So understated
# requests make the cluster look like it has room when it does not.
# NODE AUTOPROVISIONING (Karpenter based) creates nodes matching what
# pending pods actually need, and CONSOLIDATES underused ones. On by
# default in AKS Automatic, and it removes most sizing decisions.
az aks update --name aks-shop --resource-group rg-shop-prod-uks \
  --node-provisioning-mode Auto
# KEDA scales on QUEUE DEPTH, event hub lag, a Prometheus query or a cron,
# and can reach ZERO, which HPA cannot. CPU is a poor proxy for "there is
# work waiting".
az aks update --name aks-shop --resource-group rg-shop-prod-uks --enable-keda
# UPGRADES: CONTROL PLANE FIRST, THEN NODE POOLS. YOU CANNOT SKIP MINOR
# VERSIONS: 1.30 to 1.32 goes through 1.31, as separate upgrades. Falling
# behind makes catching up slow.
az aks upgrade --name aks-shop --resource-group rg-shop-prod-uks \
  --kubernetes-version 1.32.1 --control-plane-only
az aks nodepool upgrade --cluster-name aks-shop --resource-group rg-shop-prod-uks \
  --name userpool --kubernetes-version 1.32.1 --max-surge 33%
# --max-surge adds temporary nodes so CAPACITY IS MAINTAINED while nodes
# drain. Without it, capacity drops during the upgrade.
# NODE IMAGE UPGRADES ARE SEPARATE, and are where OS security patches
# arrive. Do them regularly even when not changing Kubernetes version.
az aks nodepool upgrade --cluster-name aks-shop --resource-group rg-shop-prod-uks \
  --name userpool --node-image-only
# Kubernetes releases three times a year, so manual upgrading becomes a
# recurring scramble. Set a channel AND a maintenance window:
az aks update --name aks-shop --resource-group rg-shop-prod-uks \
  --auto-upgrade-channel stable --node-os-upgrade-channel NodeImage
az aks maintenanceconfiguration add --cluster-name aks-shop \
  --resource-group rg-shop-prod-uks --name aksManagedAutoUpgradeSchedule \
  --schedule-type Weekly --day-of-week Sunday --start-time 02:00 --duration 4
# THREE THINGS MAKE DRAINING SAFE: pod disruption budgets, more than one
# replica, and HANDLING SIGTERM. Kubernetes sends SIGTERM, waits the grace
# period, then SIGKILL. An app that ignores it drops requests on EVERY
# upgrade and every scale in.
kubectl get hpa -A ; kubectl describe hpa shop-api
kubectl get events --field-selector reason=TriggeredScaleUp
# If node count NEVER changes, the autoscaler is doing nothing: requests
# are too high for anything to be pending, or the maximum is reached.

Was this explanation helpful?

Your feedback helps us improve the content for everyone.