Hyper-V → KVM Migration
Setup: Pop!_OS host, KVM/QEMU + libvirt, Arch Linux guest, 40GB VHDX → qcow2.
Three non-obvious blockers encountered.
1. No Bootloader on the Disk
Hyper-V Gen 2 uses direct kernel boot — it reads the kernel and initramfs from a partition directly, bypassing any bootloader. Boot config (kernel path, flags, root partition) lives in Hyper-V's settings, not on disk.
Verification:
sudo modprobe nbd max_part=8
sudo qemu-nbd --connect=/dev/nbd0 Arch.vhdx
sudo fdisk -l /dev/nbd0 # Disklabel type: dos — no EFI System Partition
sudo hexdump -C /dev/nbd0 | head -5 # MBR is all zeros — no bootloader code
OVMF falls through PXE and HTTP boot to EFI shell. SeaBIOS finds empty MBR and fails. Both correct.
Fix: QEMU direct kernel boot, mirroring what Hyper-V did internally:
sudo mount /dev/nbd0p1 /mnt/boot
cp /mnt/boot/vmlinuz-linux ~/vm/
cp /mnt/boot/initramfs-linux.img ~/vm/
sudo umount /mnt/boot && sudo qemu-nbd --disconnect /dev/nbd0
qemu-system-x86_64 \
-kernel ~/vm/vmlinuz-linux \
-initrd ~/vm/initramfs-linux.img \
-append "root=/dev/vda2 rw console=ttyS0" \
...
2. initramfs Has Wrong Drivers
Symptom: system hung at boot with Timed out waiting for device /dev/vda2.
Cause: Arch's mkinitcpio uses an autodetect hook that scans running hardware and bakes in only those drivers. On Hyper-V, it included hv_storvsc + hv_vmbus. KVM presents storage via virtio_blk, which was absent.
Verification:
cpio -t < initramfs-linux.img | grep -i "virtio\|hv_"
# hv_vmbus.ko.zst ← present
# hv_storvsc.ko.zst ← present
# virtio_blk ← missing
# virtio_pci ← missing
Fix: rebuild initramfs in a chroot with explicit driver list (module paths are kernel-version-specific — can't copy from another machine):
sudo qemu-nbd --connect=/dev/nbd0 disk.vhdx
sudo mount /dev/nbd0p2 /mnt/vm-root
sudo mount /dev/nbd0p1 /mnt/vm-root/boot
sudo mount --bind /proc /mnt/vm-root/proc
sudo mount --bind /dev /mnt/vm-root/dev
# In /mnt/vm-root/etc/mkinitcpio.conf:
# MODULES=(virtio_pci virtio_blk)
sudo chroot /mnt/vm-root mkinitcpio -p linux
cp /mnt/vm-root/boot/initramfs-linux.img ~/vm/
sudo umount /mnt/vm-root/proc /mnt/vm-root/dev /mnt/vm-root/boot
sudo umount /mnt/vm-root && sudo qemu-nbd --disconnect /dev/nbd0
autodetect makes initramfs hardware-specific in a non-obvious way. Explicit MODULES=() overrides it and makes the image portable.
3. Guest Config References Hardware by Name
Interface naming: Hyper-V NIC was eth0. KVM virtio NIC gets a predictable slot-based name (ens3, enp2s0, etc.). systemd-networkd config matched on the old name and did nothing.
Fix — match by MAC, set a fixed MAC in libvirt:
# /etc/systemd/network/20-wired.network
[Match]
MACAddress=52:54:00:12:34:56
[Network]
DHCP=yes
Avahi: /etc/avahi/avahi-daemon.conf had allow-interfaces=eth0. Avahi started, found no eth0, bound to nothing, mDNS silently broken. Fix: remove allow-interfaces line entirely.
systemd-resolved conflict: Avahi and systemd-resolved both contend for port 5353. Avahi backs off with a warning. If using Avahi for mDNS, disable it in resolved:
mkdir -p /etc/systemd/resolved.conf.d
echo -e "[Resolve]\nMulticastDNS=no" > /etc/systemd/resolved.conf.d/no-mdns.conf
systemctl restart systemd-resolved avahi-daemon
Debugging Without Network
Serial console via Unix socket (only viable path when SSH is unavailable):
# QEMU flag:
-serial unix:/tmp/vm-serial.sock,server,nowait
# Kernel cmdline must include console=ttyS0 — otherwise output goes to VGA only
Connect with Python (socat/nc were unreliable):
import socket, time
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect('/tmp/vm-serial.sock')
s.settimeout(3)
s.send(b'\r\n')
buf = b''
for _ in range(30):
try: buf += s.recv(4096)
except: break
time.sleep(0.2)
print(buf.decode('utf-8', errors='replace'))
s.close()
Reading guest journal from host fails if systemd versions differ:
Failed to open files: Protocol not supported
Workaround: chroot into guest and use its own journalctl:
sudo chroot /mnt/vm-root journalctl --no-pager -b -1 -u systemd-networkd
Disk Format
QEMU boots VHDX directly with format=vhdx, but libvirt doesn't accept vhdx as a driver name. Convert after fixing initramfs and network — not before:
qemu-img convert -f vhdx -O qcow2 Arch.vhdx Arch.qcow2
Installing GRUB and Finalizing
Once the VM is running, retire the host-side kernel/initramfs copies:
sudo pacman -S grub
sudo grub-install --target=i386-pc /dev/vda
sudo grub-mkconfig -o /boot/grub/grub.cfg
MBR partition table has a 1MB gap before the first partition (sectors 0–2047) — that's where GRUB embeds its core image. grub-mkconfig picks up kernel + initramfs from /boot automatically.
Clean up mkinitcpio.conf: revert MODULES=(virtio_pci virtio_blk) back to MODULES=(). autodetect now runs inside KVM and includes the right drivers. Regenerate:
sudo mkinitcpio -p linux
Remove <kernel>, <initrd>, <cmdline> from libvirt XML, reboot. Boot path: SeaBIOS → GRUB → kernel. VM is fully self-contained.
Final libvirt XML
<domain type="kvm">
<name>supraa-virt</name>
<memory unit="GiB">18</memory>
<vcpu>10</vcpu>
<os><type arch="x86_64">hvm</type></os>
<features><acpi/><apic/></features>
<cpu mode="host-passthrough"/>
<devices>
<emulator>/usr/bin/qemu-system-x86_64</emulator>
<disk type="file" device="disk">
<driver name="qemu" type="qcow2" cache="none" io="native"/>
<source file="/home/surya/vm/Arch.qcow2"/>
<target dev="vda" bus="virtio"/>
</disk>
<interface type="bridge">
<mac address="52:54:00:12:34:56"/>
<source bridge="br0"/>
<model type="virtio"/>
</interface>
<serial type="unix">
<source mode="bind" path="/tmp/vm-serial.sock"/>
<target type="isa-serial" port="0"/>
</serial>
<console type="unix">
<source mode="bind" path="/tmp/vm-serial.sock"/>
<target type="serial" port="0"/>
</console>
</devices>
</domain>
virsh define supraa-virt.xml && virsh start supraa-virt && virsh autostart supraa-virt
Migration Checklist (order matters)
Host-side (disk offline):
- Mount via NBD, inspect partition table:
fdisk -l /dev/nbd0 - Extract kernel + initramfs from boot partition to host directory
- Edit
mkinitcpio.confin chroot:MODULES=(virtio_pci virtio_blk) - Chroot, regenerate initramfs:
mkinitcpio -p linux, copy result to host - Update network config: match by MAC, not interface name
- Fix Avahi if present: remove
allow-interfacesline - Disable mDNS in systemd-resolved if Avahi owns it
- Convert VHDX → qcow2:
qemu-img convert -f vhdx -O qcow2 - Write libvirt XML with direct kernel boot (kernel/initrd/cmdline pointing at host copies)
virsh define,virsh start,virsh autostart
Guest-side (once running):
- Install GRUB:
pacman -S grub && grub-install --target=i386-pc /dev/vda && grub-mkconfig -o /boot/grub/grub.cfg - Revert
MODULES=()inmkinitcpio.conf, regenerate:mkinitcpio -p linux - Host: remove
<kernel>,<initrd>,<cmdline>from libvirt XML - Reboot — SeaBIOS → GRUB → kernel, fully self-contained