Blog Post

Azure High Performance Computing (HPC) Blog
8 MIN READ

Build a Simple GPU-Enabled Kubernetes Cluster on Azure N-Series VMs

vinilv's avatar
vinilv
Icon for Microsoft rankMicrosoft
Oct 14, 2026

A hands-on kubeadm guide for an Ubuntu 24.04 control plane and an NVIDIA A100 worker

Audience: Platform engineers, ML engineers, and developers who need a small, understandable GPU development cluster without the abstraction of a managed Kubernetes service.

Reference topology:

Role

Azure VM size

Operating system

Purpose

Control plane

Standard_D8s_v6

Ubuntu 24.04 LTS

API server, scheduler, controllers, etcd

GPU worker

Standard_NC24ads_A100_v4

Ubuntu 24.04 LTS

CUDA-enabled development workloads

Software baseline: Kubernetes v1.33, containerd, Flannel CNI, NVIDIA Container Toolkit, and NVIDIA Kubernetes device plugin v0.17.0.

Why Build This Cluster?

A two-node kubeadm cluster is useful when you want direct access to Kubernetes internals while developing or debugging GPU applications. It keeps the control plane on a general-purpose VM and reserves the expensive N-series capacity for workloads that explicitly request a GPU.

Development Use Case: Shared GPU Sandbox

Imagine a small ML team validating a PyTorch service before moving it to a production platform. Developers build containers locally or in CI, deploy them to this cluster, request nvidia.com/gpu: 1, inspect CUDA behavior, tune resource requests, and test Kubernetes manifests. The same environment can exercise Jobs, model-serving Deployments, ConfigMaps, Secrets, storage mounts, and observability agents.

This design is appropriate for experimentation, integration testing, workshops, and short-lived development. It is not a production blueprint: the control plane is not highly available, etcd has no redundancy, Flannel is intentionally simple, and operational safeguards are minimal.

Architecture and Traffic Flow
  1. Control plane: runs the Kubernetes API server, scheduler, controller manager, and etcd.
  2. GPU worker: runs containerd, kubelet, the NVIDIA container runtime, and GPU workloads.
  3. Pod network: Flannel allocates pod addresses from 10.244.0.0/16.
  4. GPU discovery: the NVIDIA device plugin advertises nvidia.com/gpu to Kubernetes.
Before You Begin

Create both VMs in the same Azure virtual network and subnet. Use private IP connectivity between nodes. Confirm that the selected region has quota and capacity for Standard_NC24ads_A100_v4. GPU VM availability and quota are subscription- and region-dependent.

Use trusted administrative source ranges for SSH. Ensure the nodes can communicate on the Kubernetes control-plane, kubelet, and Flannel ports required by your network policy. Avoid exposing the Kubernetes API server broadly to the internet.

Disk requirement: Use 128GB disk as OS disk in Standard_NC24ads_A100_v4 as 32GB will be the default and it may not be sufficient to run the test job mentioned below.

1. Prepare Both Nodes

Run the following commands on both the control-plane VM and the GPU worker. Use a non-root account with sudo access.

Set Host Names and Name Resolution

Assign a unique host name on each VM:

Control plane

sudo hostnamectl set-hostname controlplane

GPU worker

sudo hostnamectl set-hostname gpunode

Add both private IP addresses to /etc/hosts on each node, replacing the placeholders:

<CONTROL_PLANE_PRIVATE_IP> controlplane
<GPU_WORKER_PRIVATE_IP> gpunode
Disable Swap

Kubelet expects swap to be disabled unless it is explicitly configured to tolerate it.

sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab
swapon --show

The final command should return no active swap devices.

Load Required Kernel Modules
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

sudo modprobe overlay
sudo modprobe br_netfilter
Configure Kubernetes Networking Parameters
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF

sudo sysctl --system
Install and Configure containerd
sudo apt-get update
sudo apt-get install -y containerd
sudo mkdir -p /etc/containerd
sudo containerd config default | sudo tee /etc/containerd/config.toml > /dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/g' /etc/containerd/config.toml
sudo systemctl restart containerd
sudo systemctl enable containerd
sudo systemctl status containerd --no-pager

The SystemdCgroup = true setting aligns containerd with kubelet and systemd cgroup management.

Install Kubernetes v1.33 Packages

The repository minor version and the packages installed on every node must match. The commands below configure the v1.33 repository used by this guide.

sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gpg conntrack socat
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.33/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.33/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl
kubeadm version
kubectl version --client

If you choose another supported Kubernetes minor release, replace v1.33 consistently on every node and follow that release’s upgrade and version-skew guidance.

2. Initialize the Control Plane

Run this section only on controlplane.

Initialize Kubernetes

Update the CIDR value as per your requirement

sudo kubeadm init --pod-network-cidr=10.xxx.0.0/16

Save the kubeadm join command printed at the end. It contains a time-limited bootstrap token and the control-plane CA hash.

Configure kubectl for the Administrator
mkdir -p "$HOME/.kube"
sudo cp -i /etc/kubernetes/admin.conf "$HOME/.kube/config"
sudo chown "$(id -u):$(id -g)" "$HOME/.kube/config"
kubectl get nodes

The control-plane node may remain NotReady until the CNI is installed.

Install Flannel
kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml
kubectl get pods -n kube-flannel -w

Because Flannel’s default manifest uses 10.244.0.0/16, it matches the pod CIDR supplied to kubeadm.

Generate a New Join Command if Needed
kubeadm token create --print-join-command

Treat the join command as a secret while it is valid. Run it only on the intended worker.

3. Configure the GPU Worker

Complete the shared preparation steps first, then perform the following work on gpunode.

Validate the NVIDIA Driver

The Azure GPU VM must have a compatible NVIDIA driver. Confirm that the host can see the accelerator:

nvidia-smi

Do not continue until this command reports the GPU and driver successfully. Driver installation varies by Azure image and extension strategy, so standardize it before automating the node build.

Install NVIDIA Container Toolkit
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /etc/apt/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | sed 's#deb https://#deb [signed-by=/etc/apt/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=containerd --nvidia-set-as-default
sudo systemctl restart containerd
sudo systemctl status containerd --no-pager
Join the Worker to the Cluster

Run the exact command generated by the control plane. It will resemble:

sudo kubeadm join <CONTROL_PLANE_PRIVATE_IP>:6443 --token <TOKEN> --discovery-token-ca-cert-hash sha256:<HASH>

Back on the control plane, watch the node registration:

kubectl get nodes -o wide -w
azureuser@controlplane:~$ kubectl get nodes
NAME           STATUS   ROLES           AGE   VERSION
controlplane   Ready    control-plane   84s   v1.33.13
azureuser@controlplane:~$ kubectl get nodes
NAME           STATUS     ROLES           AGE    VERSION
controlplane   Ready      control-plane   111s   v1.33.13
gpunode        NotReady   <none>          8s     v1.33.13
azureuser@controlplane:~$ kubectl get nodes
NAME           STATUS   ROLES           AGE    VERSION
controlplane   Ready    control-plane   112s   v1.33.13
gpunode        Ready    <none>          9s     v1.33.13
4. Advertise the GPU to Kubernetes

Run these commands on the control plane.

Install the NVIDIA Device Plugin

Pinning the manifest version makes the cluster reproducible.

curl -fsSL -o nvidia-device-plugin.yml https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.17.0/deployments/static/nvidia-device-plugin.yml
kubectl apply -f nvidia-device-plugin.yml
kubectl get daemonset -n kube-system nvidia-device-plugin-daemonset
kubectl get pods -n kube-system -l name=nvidia-device-plugin-ds -o wide
Verify GPU Capacity
kubectl describe node gpunode | grep -A 10 -E 'Capacity:|Allocatable:'
kubectl get node gpunode -o jsonpath='{.status.allocatable.nvidia\.com/gpu}{"\n"}'

The expected allocatable value depends on the VM’s visible GPU count and partitioning configuration. Kubernetes schedules GPUs as integer extended resources.

azureuser@controlplane:~$ kubectl describe node gpunode | grep -A 10 -E 'Capacity:|Allocatable:'
Capacity:
  cpu:                24
  ephemeral-storage:  128917488Ki
  hugepages-1Gi:      0
  hugepages-2Mi:      0
  memory:             226765912Ki
  nvidia.com/gpu:     1
  pods:               110
Allocatable:
  cpu:                24
  ephemeral-storage:  118810356745
  hugepages-1Gi:      0
  hugepages-2Mi:      0
  memory:             226663512Ki
  nvidia.com/gpu:     1
  pods:               110
System Info:
  Machine ID:                 ac82b1424d30473a86a3f22bb2c4ffa1
  System UUID:                67d695c2-b663-4514-9952-9e6e29671e4e
azureuser@controlplane:~$ kubectl get node gpunode -o jsonpath='{.status.allocatable.nvidia\.com/gpu}{"\n"}'
1
5. Run an End-to-End PyTorch GPU Test

Create gpu-test-job.yaml on the control plane with the following content:

apiVersion: batch/v1
kind: Job
metadata:
  name: pytorch-gpu-test
  namespace: default
spec:
  backoffLimit: 2
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: pytorch-trainer
        image: pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime
        command: ["python3", "-c"]
        args:
        - |
          import time
          import torch
          import torch.nn as nn
          import torch.optim as optim

          print("=== CUDA validation ===")
          if not torch.cuda.is_available():
              raise SystemExit("CUDA was not detected by PyTorch")

          device = torch.device("cuda:0")
          print(f"Device: {torch.cuda.get_device_name(0)}")
          print(f"Capability: {torch.cuda.get_device_capability(0)}")

          model = nn.Sequential(
              nn.Linear(1000, 2048),
              nn.ReLU(),
              nn.Linear(2048, 1000)
          ).to(device)
          optimizer = optim.SGD(model.parameters(), lr=0.01)
          criterion = nn.MSELoss()
          inputs = torch.randn(128, 1000, device=device)
          targets = torch.randn(128, 1000, device=device)

          started = time.time()
          for epoch in range(1, 101):
              optimizer.zero_grad()
              loss = criterion(model(inputs), targets)
              loss.backward()
              optimizer.step()
              if epoch % 20 == 0:
                  vram = torch.cuda.memory_allocated(0) / 1e6
                  print(f"Epoch {epoch}/100 | Loss {loss.item():.4f} | VRAM {vram:.2f} MB")

          print(f"Completed in {time.time() - started:.4f}s")
        resources:
          limits:
            nvidia.com/gpu: "1"
          requests:
            nvidia.com/gpu: "1"

Submit and observe the Job:

kubectl apply -f gpu-test-job.yaml
kubectl get pods -l job-name=pytorch-gpu-test -o wide
kubectl logs -l job-name=pytorch-gpu-test --follow
kubectl get job pytorch-gpu-test

A successful run prints the NVIDIA device name, CUDA capability, training progress, and completion time.

azureuser@controlplane:~$ kubectl get pods -l job-name=pytorch-gpu-test -o wide
NAME                     READY   STATUS    RESTARTS   AGE    IP           NODE      NOMINATED NODE   READINESS GATES
pytorch-gpu-test-j5wh8   1/1     Running   0          109s   XXXX gpunode   <none>           <none>
azureuser@controlplane:~$ kubectl logs -l job-name=pytorch-gpu-test --follow
=== CUDA validation ===
Device: NVIDIA A100 80GB PCIe
Capability: (8, 0)
Epoch 20/100 | Loss 1.0461 | VRAM 51.37 MB
Epoch 40/100 | Loss 1.0415 | VRAM 51.37 MB
Epoch 60/100 | Loss 1.0371 | VRAM 51.37 MB
Epoch 80/100 | Loss 1.0328 | VRAM 51.37 MB
Epoch 100/100 | Loss 1.0287 | VRAM 51.37 MB
Completed in 0.1250s
Conclusion

This compact cluster provides a transparent path from an Azure GPU VM to a Kubernetes-scheduled CUDA workload. It is small enough to understand, reset, and rebuild, yet realistic enough to validate container images, resource requests, device discovery, Jobs, and model-serving patterns before adopting a production-grade architecture.

Updated Oct 14, 2026
Version 2.0
No CommentsBe the first to comment