You will provision a managed AKS cluster, deploy a workload, and enable both cluster and horizontal pod autoscaling. By the end you will run Kubernetes in the cloud without operating the control plane yourself.

AKS is Microsoft's managed Kubernetes offering: Azure runs the API server, etcd, scheduler, and controller manager for you, and you only pay for and manage the worker nodes. That division of responsibility is the whole point of this lab, you get real, upstream-compatible Kubernetes (the same kubectl, the same YAML, the same primitives you would use on any cluster) without ever SSHing into a control-plane node, patching etcd, or worrying about API-server certificates.

The workload itself is deliberately simple (an nginx deployment behind a public load balancer) so the interesting machinery stays in focus: the Horizontal Pod Autoscaler (HPA), which adds pod replicas when CPU rises, and the cluster autoscaler, which adds whole VMs to the node pool when pods no longer fit. Watching those two layers react to generated load is where you actually learn how cloud-native capacity management works. Every command below is real and runnable, follow it end to end and you will have a working, self-scaling service.

Learning Objectives

  • Create an AKS cluster with the az CLI.
  • Deploy a workload and expose it via a load balancer.
  • Enable cluster autoscaler and a Horizontal Pod Autoscaler.
  • Generate load, watch both autoscalers react, and verify scale-down after the load stops.
  • Clean up every resource so the lab leaves no running charges.
  • Time: ~4 hours · Difficulty: Advanced · Prereqs: an Azure subscription and kubectl.

Architecture Overview

Reading the diagram top to bottom: the az CLI talks to the Azure Resource Manager API, which stamps out the cluster. The control plane (API server, etcd, scheduler, controllers) lives in an Azure-managed subscription you cannot see, you interact with it only through the Kubernetes API. Your node pool is a Virtual Machine Scale Set in your subscription; the cluster autoscaler watches for unschedulable pods and grows or shrinks that scale set between the min and max counts you set.

Traffic arrives through an Azure Load Balancer, which AKS provisions automatically the moment you create a Service of type: LoadBalancer, the Kubernetes cloud-controller-manager translates that Service object into real Azure networking (a public IP, a load-balancer rule, health probes). The HPA is pure Kubernetes: it polls the metrics-server every 15 seconds and edits the replica count on your Deployment. Two feedback loops, two different layers, pods first, nodes only when pods run out of room.

Environment Setup

Prerequisites

  • Azure CLI, kubectl, and a subscription with enough vCPU quota for 3 or more nodes.
  • Docker Hub access for the sample image; about an hour of cluster runtime (watch the meter).
  • Assumed skills: Kubernetes basics (deployments, services) and comfort with az aks commands.

You will need: the az CLI authenticated and kubectl.

Before you begin: AKS incurs cost; delete the resource group when finished.

Everything in this lab is driven from a shell. On macOS the fastest install path is Homebrew; on Windows use winget install Microsoft.AzureCLI, and on Debian/Ubuntu use Microsoft's apt repository. You also need kubectl, the az CLI can install it for you in one shot.

INSTALL CLI TOOLS (MACOS)

brew install azure-cli
az aks install-cli        # installs kubectl (and kubelogin if needed)
az version | head -3
kubectl version --client
$ az version | head -3
{ "azure-cli": "2.65.0", "azure-cli-core": "2.65.0",

Next, sign in and make sure you are pointed at the subscription you intend to bill. az login opens a browser for device authentication; if you have several subscriptions, always confirm which one is active before creating anything.

az login && az account show --query "{name:name, id:id, state:state}" -o table
Confirms the active subscription so nothing lands in the wrong tenant.

AKS resources live inside a resource group, a lifecycle boundary that also becomes your one-command cleanup at the end. Create it in a region close to you that supports AKS (East US is a safe default):

az group create --name rg --location eastus
Short name "rg" keeps the later commands readable; use any unique name you like.
$ az group show -n rg --query properties.provisioningState -o tsv
Succeeded

Common mistake: creating the group in one region and the cluster in another, or working in the wrong subscription. Verify with az account show and keep region names consistent (eastus, not east-us). Also confirm your subscription has quota for at least a handful of vCPUs in that region, see the Troubleshooting section if cluster creation fails on quota.

Step-by-Step Execution

01
Create the cluster with autoscaling nodes

This is the only heavy Azure operation in the lab. One az aks create call provisions the managed control plane and a system node pool backed by a Virtual Machine Scale Set. The flags matter: --enable-cluster-autoscaler turns on node-level autoscaling from day one (it is painful to retrofit), --min-count 2 keeps two nodes for redundancy, and --max-count 5 caps your spend. --generate-ssh-keys creates a keypair installed on the nodes for debugging (for example with az aks node-debug). It does not make the nodes publicly reachable: AKS nodes get no public IPs by default, so the keys alone do not give you SSH access from the internet. Expect provisioning to take roughly 5 to 10 minutes.

az aks create -g rg -n tyf-aks --node-count 2 --enable-cluster-autoscaler --min-count 2 --max-count 5 --generate-ssh-keys
Provisions a managed cluster with an autoscaling node pool.

Verify the cluster finished provisioning and note the Kubernetes version Azure picked (the 1.31.7 below is illustrative; Azure selects the current default at creation time):

$ az aks show -g rg -n tyf-aks --query "{state:provisioningState, k8s:kubernetesVersion}" -o table
State K8s --------- ------- Succeeded 1.31.7

Common mistake: re-running az aks create after a failure without reading the error, if the error mentions quota (e.g. Operation could not be completed as it results in exceeding approved Total Regional Cores quota), no retry will fix it; switch VM size or region. Also note kubectl's long flags need two dashes (--node-count, not -node-count), a single dash is parsed as bundled short flags and fails.

Expected output:

$ az aks show -g rg -n tyf-aks --query "{state:provisioningState, k8s:kubernetesVersion}" -o table
State      K8s
---------  -------
Succeeded  1.31.7

What this teaches: Autoscaling node pools let the cluster grow with demand; the min/max bounds are the budget guardrail.

02
Get credentials and deploy

az aks get-credentials fetches the cluster's kubeconfig from Azure and merges it into ~/.kube/config, setting AKS as your current context. Without this step kubectl has no idea where your API server is, every command would target localhost:8080 and fail with a connection-refused error. The second half of the command creates a Deployment: one declarative object that keeps a replica set of nginx pods alive.

az aks get-credentials -g rg -n tyf-aks && kubectl create deployment web --image=nginx:stable
nginx:stable is a floating tag: it always points at the latest stable release, so two runs months apart deploy different nginx builds. Fine for this lab; pin a digest (nginx:stable@sha256:...) when you need byte-identical rollouts.
$ az aks get-credentials -g rg -n tyf-aks
Merged "tyf-aks" as current context in /Users/taki/.kube/config
$ kubectl create deployment web --image=nginx:stable
deployment.apps/web created

Watch the pod come up, it should pass through ContainerCreating to Running within a minute or two (the first image pull takes the longest):

$ kubectl get pods -l app=web -w
NAME READY STATUS RESTARTS AGE web-6f8b9d7c4f-x2kqp 0/1 ContainerCreating 0 4s web-6f8b9d7c4f-x2kqp 1/1 Running 0 18s

Common mistake: running kubectl against the wrong context after working with other clusters (minikube, Docker Desktop). Check kubectl config current-context, it must say tyf-aks. If you rebuilt the cluster, re-run az aks get-credentials --overwrite-existing so stale certificates do not linger.

Expected output:

Merged "tyf-aks" as current context in /home/taki/.kube/config

What this teaches: Merging kubeconfig is what connects kubectl to the managed control plane; verify the context before deploying.

03
Expose and add an HPA

Two things happen here. kubectl expose ... --type=LoadBalancer creates a Service and, because this is AKS, the cloud-controller-manager immediately provisions a real Azure Load Balancer with a public IP in front of your pods. kubectl autoscale then creates an HPA that targets 60% average CPU utilization across pods, bounded between 2 and 10 replicas.

$ kubectl expose deploy web --type=LoadBalancer --port=80 && kubectl autoscale deploy web --cpu-percent=60 --min=2 --max=10
service/web exposed horizontalpodautoscaler.autoscaling/web autoscaled

One critical detail most quickstarts skip: CPU-based HPA utilization is computed as a percentage of the pod's CPU request, and a Deployment created imperatively has no requests set. Without them the HPA cannot compute a percentage and reports <unknown> forever. Set requests (and a limit for good hygiene) explicitly:

SET CPU REQUESTS SO THE HPA CAN MEASURE

kubectl set resources deployment web \
  --requests=cpu=250m --limits=cpu=500m

Expected output:

$ kubectl get hpa web
NAME   REFERENCE        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
web    Deployment/web   3%/60%    2         10        2          2m
$ kubectl get hpa web
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE web Deployment/web 3%/60% 2 10 2 2m

How to verify: TARGETS must show current/target as real percentages (e.g. 3%/60%). If it shows <unknown>/60% for more than two or three minutes, either the resource requests are missing (fix above) or the metrics-server is not answering, see Troubleshooting. Note the HPA already bumped the Deployment to 2 replicas, its floor.

What this teaches: The HPA needs resource requests to compute utilization; without the denominator, autoscaling cannot work.

04
Wait for the public IP and test the app end to end

Azure takes a minute or two to allocate the public IP and program the load-balancer rules. Until then the Service shows <pending>. Watch the Service object until EXTERNAL-IP appears, then curl it.

$ kubectl get svc web -w
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE web LoadBalancer 10.0.123.45 <pending> 80:31415/TCP 20s web LoadBalancer 10.0.123.45 20.62.140.77 80:31415/TCP 95s
All IPs above are illustrative. Your CLUSTER-IP comes from your cluster CIDR and your EXTERNAL-IP from your region's public pool.
$ curl -s http://20.62.140.77 | grep -i '<title>'
<title>Welcome to nginx!</title>

That title from the default nginx page, served from a pod on an AKS node through an Azure Load Balancer, is your end-to-end proof: deployment, service, cloud networking, and kube-proxy routing all work. The CLUSTER-IP is internal-only; the EXTERNAL-IP is the one the world can reach.

Common mistake: curling the ClusterIP from your laptop, it is only routable inside the cluster's virtual network. Always use the EXTERNAL-IP. If it stays pending beyond five minutes, run kubectl describe svc web and read the Events section at the bottom.

Expected output:

$ kubectl get svc web -o jsonpath='{.status.loadBalancer.ingress[0].ip}'
20.81.44.12

What this teaches: A public IP on the service is the moment the app is truly reachable; test it from outside the cluster.

05
Generate load and watch the HPA scale out

Now the fun part: make the HPA react. Run a throwaway busybox pod that hammers the Service in a loop, because it resolves the Service name web over cluster DNS, the load spreads across all replicas through the internal ClusterIP. The nginx pods' CPU climbs, the metrics-server reports it, and the HPA starts adding replicas to bring average utilization back under 60%.

LOAD GENERATOR (LEAVE RUNNING IN THIS TERMINAL)

kubectl run -i --tty loadgen --rm --restart=Never \
  --image=busybox:1.36 -- /bin/sh -c \
  "for i in 1 2 3 4; do while true; do wget -q -O- http://web > /dev/null; done & done; wait"
One single-threaded wget loop rarely generates enough CPU to move the HPA: nginx serves its tiny welcome page cheaply. Four parallel loops in one pod usually do it. If TARGETS still sits near idle after a few minutes, run a second loadgen pod in another terminal rather than assuming the HPA is broken.

Expected output:

In a second terminal, watch the HPA. Within a couple of minutes you should see current CPU blow past the target and the replica count climb:

$ kubectl get hpa web -w
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE web Deployment/web 3%/60% 2 10 2 8m web Deployment/web 145%/60% 2 10 2 9m web Deployment/web 145%/60% 2 10 5 10m web Deployment/web 62%/60% 2 10 5 11m web Deployment/web 48%/60% 2 10 5 12m

The math is simple: at 145% of the 60% target the HPA computes ceil(2 × 145/60) = 5 replicas and patches the Deployment. New pods schedule onto the nodes, pull the image, and start absorbing traffic, average utilization falls back toward the target. The feedback loop is closed.

Common mistake: giving up after 30 seconds. The HPA polls metrics every ~15 seconds and has a stabilization window; allow 2 to 3 minutes for the first scale-up and 5+ minutes for scale-down after you stop the load (Ctrl+C the loadgen pod to stop it, scale-down is deliberately slower to avoid flapping).

What this teaches: Load generation is how you prove the HPA reacts; watch the replica count climb with the metric.

06
Watch the cluster autoscaler add nodes

Pod-level scaling only works while the nodes have room. Each web pod requests 250m CPU. On the default 2-vCPU node size, 10 replicas request 2500m against roughly 3800m allocatable across two nodes, so as the HPA pushes toward 8 to 10 replicas the nodes fill up and new pods sit Pending with Insufficient cpu. If you created the cluster with a larger VM size, 10 small pods may still fit: raise the HPA --max or add load until pods genuinely do not fit, the autoscaler only reacts to pods that cannot schedule. That is exactly the signal the cluster autoscaler listens for: it grows the VM Scale Set from 2 toward the 5-node ceiling so the pending pods can schedule.

The web pods alone may or may not exhaust the nodes, depending on the VM size you picked. To make the Pending state deterministic, add a dedicated pressure workload: twenty pods each requesting 500m CPU (10 CPU total). Every single pod fits on any node size, but twenty of them cannot fit on two nodes of any reasonable size, so some are guaranteed to sit Pending and the autoscaler must react. Delete it after the demo so it does not fight the HPA for room:

GUARANTEE PENDING PODS WITH A PRESSURE WORKLOAD

kubectl create deployment pressure --image=nginx:stable --replicas=20
kubectl set resources deployment pressure --requests=cpu=500m
kubectl get pods -l app=pressure | grep -c Pending   # expect a nonzero count within a minute
# after you see the new node join: kubectl delete deployment pressure
$ kubectl get pods -l app=web | head -4
NAME READY STATUS RESTARTS AGE web-6f8b9d7c4f-x2kqp 1/1 Running 0 25m web-6f8b9d7c4f-7hj2m 1/1 Running 0 6m web-6f8b9d7c4f-9qzv4 0/1 Pending 0 3m
$ kubectl get nodes -w
NAME STATUS ROLES AGE VERSION aks-nodepool1-28531641-vmss000000 Ready <none> 40m v1.31.7 aks-nodepool1-28531641-vmss000001 Ready <none> 40m v1.31.7 aks-nodepool1-28531641-vmss000002 Ready <none> 45s v1.31.7

The third node appearing with a tiny AGE is the autoscaler doing its job, from Pending pod to Ready node typically takes 3 to 5 minutes (VM provisioning plus kubelet registration). You can corroborate it from the Azure side with az aks show -g rg -n tyf-aks --query properties.agentPoolProfiles[0].count, or watch autoscaler decisions in the cluster events.

How to verify: after the new node is Ready, the Pending pod flips to Running within seconds, kubectl get pods -l app=web -o wide shows which node each pod landed on. If pods stay Pending and no node appears, check whether you already hit --max-count 5; the autoscaler cannot exceed it.

Expected output:

$ kubectl get nodes
NAME                                  STATUS   ROLES
aks-nodepool1-12345678-vmss000000      Ready    agent
aks-nodepool1-12345678-vmss000001      Ready    agent
aks-nodepool1-12345678-vmss000002      Ready    agent

What this teaches: The cluster autoscaler reacts to pending pods, not to CPU directly; unschedulable pods are its trigger.

Step Flow Map

Each box is one step in the lab; work them in order, since later steps build on earlier state.

Progress So Far

How It Works

AKS is Kubernetes with the control plane managed for you: Microsoft runs the API server, etcd, scheduler, and controller manager, and on the default Free tier you pay only for the agent nodes (the Standard tier adds an hourly control-plane charge). Creating the cluster provisions a node pool of VMs joined to that control plane; enabling the cluster autoscaler lets AKS add or remove nodes as pending pods demand it, within the min/max bounds you set.

The Horizontal Pod Autoscaler is a control loop, not magic. Every 15 seconds it queries the metrics pipeline (metrics-server) for the average CPU or custom metric across a deployment's pods and computes desired replicas from the ratio of current to target utilization. If the metrics pipeline is missing or the pods have no resource requests, the HPA reports unknown and does nothing; requests are the denominator the whole calculation rests on.

Two autoscalers at two layers can fight if you are careless: the HPA scales pods, the cluster autoscaler scales nodes. The standard pattern is to let the HPA drive pod count and let pending pods trigger node scale-out, with PodDisruptionBudgets protecting availability during scale-in. Cleanup matters here more than in any other lab: AKS, load balancers, and public IPs all bill by the hour.

Testing & Validation

Before calling the lab done, run the full verification sweep. You are checking four layers: the app answers on its public IP, the HPA reports real metrics, the node pool is healthy, and the autoscalers are within their configured bounds.

kubectl get svc web && kubectl get hpa web
$ kubectl get deploy web && kubectl get nodes
NAME READY UP-TO-DATE AVAILABLE AGE web 2/2 2 2 30m NAME STATUS ROLES AGE VERSION aks-nodepool1-28531641-vmss000000 Ready <none> 45m v1.31.7 aks-nodepool1-28531641-vmss000001 Ready <none> 45m v1.31.7

Checklist: the Deployment shows 2/2 ready (the HPA floor), all nodes are Ready, the Service has an EXTERNAL-IP that answers curl, and the HPA shows a real current/target percentage rather than <unknown>. Under the load test you should additionally observe replicas rising toward 10 and nodes rising toward 5, and after stopping the load, both drifting back down over the following 10 to 15 minutes.

You should get an external IP and an HPA reporting current vs target CPU. If reachable and scaling, your AKS deployment works. Remember to az group delete -n rg to stop charges.

CLEAN UP EVERYTHING

Cloud labs are only cheap if you tear them down. Because every resource from this lab (cluster, node VMs, disks, load balancer, public IP) lives in the one resource group you created, a single delete removes all of it. The --no-wait flag returns immediately; deletion itself takes several minutes in the background.

az group delete -n rg --yes --no-wait
Deletes the cluster, nodes, load balancer, and public IP in one shot.
$ while [ "$(az group exists -n rg -o tsv)" = true ]; do echo "still deleting..."; sleep 20; done; echo "group gone"
still deleting... still deleting... group gone

Common mistake: assuming the job is done when the delete command returns. AKS creates a second, auto-generated resource group (named like MC_rg_tyf-aks_eastus) holding the VMs, disks, and load balancer. Deleting the parent group deletes the cluster resource, and the AKS resource provider then removes that MC_ node group as part of cluster deletion, but a failed delete can orphan it. Poll with the loop above until the group is gone, then check the portal resource-group list for a leftover MC_ group and delete it manually if one remains. A glance at Cost Management confirms nothing is still billing.

Expected output:

$ az group delete -n rg --yes --no-wait
$ az group show -n rg
(ResourceGroupNotFound) Resource group 'rg' could not be found.

Cleanup is part of the lab; managed Kubernetes bills by the hour even when idle.

Troubleshooting

Quick-reference for the failures you are most likely to hit in this lab.

SymptomLikely causeFix
Cluster creation failsvCPU quota exhausted in the regionRequest a quota increase, or pick a smaller VM size and fewer initial nodes.
kubectl cannot connectKubeconfig points at the wrong cluster or is staleRe-run az aks get-credentials --overwrite-existing and verify the context.
HPA shows unknown targetsMetrics-server missing, or pods lack CPU requestsConfirm the metrics addon and set resources.requests.cpu on the deployment.
Service never gets a public IPLoad balancer provisioning is slow or the quota is hitCheck kubectl get events and the Azure activity log; most cases resolve within minutes.
Cluster autoscaler never adds nodesMin/max bounds too tight, or pods are unschedulable for other reasonsDescribe the pending pod; the autoscaler only reacts to pods it could place on a new node.
Costs keep accruing after the labThe resource group was not deletedDelete the whole resource group; AKS-adjacent resources (load balancer, public IP, disks) hide outside the cluster view.
Troubleshooting
  • External IP pending: the Azure Load Balancer takes a minute to provision; keep watching.
  • HPA shows unknown: install the metrics-server (built in on AKS, may need a moment).
  • Quota errors: the region/subscription may cap node SKUs; pick a smaller VM size.
  • External IP stuck at <pending> beyond ~5 minutes: run kubectl describe svc web and read the Events block. The usual culprits are a failed public-IP allocation (check the Azure activity log for the auto-created MC_rg_tyf-aks_eastus group) or a standard-SKU LB policy restriction. Recreating the Service (kubectl delete svc web then expose again) forces a fresh allocation.
  • HPA TARGETS shows <unknown>/60% indefinitely: first confirm the pods have CPU requests, kubectl get deploy web -o jsonpath='{.spec.template.spec.containers[0].resources}' must show a requests.cpu value (set it with kubectl set resources as in step 03). Then confirm the metrics pipeline works at all: kubectl top nodes should return numbers; if it errors, check the metrics-server pods with kubectl get pods -n kube-system -l k8s-app=metrics-server.
  • kubectl fails with "connection refused" to localhost:8080: no cluster credentials are loaded, either az aks get-credentials was never run, or your current context points elsewhere. Fix with az aks get-credentials -g rg -n tyf-aks --overwrite-existing and confirm via kubectl config current-context.
  • Pods Pending with "Insufficient cpu" that never schedule: check whether the node pool is already at --max-count (az aks show -g rg -n tyf-aks --query properties.agentPoolProfiles[0].count). If it is below the max, give the autoscaler 3 to 5 minutes and inspect its decisions: kubectl get events -A --field-selector reason=TriggeredScaleUp. Persistent failures usually trace back to regional vCPU quota, run az vm list-usage --location eastus -o table to see headroom.
  • Cluster creation fails immediately with a quota or SKU error: free trial and student subscriptions often cap regional cores at 4 to 10. Either request a quota increase in the portal (takes minutes to hours) or shrink the lab: add --node-vm-size Standard_B2s and drop --max-count to 3.

Extension Ideas

  • Add Ingress + TLS as in Kubernetes Networking.
  • Provision the cluster with Terraform (see IaC).
  • Audit posture with CSPM.
  • Replace the imperative commands with declarative manifests (kubectl apply -f) and wire them into a GitHub Actions pipeline for a basic GitOps flow.
  • Swap the CPU-based HPA for KEDA and scale on queue depth or HTTP request rate instead.

Key Results

  • Ran managed Kubernetes without operating the control plane.
  • Enabled cluster autoscaling from 2 to 5 nodes.
  • Added an HPA scaling pods from 2 to 10 on CPU.
  • Exposed the app via an Azure Load Balancer.

Key Takeaways

  • AKS splits responsibility: Azure runs the control plane (free on the default Free tier; the Standard tier bills per hour); you own, and pay for, the node pool VMs.
  • az aks get-credentials is the bridge between Azure and kubectl; without it kubectl has no cluster to talk to.
  • A Service of type: LoadBalancer on AKS is not just a Kubernetes object, it provisions a real Azure Load Balancer and public IP behind the scenes.
  • A CPU-based HPA is useless without resource requests on the pods; utilization is a percentage of requested CPU, so set --requests=cpu before expecting it to scale.
  • Two autoscaling layers cooperate: the HPA adds pods within node capacity, and the cluster autoscaler adds nodes when pods go Pending, pods scale in seconds, nodes in minutes.
  • Cleanup is part of the lab: deleting the resource group removes the cluster, VMs, disks, and load balancer together, which is the only reliable way to stop all charges.

Practice Challenges

Challenge 1: Add a spot node pool

Goal: Attach a second node pool with spot VMs, taint it, and schedule only tolerant workloads there.

Hint: Taints repel, tolerations attract; the combination is how you dedicate cheap capacity.

Challenge 2: Protect availability during scale-in

Goal: Add a PodDisruptionBudget and drain a node, observing that the budget is respected.

Hint: Use kubectl drain for the graceful path and watch the evictions.

Challenge 3: Query container logs centrally

Goal: Enable Container Insights and write a KQL query over container logs for the demo app.

Hint: The ContainerLog table is where stdout and stderr land.