~/2026/09/16/hyper-v-to-talos-rethinking-the-virtualization-stack.md

Hyper-V to Talos: Rethinking the Virtualization Stack

---
author: 
date: 
updated: 
read: 5 min
in:   [engineering, strategy]
tags: [hyper-v, talos, kubernetes]
---

$ grep -n '^#' post.md

Moving a fleet of Hyper-V virtual machines onto a Talos Kubernetes cluster sounds, on paper, like a hypervisor swap: fewer VMs, more containers, same general shape of infrastructure. In practice it changed how I think about the whole stack, because the unit of work stopped being "a machine running an OS running an application" and became "an application, full stop," with everything underneath it treated as disposable. That's a bigger shift than it sounds, and it took longer to internalize than the migration itself.

This is the strategic half of a pair. Kubernetes: Running Talos Alongside a Legacy Hyper-V Estate covers the mechanics of running Talos nodes as Hyper-V guests; this post is about deciding what moves and what doesn't.

The VM was never really the point

A Hyper-V VM running a line-of-business application carries a lot of incidental complexity that has nothing to do with the application: a Windows Server license, a patch cadence, a local firewall configuration, a backup agent, sometimes local storage that needs its own maintenance window. None of that serves the application; it serves the fact that the application happens to live inside a general-purpose operating system. Once workloads moved to containers on Talos, most of that incidental surface stopped existing. There's no OS to patch inside the workload, because the container image carries only the application and its libraries, and the only operating system underneath is Talos: immutable, managed through an API, upgraded as a cluster-wide rolling operation rather than VM by VM.

That doesn't mean every VM is a container waiting to happen, and the first practical step was finding out which ones were. I ran an inventory across the hosts that turned the VM list into a migration conversation. It records what each VM consumes and flags the properties that change the answer: likely Windows guests, several disks, large memory footprints, existing checkpoints. It doesn't produce a verdict, because the verdict needs someone who knows the application.

powershell
#Requires -Modules Hyper-V
<#
.SYNOPSIS
    Inventories Hyper-V VMs across hosts and flags what each one would need to become a container workload.

.DESCRIPTION
    Collects, per VM: host, state, generation, vCPU, memory, disk count and size,
    checkpoints, IP addresses and the VM's Notes field. It then adds flags that
    drive the migration conversation, not a verdict: likely Windows guests
    (Talos nodes are Linux, and Windows containers need Windows worker nodes),
    several disks (state that needs a PersistentVolume design), large memory
    footprints, and existing checkpoints. Output goes to the pipeline and,
    optionally, a CSV.

.PARAMETER ComputerName
    Hyper-V hosts to query. Defaults to the local host.

.PARAMETER CsvPath
    Optional path for a CSV export.

.PARAMETER LargeMemoryGB
    Startup memory at or above which a VM is flagged as large (default 16).

.PARAMETER WindowsPattern
    Regular expression matched against the VM name and Notes to flag Windows
    guests. Adjust it to your naming convention (default '^(win|ws|srv)|windows').

.EXAMPLE
    .\Get-HyperVMigrationInventory.ps1 -ComputerName HV01, HV02, HV03 -CsvPath .\hyperv-inventory.csv

.EXAMPLE
    .\Get-HyperVMigrationInventory.ps1 -ComputerName HV01 | Where-Object -FilterScript { $_.Flags -eq '' } | Format-Table -Property Name, Host, MemoryGB, DiskGB
#>
[CmdletBinding()]
param(
    [string[]] $ComputerName = @($env:COMPUTERNAME),
    [string] $CsvPath,
    [int] $LargeMemoryGB = 16,
    [string] $WindowsPattern = '^(win|ws|srv)|windows'
)

$ErrorActionPreference = 'Stop'

$results = foreach ($hostName in $ComputerName) {
    foreach ($vm in (Get-VM -ComputerName $hostName)) {
        $drives = @(Get-VMHardDiskDrive -VM $vm)
        $diskBytes = 0
        foreach ($drive in $drives) {
            if ($drive.Path) {
                $diskBytes += (Get-VHD -Path $drive.Path -ComputerName $hostName).FileSize
            }
        }

        $adapters = @(Get-VMNetworkAdapter -VM $vm)
        $addresses = @($adapters.IPAddresses | Where-Object -FilterScript { $_ -match '^\d{1,3}(\.\d{1,3}){3}$' })
        $checkpoints = @(Get-VMSnapshot -VM $vm)
        $memoryGB = [math]::Round($vm.MemoryStartup / 1GB, 1)

        $flags = [System.Collections.Generic.List[string]]::new()
        if ($vm.Name -match $WindowsPattern -or $vm.Notes -match $WindowsPattern) {
            $flags.Add('windows-guest')
        }
        if ($drives.Count -gt 1) {
            $flags.Add('multiple-disks')
        }
        if ($memoryGB -ge $LargeMemoryGB) {
            $flags.Add('large-memory')
        }
        if ($checkpoints.Count -gt 0) {
            $flags.Add('has-checkpoints')
        }

        [pscustomobject]@{
            Name         = $vm.Name
            Host         = $hostName
            State        = $vm.State
            Generation   = $vm.Generation
            vCPU         = $vm.ProcessorCount
            MemoryGB     = $memoryGB
            DynamicMem   = $vm.DynamicMemoryEnabled
            Disks        = $drives.Count
            DiskGB       = [math]::Round($diskBytes / 1GB, 1)
            Checkpoints  = $checkpoints.Count
            IPv4         = $addresses -join ';'
            Switch       = ($adapters.SwitchName | Select-Object -Unique) -join ';'
            Flags        = $flags -join ';'
            Notes        = $vm.Notes
        }
    }
}

if ($CsvPath) {
    $results | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding utf8
}

$results
text
Name        : app-reports01
Host        : HV02
State       : Running
Generation  : 2
vCPU        : 2
MemoryGB    : 4
DynamicMem  : True
Disks       : 1
DiskGB      : 38.2
Checkpoints : 0
IPv4        : 10.20.12.44
Switch      : vSwitch-Trunk
Flags       :
Notes       : Reporting web front end (Linux, nginx + Python)

An empty Flags column is the short list: a small Linux VM with one disk is usually a service whose state already lives in a database somewhere else. The IP column depends on Hyper-V integration services reporting guest addresses, so an empty value there usually means the guest isn't reporting through KVP rather than that it has no address.

The flag that ends the conversation fastest is windows-guest. Talos nodes are Linux, and Kubernetes runs Windows containers only on Windows worker nodes. The control plane is Linux-only, and Windows nodes join an existing Linux cluster. Talos doesn't provide Windows nodes, so a Windows service that can't be rebuilt for Linux stays a VM, and saying so early saves months of false hope.

Talos removes the operational tax that made Hyper-V feel heavy

The comparison that convinced skeptical stakeholders wasn't a performance benchmark, it was an operational one. A Hyper-V host needs Windows Update planning, cluster-aware updating coordination, and antivirus exclusions tuned correctly. A Talos node has none of that: no shell to secure, no package manager to patch, configuration applied through an API instead of accumulated through years of interactive changes. Upgrades use an A-B image scheme with automatic fallback if the new version doesn't boot, and Talos refuses to upgrade a control plane node when doing so would cost etcd quorum. The platform layer went from something that needed a dedicated maintenance calendar to something that mostly gets out of the way, and that freed up real time that used to go into hypervisor and guest OS care and feeding.

What a VM becomes

The unit of translation is worth making concrete, because "containerize it" hides most of the work. Take the reporting front end from the inventory: on Hyper-V it was a VM with the application under /opt/reports, its settings in a file on the local disk, generated exports written to a second folder, a local firewall rule for port 8080, and a backup agent. On the cluster it's this:

yaml
apiVersion: v1
kind: Namespace
metadata:
  name: reports
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: reports-config
  namespace: reports
data:
  settings.toml: |
    [database]
    host = "sql-reports.example.internal"
    port = 5432
    [exports]
    path = "/var/lib/reports/exports"
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: reports-exports
  namespace: reports
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: replicated-block
  resources:
    requests:
      storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: reports
  namespace: reports
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: reports
  template:
    metadata:
      labels:
        app: reports
    spec:
      containers:
        - name: reports
          image: registry.example.com/reports/web:3.2.0
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
          resources:
            requests:
              cpu: 250m
              memory: 512Mi
            limits:
              memory: 1Gi
          volumeMounts:
            - name: config
              mountPath: /etc/reports
              readOnly: true
            - name: exports
              mountPath: /var/lib/reports/exports
      volumes:
        - name: config
          configMap:
            name: reports-config
        - name: exports
          persistentVolumeClaim:
            claimName: reports-exports
---
apiVersion: v1
kind: Service
metadata:
  name: reports
  namespace: reports
spec:
  selector:
    app: reports
  ports:
    - port: 80
      targetPort: 8080

Every line of that replaces something that used to be implicit in the VM. The settings file is a ConfigMap under version control instead of a file someone edited over RDP. The exports folder is a PersistentVolumeClaim against a CSI storage class, so it survives the pod moving to another node. The port the firewall rule opened is exposed by a Service (a NetworkPolicy takes over the allow/deny half, and an Ingress or Gateway route the outside access). The patch cadence is a new image tag. And the honest part: a ReadWriteOnce volume attaches to one node at a time, so this Deployment deliberately runs one replica with the Recreate strategy (a rolling update would start the new pod before the old one releases the volume), so a node drain still means a short outage for this app. Getting to more than one replica would mean moving the exports to object storage or a shared filesystem, which is application work, not platform work. The manifest makes that trade-off visible, where the VM hid it.

What actually got harder

None of this was free. Applications that assumed a persistent, stateful VM (a specific file written to a specific local path, a Windows service with baked-in configuration, licensing tied to a machine identity) needed real rework to run well as containers, and a few didn't make the trip at all. They stayed on a smaller, deliberately retained Hyper-V footprint, which is a legitimate outcome rather than a failure. Hyper-V: Migrating VMs Off an Aging Cluster Without Downtime covers keeping that footprint healthy.

Storage was the sharpest edge. A VM's virtual disk is a familiar, forgiving abstraction. Kubernetes persistent storage (we settled on a CSI-backed distributed storage layer) demands more upfront design about replication, failure domains and backup than most teams have previously had to think about for a "just give it a disk" VM workload. Talos' storage guide is refreshingly blunt about it: most people shouldn't use mount-many (ReadWriteMany) semantics at all, NFS is pervasive "because it is old and easy, not because it is a good idea," and Ceph can be slow and resource-hungry on small clusters. Backup changes shape too. There's no VM to snapshot; what needs protecting is the cluster's secrets bundle and machine config patches, regular etcd snapshots (talosctl etcd snapshot), and the application data in those persistent volumes.

The strategic case outlasts the technical one

The technical reasons to make this move are solid, but the argument that held up in front of leadership was different. A fleet of general-purpose VMs is a fleet of general-purpose maintenance obligations that grow with every VM: each new application on Hyper-V was a new VM with its own OS, patch cycle, agents and backup job. Each new application on the Talos cluster is a new set of manifests on infrastructure that is already being maintained anyway. The platform's own upkeep grows with the number of nodes, not the number of applications. That's the version of "rethinking the stack" that actually changes a budget conversation, not just an architecture diagram.

References