Compute: VM & AKS
.mdVerified against Azure CLI 2.87.0, flags verified via `az vm create --help`, `az aks create --help`, · official docs
What it is and where it fits 🎯#
This page covers the two compute surfaces most az users touch daily: virtual machines (Azure's IaaS
compute primitive, the equivalent of an EC2 instance or a GCE instance) and Azure Kubernetes Service — AKS
— Azure's managed Kubernetes control plane. Both share the same resource-group/subscription scoping and
identity model covered on page 01; this page assumes you're already authenticated and have a resource
group to work in.
VM lifecycle states#
stop and deallocate are not the same operation, and this is the single most common Azure-specific
billing surprise: stop powers the guest OS off but Azure still holds the underlying hardware allocation
(you're still billed for compute); deallocate releases that allocation entirely (billing for compute
stops, but a dynamically-assigned public IP can change the next time the VM starts). This is the Azure
equivalent of AWS's stop-vs-terminate distinction, except both Azure states look identical from inside the
Portal's power-state column unless you know to check closely.
Creating a VM#
az vm create \
--resource-group my-rg --name my-vm \
--image Ubuntu2204 --size Standard_DS2_v2 \
--admin-username deploy --generate-ssh-keys \
--public-ip-sku Standard --nsg my-nsg --vnet-name my-vnet --subnet my-subnet--generate-ssh-keys creates a new SSH key pair if one doesn't already exist at the default path and
reuses it if it does — convenient for one-off VMs, but for anything scripted/repeatable, pass an explicit
--ssh-key-values pointing at a managed key instead, so a fresh CI runner doesn't silently generate (and
discard) a brand-new key pair on every run.
az vm list-sizes --location eastus --output table # every VM size available in that region
az vm list-vm-resize-options --resource-group my-rg --name my-vm --output table # sizes this specific
# VM can resize into--size (SKU) availability varies by region and, for some SKUs, by specific availability zone — az vm list-sizes against the target region before scripting a create avoids a failed deployment discovering
this the hard way.
Listing and inspecting VMs#
az vm list --resource-group my-rg --output table
az vm list --show-details --output table # + public IP, FQDN, power state (slower call)
az vm show --resource-group my-rg --name my-vm
az vm get-instance-view --resource-group my-rg --name my-vm # power/provisioning state only, faster
# than a full `show` when that's all you needStarting, stopping, deallocating, and deleting VMs#
az vm start --resource-group my-rg --name my-vm
az vm stop --resource-group my-rg --name my-vm # power off, still billed for allocated compute
az vm deallocate --resource-group my-rg --name my-vm # power off AND release the compute allocation
az vm resize --resource-group my-rg --name my-vm --size Standard_DS3_v2 # must be deallocated or the
# target size unavailable on
# the current host, per Azure
az vm delete --resource-group my-rg --name my-vm --yesWarning
Deleting a VM does not delete its managed disks or NICs by default — they become orphaned, unattached
resources that keep costing money silently. Use az vm delete --yes together with a follow-up az disk list --query "[?diskState=='Unattached']" sweep, or pass --force-deletion (where the resource type
supports it) if you specifically intend to remove the disk too. A team that ran repeated VM
create/delete cycles for a load test without checking for orphaned disks is a common way to discover a
surprise bill weeks later.
Managed disks and snapshots#
az disk create --resource-group my-rg --name my-data-disk --size-gb 128 --sku Premium_LRS
az vm disk attach --resource-group my-rg --vm-name my-vm --name my-data-disk
az disk list --resource-group my-rg --output table
az disk list --query "[?diskState=='Unattached']" --output table # find orphaned, still-billed disks
az snapshot create --resource-group my-rg --name my-disk-snap --source my-data-disk
az disk create --resource-group my-rg --name restored-disk --source my-disk-snap # restore from snapshotA snapshot is a point-in-time, read-only copy of a managed disk — the standard pattern for a pre-change backup (before an OS upgrade, a risky migration) or for seeding a new disk with known-good data without touching the original.
Virtual Machine Scale Sets (VMSS)#
az vmss create \
--resource-group my-rg --name my-vmss \
--image Ubuntu2204 --instance-count 3 \
--vm-sku Standard_DS2_v2 --generate-ssh-keys \
--vnet-name my-vnet --subnet my-subnet
az vmss scale --resource-group my-rg --name my-vmss --new-capacity 5
az vmss update --resource-group my-rg --name my-vmss --set virtualMachineProfile.priority=Spot # not
# supported on every SKU/region — see docsVMSS is Azure's autoscaling VM group primitive — the equivalent of an AWS Auto Scaling Group or a GCP Managed Instance Group. Prefer AKS over a hand-rolled VMSS for anything containerized; reach for VMSS directly when the workload genuinely needs bare VMs (a licensing constraint, a legacy app that can't be containerized yet).
Creating an AKS cluster#
az aks create \
--resource-group my-rg --name my-cluster \
--node-count 3 --generate-ssh-keys \
--enable-managed-identity \
--network-plugin azure --network-policy azure \
--enable-oidc-issuer --enable-workload-identity \
--tier standard --zones 1 2 3 \
--attach-acr myregistryEach flag above earns its place in a production cluster:
--enable-managed-identity— the cluster manages its own Azure resources (load balancers, disks) using a system-assigned managed identity instead of an older, harder-to-rotate service principal. This is the current default and recommended path.--network-plugin azure(vs.kubenet) — assigns pods real, routable VNet IPs, which is required for several features (Azure Network Policy, some private-cluster configurations) and is Microsoft's current general recommendation overkubenetfor anything beyond a small test cluster.--enable-oidc-issuer+--enable-workload-identity— together, these let Kubernetes pods authenticate to Azure using AKS workload identity federation: a pod's Kubernetes service account token is federated to an Entra ID app registration, exactly the same mechanism page01describes for GitHub Actions, just with a pod's service account as the external identity instead of a GitHub workflow. This replaces the olderaad-pod-identityproject (deprecated) as the recommended way for pods to get Azure credentials without a stored secret.--tier standard— enables a financially-backed SLA on the control plane; the free tier has no SLA and is not recommended for production.--zones 1 2 3— spreads nodes across availability zones for zonal-failure resilience.--attach-acr— grants the cluster's identityAcrPullon the named Azure Container Registry in one step, instead of a separate manual role assignment.
Getting kubectl credentials#
az aks get-credentials --resource-group my-rg --name my-cluster
az aks get-credentials --resource-group my-rg --name my-cluster --admin # bypass Azure RBAC/AAD auth,
# local admin credentials —
# break-glass onlySame role as aws eks update-kubeconfig / gcloud container clusters get-credentials — merges a context
into your local kubeconfig so kubectl can talk to the cluster; it does not itself create or change
anything cluster-side. --admin fetches the cluster's local admin credentials, bypassing whatever Entra
ID/Azure RBAC authorization is configured — reserve it for break-glass access, not routine use, since it
defeats the point of having Azure RBAC on the cluster at all.
Listing clusters and node pools#
az aks list --resource-group my-rg --output table
az aks nodepool list --resource-group my-rg --cluster-name my-cluster --output table
az aks show --resource-group my-rg --name my-cluster --query "kubernetesVersion"Scaling a node pool#
az aks scale --resource-group my-rg --name my-cluster --node-count 5
az aks scale --resource-group my-rg --name my-cluster --nodepool-name userpool --node-count 5Adding, updating, and removing node pools#
az aks nodepool add \
--resource-group my-rg --cluster-name my-cluster \
--name userpool --node-count 3 --node-vm-size Standard_DS2_v2 \
--mode User --node-taints "workload=batch:NoSchedule" --zones 1 2 3
az aks nodepool add \
--resource-group my-rg --cluster-name my-cluster \
--name spotpool --priority Spot --eviction-policy Delete \
--node-vm-size Standard_DS2_v2 --node-count 2 \
--node-taints "kubernetes.azure.com/scalesetpriority=spot:NoSchedule"
az aks nodepool show --resource-group my-rg --cluster-name my-cluster --name userpool
az aks nodepool delete --resource-group my-rg --cluster-name my-cluster --name userpoolA cluster always keeps at least one system node pool (--mode System, the default for the pool created
by az aks create) for core cluster components like CoreDNS and metrics-server — application workloads
belong on a separate user node pool (--mode User) so it can be scaled, upgraded, or deleted
independently without touching system components. --node-taints on a system pool
(CriticalAddonsOnly=true:NoSchedule is the standard convention) plus a matching toleration on application
pods keeps workloads off the system pool entirely — see the scenario below.
Enabling and configuring the cluster autoscaler on a node pool#
az aks nodepool update \
--resource-group my-rg --cluster-name my-cluster --name userpool \
--enable-cluster-autoscaler --min-count 2 --max-count 8
az aks nodepool update \
--resource-group my-rg --cluster-name my-cluster --name userpool \
--disable-cluster-autoscaler--min-count/--max-count only take effect with --enable-cluster-autoscaler — the fixed --node-count
from az aks scale is ignored once autoscaling is on, since the autoscaler owns the node count from that
point on. A manual az aks scale against an autoscaled pool can be immediately reverted by the autoscaler
on its next evaluation unless you also adjust --min-count/--max-count.
Upgrading clusters and node pools#
az aks get-upgrades --resource-group my-rg --name my-cluster --output table # available Kubernetes
# versions for this cluster
az aks upgrade --resource-group my-rg --name my-cluster --kubernetes-version 1.31.1 # control plane +
# all node pools
az aks nodepool upgrade --resource-group my-rg --cluster-name my-cluster \
--name userpool --kubernetes-version 1.31.1 # one node pool only
az aks update --resource-group my-rg --name my-cluster --auto-upgrade-channel stableaz aks upgrade without a targeted node pool upgrades the control plane and every node pool by default —
for a cluster where you want to validate the new control-plane version against production traffic before
touching worker nodes, upgrade node pools individually with az aks nodepool upgrade instead of the
all-in-one command. --auto-upgrade-channel (patch, stable, rapid, node-image) hands ongoing
version management to Azure on a schedule — stable is the common production default, avoiding both
falling behind on security patches and being first onto a brand-new minor version.
Real-world scenario: isolating system pods from application workloads#
A team's application pods were repeatedly getting scheduled onto the system node pool, competing with CoreDNS for CPU during traffic spikes and causing intermittent DNS resolution failures cluster-wide.
az aks nodepool update --resource-group my-rg --cluster-name my-cluster \
--name nodepool1 --node-taints "CriticalAddonsOnly=true:NoSchedule"Paired with a toleration only on the handful of pods that legitimately need to run on the system pool (or none — most application workloads need no toleration at all and will simply schedule onto the untainted user pool). This is the standard AKS pattern: taint the system pool, leave user pools untainted, let the scheduler naturally keep application workloads off critical infrastructure.
Real-world scenario: a cost-optimized batch node pool with Spot VMs#
A nightly batch job tolerates interruption but runs at real scale — a dedicated Spot-priced node pool with a matching taint keeps it off normal on-demand nodes and off the on-demand billing rate:
az aks nodepool add \
--resource-group my-rg --cluster-name my-cluster \
--name batchspot --priority Spot --eviction-policy Delete \
--node-vm-size Standard_D4s_v5 --enable-cluster-autoscaler --min-count 0 --max-count 20 \
--node-taints "kubernetes.azure.com/scalesetpriority=spot:NoSchedule"--min-count 0 matters here — a Spot pool that can scale to zero costs nothing when the nightly job isn't
running, which is the point of using Spot at all. --eviction-policy Delete (vs. Deallocate) means an
evicted Spot node is removed rather than stopped-but-billed-for-disk, appropriate for genuinely stateless
batch workers.
CI/CD integration recipe: GitHub Actions deploy to AKS#
# .github/workflows/deploy-aks.yml
name: Deploy to AKS
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- run: az aks get-credentials --resource-group my-rg --name my-cluster --overwrite-existing
- run: kubectl apply -f k8s/
- run: kubectl rollout status deployment/my-app --timeout=180s--overwrite-existing matters on a shared CI runner image — without it, get-credentials fails instead of
replacing a stale kubeconfig entry left over from a previous job.
Common pitfalls#
- Confusing
stopwithdeallocate— see the stateDiagram note above;stopkeeps billing compute. - Deleting a VM and assuming its disks went with it — they don't by default; see the WARNING above.
- Scaling an autoscaled node pool with
az aks scale— the autoscaler can immediately revert it; adjust--min-count/--max-countinstead. - Running
az aks upgradeexpecting it to touch only the control plane — it upgrades every node pool too unless you useaz aks nodepool upgradeper pool. - Using
--admincredentials for routine kubectl access — it bypasses Azure RBAC entirely; reserve it for break-glass scenarios.
Exit codes#
0 success · non-zero on any API/validation error, including a node pool operation that times out waiting
for provisioning — add --no-wait on long-running create/scale/upgrade operations if the script shouldn't
block, then poll with az aks show --query provisioningState separately.
When to reach for something else#
Use kubectl/Helm/GitOps tooling (Argo CD, Flux) for anything happening inside the cluster once it
exists — az aks commands provision and manage the cluster and node pool shape, not workloads running on
it. For declarative, reviewable cluster provisioning across environments, prefer Bicep/Terraform's
azurerm_kubernetes_cluster over a growing shell script of az aks calls, the same tradeoff described on
page 01 for general resource provisioning.