TL;DR: A production-grade blueprint for running Virtual Machines on Azure Kubernetes Service (AKS). This project demonstrates how to unify container and VM operations while using Azure Arc to enable Azure Entra ID based SSH authentication with zero manual key management.
📖 Table of Contents
- [The Problem: Operational Fragmentation]
- [What is KubeVirt?]
- [Architecture Overview]
- [The Identity Challenge: No IMDS]
- [Multi-Tenancy & Security]
- [Implementation Deep Dive]
- [Deployment Guide]
- [Technologies & Skills Demonstrated]
💼 The Problem: Operational Fragmentation
The Reality of Enterprise IT
In every enterprise, there's a constant tension between innovation and stability. While cloud-native applications built on microservices and containers represent the future, the reality is that most organizations still run critical workloads on Virtual Machines:
Legacy Databases: Oracle, SQL Server, and other enterprise databases that can't be easily containerized.
Proprietary Software: Licensed applications that require specific OS configurations or don't support containers.
Regulatory Compliance: Workloads that must run in isolated VMs for compliance reasons (PCI-DSS, HIPAA).
Lift-and-Shift Migrations: Applications moved from on-premise that aren't yet refactored.
The "Two-Stack Problem"
This leads to what I call the "Two-Stack Problem": organizations end up managing two completely separate infrastructure stacks:
| Aspect | Container Stack | VM Stack |
|---|---|---|
| Orchestration | Kubernetes | vSphere, Hyper-V, Azure VMs |
| CI/CD Pipeline | ArgoCD, Flux, Jenkins | Separate scripts, manual deployment |
| Monitoring | Prometheus, Grafana | vRealize, SCOM, Azure Monitor |
| Networking | CNI (Calico, Cilium) | NSX, Azure VNet |
| Access Control | Kubernetes RBAC | AD Groups, SSH Keys |
The Hidden Costs of Two-Stack Operations
Double the tooling costs (licenses, training, maintenance)
Context switching reduces developer productivity by up to 40%
Security gaps at the boundary between stacks
Siloed teams that don't share knowledge
Inconsistent policies across environments
The Solution: Unified Operations with KubeVirt
What if you could run your VMs on the same platform as your containers?
This is exactly what KubeVirt enables. By running VMs as Kubernetes objects, we collapse the two stacks into one:
- One Pipeline: Deploy VMs with the same GitOps workflows (ArgoCD, Flux) as your microservices.
- One Monitoring Stack: Use Prometheus and Grafana for all workloads.
- One Access Model: Kubernetes RBAC governs who can create, start, and stop VMs.
- One Team: Platform engineers manage everything.
What is KubeVirt? VMs as Kubernetes Objects
Definition
KubeVirt is a Kubernetes add-on that enables running traditional Virtual Machines alongside containers. It extends the Kubernetes API to include VM-specific resources like VirtualMachine, VirtualMachineInstance, and DataVolume.
Key Insight: KubeVirt doesn't emulate or containerize your VM. It runs a real KVM/QEMU hypervisor inside a Kubernetes Pod. The guest OS is a full, unmodified Linux or Windows installation.
How It Works: Under the Hood
graph TB
subgraph K8sNode["🖥️ Kubernetes Node (Azure VM with Nested Virt)"]
subgraph VirtLauncher["virt-launcher Pod"]
QEMU["QEMU/KVM Hypervisor"]
Libvirt["libvirtd"]
Guest["🖥️ Guest VM<br/>(Ubuntu, Windows, etc.)"]
end
subgraph System["System Components"]
Kubelet["kubelet"]
VirtHandler["virt-handler<br/>(DaemonSet)"]
CNI["CNI Plugin<br/>(Network)"]
end
end
subgraph ControlPlane["☸️ Control Plane"]
API["API Server"]
VirtController["virt-controller"]
VirtAPI["virt-api"]
end
API --> VirtController
VirtController --> VirtHandler
VirtHandler --> VirtLauncher
Kubelet --> VirtLauncher
Libvirt --> QEMU
QEMU --> Guest
CNI -.-> VirtLauncher
style Guest fill:#e3f2fd,stroke:#1976d2
style VirtLauncher fill:#e8f5e9,stroke:#388e3c
📦 Component Breakdown
| Component | Role |
|---|---|
| virt-api | Extends Kubernetes API to handle VirtualMachine resources |
| virt-controller | Manages VM lifecycle (create, start, stop, migrate) |
| virt-handler | DaemonSet on each node; interfaces with libvirt/QEMU |
| virt-launcher | Pod that hosts the actual VM; one per running VM |
| CDI (Containerized Data Importer) | Handles VM disk image imports from HTTP, S3, or registries |
VM Lifecycle in Kubernetes
A KubeVirt VM follows a familiar Kubernetes lifecycle:
apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
name: my-ubuntu-vm
namespace: student-labs
spec:
running: true # Desired state: running
template:
spec:
domain:
cpu:
cores: 2
memory:
guest: 4Gi
devices:
disks:
- name: rootdisk
disk:
bus: virtio
volumes:
- name: rootdisk
dataVolume:
name: my-ubuntu-vm-rootdisk
Key Points:
running: trueis the desired state; the controller ensures reality matches.
DataVolumeresources handle disk provisioning (like PersistentVolumeClaims for VMs).- The VM is scheduled like any other Pod, respecting taints, tolerations, and affinity rules.
🏗️ Architecture Overview {#architecture}
High-Level Design
This project implements a multi-tenant university lab platform where:
- 🎓 Faculty (Computer Science Department): Run research VMs with elevated resources.
- 📚 Students: Run lab VMs with resource quotas to prevent abuse.
- 🔧 IT Administrators: Have full control over the platform.
flowchart TB
subgraph Azure["☁️ Azure Cloud"]
subgraph RG["Resource Group: rg-uni-kubevirt"]
subgraph AKS["🚀 AKS Cluster: aks-uni-platform"]
subgraph SystemPool["System Node Pool<br/>(Standard_D2s_v3)"]
CoreDNS["CoreDNS"]
VirtOp["virt-operator"]
CDIOp["cdi-operator"]
VirtAPI["virt-api"]
end
subgraph KVPool["KubeVirt Node Pool<br/>(Standard_D4s_v3 - Nested Virt)"]
direction TB
subgraph NS1["Namespace: dept-cs"]
FacultyVM1["🎓 research-vm-1"]
FacultyVM2["🎓 research-vm-2"]
end
subgraph NS2["Namespace: student-labs"]
StudentVM1["📚 lab-vm-alice"]
StudentVM2["📚 lab-vm-bob"]
end
end
end
Storage["💾 Azure Managed Disks<br/>(Premium SSD, Retain Policy)"]
end
subgraph ImageRG["Resource Group: rg-uni-images"]
Blob["📦 Azure Blob Storage<br/>(VM Images)"]
end
subgraph Identity["🔐 Identity Services"]
Arc["Azure Arc"]
AAD["Azure AD / Entra ID"]
end
end
KVPool --> Storage
KVPool -.->|"Arc Agent"| Arc
Arc <--> AAD
CDIOp -->|"Pull Images"| Blob
style AKS fill:#e3f2fd,stroke:#1976d2
style Identity fill:#fff3e0,stroke:#f57c00
Node Pools Configuration
| Pool | VM Size | Purpose | Special Config |
|---|---|---|---|
| System | Standard_D2s_v3 | Run operators, CoreDNS | Default taint: CriticalAddonsOnly |
| KubeVirt | Standard_D4s_v3 | Run guest VMs | Taint: kubevirt.io/dedicated, Label: workload=kubevirt |
⚠️ Critical: The KubeVirt node pool must use VM sizes that support nested virtualization (Dv3, Dv4, Dv5, Ev3, Ev4, Ev5 series). Standard Bs or Ds (non-v3+) series will not work.
Storage Architecture
Storage Classes Defined:
| Class | SKU | Reclaim Policy | Use Case |
|---|---|---|---|
kv-premium-retain | Premium_LRS | Retain | Production VM disks (data preserved on VM deletion) |
kv-standard | StandardSSD_LRS | Delete | Ephemeral/test VMs |
🔐 The Identity Challenge: Solving the IMDS Gap {#identity-challenge}
Understanding the Problem
Every Azure VM has access to the Instance Metadata Service (IMDS) at the link-local address 169.254.169.254. This service provides:
Instance Identity: The VM's managed identity token.
Instance Metadata: Information about the VM (size, region, tags).
Scheduled Events: Notifications about upcoming maintenance.
Azure extensions (like the Azure AD SSH Login extension) rely on IMDS to authenticate the VM and retrieve tokens.
The KubeVirt Problem
KubeVirt VMs are nested inside an AKS node VM. When the guest VM tries to reach 169.254.169.254, the request is blocked by the network address translation (NAT) layer within the pod.
graph TB
subgraph AKS_Node ["🖥️ AKS Node VM - Standard_D4s_v3"]
direction TB
host_imds["✅ Host can reach IMDS"]
subgraph virt_launcher ["📦 virt-launcher Pod"]
direction TB
subgraph guest_vm ["🖥️ KubeVirt Guest VM - Ubuntu"]
curl["curl 169.254.169.254"]
blocked["❌ UNREACHABLE - NAT'd network"]
end
end
end
imds["🔐 IMDS 169.254.169.254"]
curl --> blocked
host_imds <--> imds
blocked -.->|"Blocked by NAT"| imds
style AKS_Node fill:#e3f2fd,stroke:#0078d4,stroke-width:2px
style virt_launcher fill:#fff3e0,stroke:#ff9800,stroke-width:1px
style guest_vm fill:#ffebee,stroke:#f44336,stroke-width:1px
style host_imds fill:#e8f5e9,stroke:#4caf50,stroke-width:1px
style blocked fill:#ffcdd2,stroke:#f44336,stroke-width:1px
style imds fill:#f3e5f5,stroke:#9c27b0,stroke-width:2px
Result: The guest VM has no Azure identity. Standard Azure extensions fail.
The Solution: Azure Arc-Enabled Servers
Azure Arc allows you to project non-Azure machines (on-premise servers, VMs in other clouds, or in our case, nested VMs) into Azure Resource Manager. This gives them:
- 🆔 Azure Resource Identity: An Azure Resource ID.
- 🔑 Managed Identity Equivalent: The ability to authenticate to Azure services.
- 🧩 Extension Support: Install Azure extensions (including AADSSHLoginForLinux).
How Arc Registration Works
sequenceDiagram
autonumber
participant TF as Terraform
participant VM as KubeVirt VM
participant Arc as Azure Arc
participant ARM as Azure Resource Manager
participant AAD as Azure AD
Note over TF: Deployment Phase
TF->>ARM: Create Service Principal (sp-arc-kubevirt)
TF->>ARM: Assign "Azure Connected Machine Onboarding" role
TF->>VM: Inject cloud-init with SP credentials
Note over VM: VM Boot Phase
VM->>VM: Wait for network (KubeVirt NAT stabilization)
VM->>VM: Download azcmagent
VM->>Arc: azcmagent connect --service-principal-id ... --service-principal-secret ...
Arc->>ARM: Create Azure Arc Machine resource
ARM-->>Arc: Resource ID assigned
Arc-->>VM: Identity certificate installed
Note over TF: Post-Registration Phase
TF->>Arc: Poll for "Connected" status
TF->>Arc: Install AADSSHLoginForLinux extension
Arc->>VM: Push extension to agent
VM->>VM: Configure SSH for AAD auth
Note over AAD: User Connection Phase
AAD->>AAD: User runs: az ssh vm --name lab-vm
AAD-->>AAD: Generate ephemeral SSH certificate
AAD->>VM: SSH connection with certificate
VM->>Arc: Validate certificate
Arc-->>VM: Access granted (RBAC checked)
VM-->>AAD: Shell session established ✅
RBAC Roles for SSH Access
Access to the VM is controlled by standard Azure RBAC roles:
| Role | Permissions | Assigned To |
|---|---|---|
| Virtual Machine Administrator Login | SSH + sudo | Faculty, IT Admins, Deployer |
| Virtual Machine User Login | SSH only (no sudo) | Students |
| Azure Connected Machine Onboarding | Register new Arc machines | Arc Registration Service Principal |
| Azure Connected Machine Resource Administrator | Manage Arc resources | Arc Registration Service Principal |
🛡️ Multi-Tenancy & Security Model {#multi-tenancy}
Namespace-Based Isolation
Multi-tenancy is implemented using Kubernetes Namespaces as the primary isolation boundary:
flowchart TB
subgraph Cluster["AKS Cluster"]
subgraph NS1["Namespace: dept-cs"]
Quota1["ResourceQuota:<br/>CPU: 16 cores<br/>Memory: 32Gi<br/>PVCs: 10"]
NP1["NetworkPolicy:<br/>Ingress: Same namespace only<br/>Egress: Internet + DNS"]
RB1["RoleBinding:<br/>cs-faculty → admin"]
end
subgraph NS2["Namespace: student-labs"]
Quota2["ResourceQuota:<br/>CPU: 8 cores<br/>Memory: 16Gi<br/>PVCs: 5"]
LR2["LimitRange:<br/>Max CPU/VM: 2<br/>Max Memory/VM: 4Gi"]
NP2["NetworkPolicy:<br/>Ingress: Same namespace only<br/>Egress: Internet + DNS"]
RB2["RoleBinding:<br/>students → view"]
end
end
style NS1 fill:#e3f2fd,stroke:#1976d2
style NS2 fill:#fff3e0,stroke:#f57c00
Security Controls Summary
| Control | Implementation | Purpose |
|---|---|---|
| ResourceQuota | Per-namespace CPU/Memory/PVC limits | Prevent resource exhaustion |
| LimitRange | Per-container/VM resource caps | Prevent single VM from consuming all quota |
| NetworkPolicy | Ingress/Egress rules | Network isolation between tenants |
| RBAC (K8s) | RoleBindings to Azure AD groups | Control who can manage VMs |
| RBAC (Azure) | VM Login roles on Resource Group | Control who can SSH into VMs |
| Node Taints | kubevirt.io/dedicated | Ensure VMs only run on dedicated nodes |
📄 Example: Student Namespace Security Configuration
# ResourceQuota: Limits total resources in namespace
apiVersion: v1
kind: ResourceQuota
metadata:
name: student-lab-quota
namespace: student-labs
spec:
hard:
requests.cpu: "8"
requests.memory: 16Gi
limits.cpu: "16"
limits.memory: 32Gi
persistentvolumeclaims: "5"
---
# LimitRange: Limits per-VM resources
apiVersion: v1
kind: LimitRange
metadata:
name: student-vm-limits
namespace: student-labs
spec:
limits:
- type: Container
max:
cpu: "2"
memory: 4Gi
default:
cpu: "1"
memory: 2Gi
🛠️ Implementation Deep Dive {#implementation}
Terraform Module Structure
The infrastructure is organized into logical Terraform files:
terraform/
├── main.tf # AKS cluster and node pools
├── providers.tf # Azure, Kubernetes, kubectl providers
├── variables.tf # Input variables with validation
├── outputs.tf # Useful outputs (connection strings, etc.)
├── identity.tf # Azure AD groups, RBAC assignments
├── arc.tf # Azure Arc SP, roles, extension installer
├── platform.tf # KubeVirt and CDI operator deployment
├── tenancy.tf # Namespace, quota, network policy per tenant
├── storage.tf # StorageClass definitions
├── networking.tf # Egress network policies for operators
├── images.tf # VM image storage (Azure Blob)
├── virtualmachines.tf # Demo VM definition
└── templates/
├── cloud-init-arc.tftpl # Cloud-init for Arc-enabled VMs
└── cloud-init-lab.tftpl # Cloud-init for basic VMs
Cloud-Init: Robust VM Bootstrap
The cloud-init script is the most critical piece for Arc registration. It must handle:
Network Delays: KubeVirt NAT takes time to stabilize after boot.
DNS Resolution: Ensure Azure endpoints are resolvable.
Agent Installation: Download and installazcmagent.
Registration Retries: Handle transient API failures.
🔧 Key Cloud-Init Script Logic
# 1. Network readiness check (with timeout)
wait_for_network() {
for i in $(seq 1 60); do
if curl -s --connect-timeout 5 https://management.azure.com > /dev/null 2>&1; then
echo "[Arc] Network ready"
return 0
fi
echo "[Arc] Waiting for network... ($i/60)"
sleep 5
done
return 1
}
# 2. Agent installation
install_arc_agent() {
curl -sSL https://aka.ms/azcmagent-download | bash
}
# 3. Registration with retry logic
register_with_arc() {
local max_retries=5
local retry_delay=30
for i in $(seq 1 $max_retries); do
if azcmagent connect \
--service-principal-id "$SP_ID" \
--service-principal-secret "$SP_SECRET" \
--tenant-id "$TENANT_ID" \
--subscription-id "$SUB_ID" \
--resource-group "$RG_NAME" \
--location "$LOCATION" \
--resource-name "$(hostname)"; then
echo "[Arc] Registration successful"
return 0
fi
echo "[Arc] Registration failed, retrying in ${retry_delay}s... ($i/$max_retries)"
sleep $retry_delay
retry_delay=$((retry_delay * 2)) # Exponential backoff
done
return 1
}
Terraform State Management Patterns
| Pattern | Implementation | Benefit |
|---|---|---|
| Trigger-based Recreation | triggers block in null_resource | Recreate VM when cloud-init changes |
| Dependency Management | Explicit depends_on chains | Ensure correct deployment order |
| Data Sources | data.azuread_client_config | Reference existing Azure AD config |
| Dynamic Blocks | for_each on operators | Deploy multiple manifests from YAML |
| Sensitive Values | sensitive = true on SP secrets | Prevent secrets in logs |
🚀 Deployment Guide {#deployment}
Prerequisites
| Tool | Version | Purpose |
|---|---|---|
| Azure CLI | 2.40+ | Azure authentication and resource management |
| Terraform | 1.3+ | Infrastructure provisioning |
| kubectl | 1.24+ | Kubernetes cluster interaction |
| Azure Subscription | Any | Must have Owner role for RBAC assignments |
Step-by-Step Deployment
# 1. Clone the repository
git clone https://github.com/ykbytes/aks-kubevirt-arc-unilab.git
cd aks-kubevirt-arc-unilab
# 2. Login to Azure
az login
az account set --subscription "Your Subscription Name"
# 3. Configure variables
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your settings
# 4. Initialize and Deploy (~15-20 minutes)
terraform init
terraform plan
terraform apply
# 5. Get AKS credentials
az aks get-credentials --resource-group rg-uni-kubevirt --name aks-uni-platform
# 6. Verify KubeVirt
kubectl get kubevirt -n kubevirt
# Expected: phase = Deployed
# 7. Verify the VM
kubectl get vm -n student-labs
# Expected: lab-vm in Running state
# 8. Connect via Azure AD SSH
az ssh vm --name lab-vm --resource-group rg-uni-kubevirt
⏱️ What to Expect During Deployment
The Arc registration phase takes approximately 5-7 minutes. Here's what the Terraform output looks like:
null_resource.arc_aad_ssh_extension[0] (local-exec): [Arc] Waiting for lab-vm to connect...
null_resource.arc_aad_ssh_extension[0] (local-exec): [Arc] Status: (attempt 1/90)
null_resource.arc_aad_ssh_extension[0]: Still creating... [00m10s elapsed]
...
null_resource.arc_aad_ssh_extension[0] (local-exec): [Arc] Status: (attempt 27/90)
null_resource.arc_aad_ssh_extension[0]: Still creating... [05m10s elapsed]
null_resource.arc_aad_ssh_extension[0] (local-exec): [Arc] Machine connected
null_resource.arc_aad_ssh_extension[0] (local-exec): [Arc] Starting extension installation (async)...
💡 Note: The empty status values (
[Arc] Status: (attempt X/90)) are normal during the first 4-5 minutes while the VM boots and runs cloud-init.
Verification Commands
# Check Arc registration
az connectedmachine show --name lab-vm --resource-group rg-uni-kubevirt \
--query "{Name:name, Status:status, AgentVersion:agentVersion}" -o table
# Check extension status
az connectedmachine extension list --machine-name lab-vm --resource-group rg-uni-kubevirt \
--query "[].{Name:name, Status:provisioningState}" -o table
# View VM console (alternative access method)
kubectl virt console lab-vm -n student-labs
Successful Azure AD SSH Connection
🎉 See a Successful Connection Example
PS> az ssh vm --name lab-vm --resource-group rg-uni-kubevirt
Port 22 is not allowed for SSH connections in this resource.
Would you like to update the current Service Configuration? (y/n): y
Finished[###############################################] 100.0000%
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-1088-kvm x86_64)
═══════════════════════════════════════════════════════════════════════════════
🎓 KubeVirt Lab VM - Azure Arc Enabled
═══════════════════════════════════════════════════════════════════════════════
🔐 Azure AD Authentication:
This VM is registered with Azure Arc. Use Azure AD to authenticate:
az ssh vm --name lab-vm --resource-group rg-uni-kubevirt
📋 Required RBAC Roles:
• Virtual Machine Administrator Login - for sudo access
• Virtual Machine User Login - for standard user access
═══════════════════════════════════════════════════════════════════════════════
[email protected]@lab-vm:~$ whoami
[email protected]
Key Observations:
- ✅ Azure AD Identity:
whoamishows Azure AD email, not a local username - ✅ No SSH Keys Required: Azure AD generates ephemeral certificates automatically
- ✅ Arc-enabled Banner: Custom MOTD confirms Azure Arc integration
💡 This is the magic of Azure Arc + AAD SSH: A KubeVirt VM nested inside AKS, with no direct Azure identity, is now accessible using your Azure AD credentials—just like a native Azure VM.
📊 Technologies & Skills Demonstrated {#technologies}
Cloud & Infrastructure
| Technology | Usage in Project |
|---|---|
| Azure Kubernetes Service (AKS) | Managed Kubernetes cluster with workload identity |
| Azure Arc | Hybrid identity for non-Azure VMs |
| Azure Blob Storage | VM image repository with SAS token access |
| Azure Managed Disks | Persistent storage for VM root disks |
| Azure RBAC | Fine-grained access control for SSH |
Kubernetes & Virtualization
| Technology | Usage in Project |
|---|---|
| KubeVirt | VM orchestration on Kubernetes |
| CDI (Containerized Data Importer) | VM disk image management |
| Kubernetes RBAC | Namespace-level access control |
| NetworkPolicies | Tenant network isolation |
| ResourceQuotas & LimitRanges | Multi-tenant resource governance |
DevOps & Automation
| Technology | Usage in Project |
|---|---|
| Terraform | Infrastructure as Code (IaC) |
| Cloud-Init | VM bootstrap automation |
| Azure CLI | Scripted Azure operations |
| Mermaid | Architecture diagrams as code |
Key Competencies Demonstrated
- ✅ Cloud Architecture: Designed a scalable, multi-tenant platform on Azure.
- ✅ Kubernetes Expertise: Deployed and configured KubeVirt, CDI, RBAC, and NetworkPolicies.
- ✅ Security Engineering: Implemented zero-trust identity with Azure Arc and AD.
- ✅ Infrastructure as Code: Wrote production-quality Terraform with proper state management.
- ✅ Automation: Created robust, self-healing deployment scripts.
- ✅ Problem Solving: Solved the IMDS identity gap with a creative Arc-based solution.
📈 Potential Extensions
| Extension | Description |
|---|---|
| GitOps Integration | Deploy VMs via ArgoCD or Flux |
| GPU Passthrough | Enable NVIDIA GPU for AI/ML VMs |
| Live Migration | Migrate VMs between nodes without downtime |
| Backup/DR | Integrate Velero for VM backup |
| Cost Management | Add Azure Cost Management tags and budgets |
👨💻 About the Author
I am a Cloud Platform Engineer with a passion for bridging the gap between legacy infrastructure and modern cloud-native operations. This project showcases my ability to:
- Design and implement complex cloud architectures
- Solve real-world identity and security challenges
- Write production-quality Infrastructure as Code
- Automate end-to-end deployment workflows