For a while my little world ran on two Windows machines. Two Hyper-V servers - I call them Atlas and Hercules - between them carrying about twenty virtual machines. Each VM does exactly one job: calendaring here, uptime monitoring there, a helpdesk, an identity provider, a secrets store, a handful of small web apps. The tidy one-service-per-box discipline I like, sitting on top of a hypervisor I had stopped liking.
Nothing was on fire. The servers worked. But two Windows hosts to patch and license, to keep Integration Services happy on, to babysit a GUI on, is two more moving parts than a one-person shop needs. I wanted one Linux box running KVM, all twenty VMs on it, and my weekends back. So I wrote a script and moved them, one at a time, in an afternoon.
This is how it went, including the single line of YAML that stops everyone the first time.
Why KVM and not something with a dashboard
I did not want Proxmox or oVirt or anything with its own web UI and its own opinions. The target, a box I call Zeus, runs plain Ubuntu with KVM (the kernel's hypervisor), QEMU (the per-VM userspace process), and libvirt (the management layer virsh and Cockpit talk to). That is the whole stack. If I can drive it from a shell script over SSH, I can understand it at 2am, and I can put the script on a repo for the next person.
The migration would be cold - stop each VM, copy its disk, boot it on the other side. Not live migration; I don't need zero downtime for a personal fleet, and cold is dramatically simpler and more reliable for a one-shot move. A VM is down only for the minutes its disk takes to convert.
The shape of one migration
Everything below happens per VM, driven by six arguments: name, which source host, MAC, memory, vCPUs, and expected IP.
Find the disk, stop the VM. Windows has had OpenSSH and PowerShell for years, so the Linux box just asks the Hyper-V host directly:
ssh Administrator@atlas "(Get-VMHardDiskDrive -VMName 'MyServer').Path"
ssh Administrator@atlas "Stop-VM -Name 'MyServer'"
Convert the disk. Hyper-V's disk format is VHDX; KVM's is qcow2. qemu-img reads the VHDX straight off a share mounted on Zeus and writes the qcow2 into the libvirt pool:
qemu-img convert -f vhdx -O qcow2 "$SRCFILE" myserver.qcow2
Define and boot it on KVM. virt-install --import takes the finished qcow2, puts the disk and NIC on virtio, attaches it to the host bridge, and - the important part - reuses the original MAC address. Then virsh autostart so it comes back on reboot.
Between convert and boot sits the one step that actually earns this post.
The eth0 trap
Here is the thing I would have paid money to read beforehand.
Under Hyper-V, the guest's network card is driven by hv_netvsc, and it shows up inside the guest as eth0. Move that same disk to KVM, where the card is now virtio-net, and Linux names the interface enp1s0 instead. If the guest configures its network by matching on the interface name - and Ubuntu's netplan does exactly that - it now matches an interface that no longer exists.
The VM boots perfectly. The disk is fine, the services start, and it has no network. You find out when it never answers a ping and you're staring at virsh console watching a healthy machine with a dead NIC.
The fix is to stop caring about the name. Before the VM ever boots, I mount the qcow2 offline with guestfish and write a netplan that matches the card by its MAC address - which I carried across from Hyper-V and which cannot change - and forces the name back to eth0:
network:
version: 2
ethernets:
primary:
match:
macaddress: "00:15:5d:..."
set-name: eth0
dhcp4: true
Two details make it robust. First, guests disagree about where netplan lives - some have 50-cloud-init.yaml, some 00-installer-config.yaml - so the script discovers the existing files and moves them aside rather than assuming. Second, it disables cloud-init's networking, because otherwise cloud-init helpfully rewrites your careful override on the next boot. Match by MAC, pin the name, tell cloud-init to keep its hands off. Now the interface name genuinely cannot matter.
Why the MAC is the whole game
Preserving the MAC does more than fix the interface name. My DHCP server hands out addresses by reservation - this MAC always gets that IP. So the moment the migrated VM comes up with its original MAC, it gets its original address. DNS still resolves it. Firewall rules still match it. Every other service that talked to it by name or number carries on as if nothing happened, because from the network's point of view, nothing did. The machine's soul moved from Windows to Linux and kept its phone number.
That is why the last step of each migration is simply to ping the expected IP until it answers. If it answers, the VM is home, at the same address, and I move to the next one.
The result
Twenty VMs now live on Zeus, all set to autostart, all bridged onto the LAN with their original MACs and IPs. Committed memory across the fleet is about 56 GiB against 124 GiB of RAM, so there is comfortable headroom - and, pleasingly, both Windows servers are now free to switch off.
The whole thing is small enough to read: an inventory script that runs on the Hyper-V side, one migration script, and a driver loop. I put it on GitHub in case you are about to do the same thing and would like to skip the afternoon I spent on the netplan trap:
https://github.com/creativeheadz/hyperv-to-kvm
Then the backups had to move too
A migration is only half a re-platforming; the nightly backup job had to come across as well. On Windows it was a scheduled Export-VM to the NAS. On KVM I replaced it with a script that takes a live snapshot of each running VM, backs up the base image while the VM keeps writing to a temporary overlay, then merges the overlay back with blockcommit - so nothing is ever stopped. Images are written zstd-compressed to fit more history in less space.
One honest caveat, the same one I had under Hyper-V: these backups are crash-consistent, not application-consistent. Without a guest agent the host can't tell the guest to freeze its filesystem for a clean instant. The upgrade path is a one-liner - install qemu-guest-agent in each guest and the snapshots become filesystem-consistent - and it is the next thing on my list, starting with the database-backed VMs.
Would I do it again
Yes, and faster. The parts I braced for - the disk conversion, defining the domains - were boring, which is what you want. The part that bit me was the one I didn't know to expect, and now you do. If you take one thing from this: when a Linux VM moves hypervisors and loses its network, it's almost always the interface name. Match on the MAC and the problem disappears.
Notes for whoever is doing this next:
- Cold migration is fine for a one-shot move. Don't reach for live migration you don't need.
- Carry the MAC across on
virt-install(--network ...,mac=...). It fixes networking AND keeps the DHCP-reserved IP. - The netplan fix is Ubuntu-specific. Other distros: the same idea via udev
.link, NetworkManager, or/etc/network/interfaces. - Disable cloud-init networking, or it will overwrite your override on boot.
- Gen-2 (UEFI) Hyper-V VMs map cleanly to the libvirt
q35machine type.