Verified16 commandsAI-assisted

Compute: VM & AKS

.md

Verified 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#

Diagram

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 need

Starting, 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 --yes

Warning

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 snapshot

A 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 docs

VMSS 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 myregistry

Each 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 over kubenet for 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 page 01 describes for GitHub Actions, just with a pod's service account as the external identity instead of a GitHub workflow. This replaces the older aad-pod-identity project (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 identity AcrPull on 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 only

Same 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 5

Adding, 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 userpool

A 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 stable

az 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 stop with deallocate — see the stateDiagram note above; stop keeps 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-count instead.
  • Running az aks upgrade expecting it to touch only the control plane — it upgrades every node pool too unless you use az aks nodepool upgrade per pool.
  • Using --admin credentials 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.