Surya Susarla

Dev log

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):

  1. Mount via NBD, inspect partition table: fdisk -l /dev/nbd0
  2. Extract kernel + initramfs from boot partition to host directory
  3. Edit mkinitcpio.conf in chroot: MODULES=(virtio_pci virtio_blk)
  4. Chroot, regenerate initramfs: mkinitcpio -p linux, copy result to host
  5. Update network config: match by MAC, not interface name
  6. Fix Avahi if present: remove allow-interfaces line
  7. Disable mDNS in systemd-resolved if Avahi owns it
  8. Convert VHDX → qcow2: qemu-img convert -f vhdx -O qcow2
  9. Write libvirt XML with direct kernel boot (kernel/initrd/cmdline pointing at host copies)
  10. virsh define, virsh start, virsh autostart

Guest-side (once running):

  1. Install GRUB: pacman -S grub && grub-install --target=i386-pc /dev/vda && grub-mkconfig -o /boot/grub/grub.cfg
  2. Revert MODULES=() in mkinitcpio.conf, regenerate: mkinitcpio -p linux
  3. Host: remove <kernel>, <initrd>, <cmdline> from libvirt XML
  4. Reboot — SeaBIOS → GRUB → kernel, fully self-contained