Debian-Gastsysteme mit QEMU-ARM und libvirt virtualisieren

Wie die meisten Embedded-Systeme auf dem Markt ist auch unsere Spektralwerk-Plattform ARM-basiert. Sie arbeiten mit einer i.MX283 CPU von NXP (ehemals Freescale Semiconductor). ARM-Kerne sind besonders für Umgebungen geeignet, die niedrigen Energieaufwand und hohe Temperatur-Stabilität erfordern. Allerdings sind sie nicht gerade für ihre hohe Leistungsfähigkeit berühmt. Zwar greifen wir deshalb so weit wie möglich auf cross-compile toolchains zurück, doch benötigen wir dennoch regelmäßig tatsächliche ARM-Umgebungen für das Kompilieren von Bibliotheken und Anwendungen.

Bis jetzt haben wir in diesen Fällen auf echte Hardware zurückgegriffen, hauptsächlich ODROID-XU4- und Banana PI BPI-M3-SBCs, weil diese Boards relativ viel RAM und genug Rechenkraft mitbringen, um vollständige Kompilierungen der Firmware in akzeptablen Zeiträumen durchlaufen zu können. Auf jedem dieser Boards läuft ein Buildbot worker.

Mit dem Release von Debian Buster haben wir uns noch einmal mit dem Thema ARM-Virtualisierung unter QEMU gewidmet. Peter Maydell hat 2016 einen exzellenten Artikel über das virt-Board in QEMU veröffentlicht. Dieses Board emuliert im Gegensatz etwa zum versatilepb-Board keine häufig genutzte echte Hardware und kann deshalb mit viel RAM und einer variablen Anzahl an CPUs konfiguriert werden. Der Artikel bildete einen tollen Ausgangspunkt für unsere eigene Umsetzung, aber ordentliche libvirt-Integration entpuppte sich als einer der Stolpersteine. Libvirt ist ein Virtualisierungs-Daemon samt API, der einige Abstraktionen für übliche Virtualisierungs- und Prozess-Isolations-Techniken wie LXC, KVM und XEN definiert.

Von QEMU-Argumenten zu libvirt-XML

Peter Maydells Artikel endet mit folgendem qemu-system-arm-Aufruf:

shell
qemu-system-arm -M virt -m 1024 \
  -kernel vmlinuz-3.16.0-4-armmp-lpae \
  -initrd initrd.img-3.16.0-4-armmp-lpae \
  -append 'root=/dev/vda2' \
  -drive if=none,file=hda.qcow2,format=qcow2,id=hd \
  -device virtio-blk-device,drive=hd \
  -netdev user,id=mynet \
  -device virtio-net-device,netdev=mynet \
  -nographic

Das spezifische Problem, über das wir stolperten, war die Übersetzung des -device-Arguments in etwas, das libvirt versteht. virsh, das Kommandozeilen-Verwaltungs-Tool, das mit libvirt geliefert wird, hat eigens einen domxml-from-native-Befehl, der entworfen wurde, um QEMU-Argumente in das entsprechende libvirt-Format zu übersetzen. Leider befindet es sich, wie in dieser Mail von Cole Robinson beschrieben, nicht gerade in einem optimal gewarteten Zustand.

In unserem nächsten Ansatz haben wir deshalb die virtuelle Maschine mit den funktionierenden QEMU-Argumenten gestartet und dann analysiert, welche Busse und Treiber innerhalb der Maschine genutzt werden. So wollten wir herausfinden, was genau wir in der libvirt-Umgebung reproduzieren müssen. Et voilá: der kritische Hinweis befand sich im Verzeichnis /dev/disk/by-path/, das die kürzesten physischen Pfade zu den Festplatten des Systems enthält. In unserem Fall führte ein simpler Aufruf von ls /dev/disk/by-path/ zu folgender Ausgabe:

platform-a003c00.virtio_mmio platform-a003e00.virtio_mmio

virtio-mmio ist ein gültiges type-Attribut in der Dokumentation des -Elements jedes <device>-Eintrags. Im Gegensatz dazu generiert libvirt standardmäßig pci-Adresstypen, die scheinbar nicht in aktuellen Versionen von Debian unterstützt werden. Letztendlich mussten wir also einfach alle <address type="pci" .../>-Zeilen durch <address type="virtio-mmio"/> ersetzen, um die default-libvirt-Konfiguration unseres ARM-hosts in etwas tatsächlich lauffähiges zu übersetzen. Das hat sowohl für lokale als auch für Netzerk-Laufwerke funktioniert.

So sieht unsere funktionierende libvirt-Konfiguration aus:

<domain type='qemu' id='38'>
 <name>usain</name>
 <uuid>3bf6e58f-e513-47b4-9b64-e00b32d9d9f4</uuid>
 <memory unit='KiB'>3145728</memory>
 <currentMemory unit='KiB'>3145728</currentMemory>
 <vcpu placement='static'>3</vcpu>
 <resource>
 <partition>/machine</partition>
 </resource>
 <os>
 <type arch='armv7l' machine='virt-3.1'>hvm</type>
 <kernel>/var/lib/libvirt/boot/usain/vmlinuz</kernel>
 <initrd>/var/lib/libvirt/boot/usain/initrd.img</initrd>
 <cmdline>root=UUID=7a7f1855-2536-4342-a481-4853a125560f</cmdline>
 <boot dev='hd'/>
 </os>
 <features>
 <gic version='2'/>
 </features>
 <clock offset='utc'/>
 <on_poweroff>destroy</on_poweroff>
 <on_reboot>restart</on_reboot>
 <on_crash>restart</on_crash>
 <devices>
 <emulator>/usr/bin/qemu-system-arm</emulator>
 <disk type='block' device='disk'>
 <driver name='qemu' type='raw'/>
 <source dev='/dev/lvm-uhura/usain-boot'/>
 <backingStore/>
 <target dev='vda' bus='virtio'/>
 <alias name='virtio-disk0'/>
 <address type='virtio-mmio'/>
 </disk>
 <disk type='block' device='disk'>
 <driver name='qemu' type='raw'/>
 <source dev='/dev/lvm-uhura/usain-root'/>
 <backingStore/>
 <target dev='vdb' bus='virtio'/>
 <alias name='virtio-disk1'/>
 <address type='virtio-mmio'/>
 </disk>
 <controller type='pci' index='0' model='pcie-root'>
 <alias name='pcie.0'/>
 </controller>
 <interface type='bridge'>
 <mac address='52:54:00:79:39:16'/>
 <source bridge='br-virt'/>
 <target dev='vnet0'/>
 <model type='virtio'/>
 <alias name='net0'/>
 <address type='virtio-mmio'/>
 </interface>
 <serial type='pty'>
 <source path='/dev/pts/1'/>
 <target type='system-serial' port='0'>
 <model name='pl011'/>
 </target>
 <alias name='serial0'/>
 </serial>
 <console type='pty' tty='/dev/pts/1'>
 <source path='/dev/pts/1'/>
 <target type='serial' port='0'/>
 <alias name='serial0'/>
 </console>
 </devices>
 <seclabel type='dynamic' model='apparmor' relabel='yes'>
 <label>libvirt-3bf6e58f-e513-47b4-9b64-e00b32d9d9f4</label>
 <imagelabel>libvirt-3bf6e58f-e513-47b4-9b64-e00b32d9d9f4</imagelabel>
 </seclabel>
 <seclabel type='dynamic' model='dac' relabel='yes'>
 <label>+64055:+64055</label>
 <imagelabel>+64055:+64055</imagelabel>
 </seclabel>
</domain>

Automatische Kernel-Updates

Als eine weitere Komplikation bei der ARM-Virtualisierung war die Notwendigkeit, das System aktuell mit dem direct kernel boot-Feature starten zu müssen. Libvirt unterstützt direct kernel boot out of the box, aber der Kernel und das initrd-Image müssen vom Dateisystem des virtualisierenden Hosts aus erreichbar sein. Das führt dazu, dass nach einem Update zwar Kernel und initrd innerhalb der virtuellen Maschine aktualisiert wurden, danach die neuen Versionen aber zusätzlich in das Host-System kopiert werden müssen. Sobald also Kernel oder initrd verändert wurden, mussten wir die boot-Partition mounten und die Datein an eine Stelle kopieren, wo libvirt auf sie zugreifen konnte.

Glücklichweise unterstützt libvirt aber Hooks, die ausgeführt werden, sobald ein qemu-Gast gestartet wird. Wir nutzen überlicherweise LVM als Speicher-backend für libvirt. Jedem ARM-Gast wird ein $name-boot- und ein $name-root-Volume zugewiesen. Wann immer wir nun einen ARM-Gast starten, können wir automatisch das entsprechende LVM-Volume mounten, Kernel und initrd kopieren und libvirt den Rest erledigen lassen. Das funktioniert bisher sehr gut und hat den Wartungsaufwand im Falle automatischer System-Updates unserer ARM-Gäste deutlich verringert.

Dies ist der Hook, den wir dafür nutzen:

#!/bin/sh

set -eu

GUEST="$1"
ACTION="$2"
PHASE="$3"

BOOT_IMAGE_BASE_PATH=/var/lib/libvirt/boot

_is_mounted() {
    grep -qwF "$1" /proc/mounts
}

_is_host_running() {
    # calling virsh domstate here will cause the process to hang so we use ps instead
    ps --no-headers -u libvirt-qemu -o cmd | grep -q -- "-name guest=$1"
}

_get_boot_volume() {
    local guest="$1"
    local configured_volume
    local dm_path

    # looks for a volume whose name ends with "-boot"
    configured_volume="$(
        xmllint --xpath 'string(/domain/devices/disk[@type="block"]/source[substring(@dev, string-length(@dev) - string-length("-boot") + 1) = "-boot"]/@dev)' \
            "/etc/libvirt/qemu/$guest.xml"
    )"

    # the configured volume might contain any path that refers to a volume but /proc/mounts
    # will contain paths from /dev/mapper so we need to find the path of the actual devices
    # and then find the corresponding symlink in /dev/mapper
    dm_path="$(realpath "$configured_volume")"
    find /dev/mapper -type l | while read -r mapper_path; do
        if [ "$(readlink -f "$mapper_path")" = "$dm_path" ]; then
            echo "$mapper_path"
            break
        fi
    done
}

update_guest_kernel_and_initrd() {
    # ARM hosts cannot be booted like any x64_64 host.
    # Instead we need libvirt to boot the kernel directly along with the guest’s generated initrd.
    # We update the kernel and initrd on every guest startup, so that a system update will behave
    # as expected on the next reboot.
    local guest="$1"
    local boot_image_path="$BOOT_IMAGE_BASE_PATH/$guest"
    local boot_volume
    local tmp_mount_path
    boot_volume="${2:-$(_get_boot_volume "$guest")}"

    if [ ! -z "$boot_volume" ]; then
        echo "Boot volume for guest $guest not found." >&2
        return 1
    fi

    if [ ! -e "$boot_volume" ]; then
        echo "Boot volume for guest $guest does not exist in '$boot_volume'. Cannot extract kernel and initrd." >&2
        return 1
    fi

    if _is_host_running "$guest"; then
        # this should not happen, but maybe someone is calling this script manually
        echo "Guest $guest is not shut down. Refusing to mount volumes." >&2
        return 1
    fi

    if _is_mounted "$boot_volume"; then
        echo "Boot volume '$boot_volume' is already mounted in the system. Mounting the volume twice may cause data loss." >&2
        return 1
    fi

    mkdir -p --mode 750 "$boot_image_path"
    chgrp libvirt-qemu "$boot_image_path"
    tmp_mount_path="$(mktemp -d)"
    trap "umount '$tmp_mount_path'; rmdir '$tmp_mount_path'" EXIT
    mount -o ro "$boot_volume" "$tmp_mount_path"
    cp "$tmp_mount_path/vmlinuz" "$tmp_mount_path/initrd.img" "$boot_image_path/"
}

if [ "$ACTION" = prepare ] && [ "$PHASE" = begin ]; then
    # kernel and initrd of guests that use ARM emulation should be updated before being started
    if grep -qwF /usr/bin/qemu-system-arm "/etc/libvirt/qemu/$GUEST.xml"; then
        update_guest_kernel_and_initrd "$GUEST"
    fi
fi

Fazit

ARM-Virtualisierung funktioniert mit Debian Buster sehr gut. Auch wenn die Performance nicht an echte ARM-Hardware heranreicht ist die Emulation dennoch schnell genug für unsere Anwendungsfälle. Die echten Boards, die wir zuvor genutzt haben, liefen oft nur mit uralten Kerneln und unfreier Firmware. Diese Probleme entfallen mit virtualisiserten Umgebungen. Gleichzeitig lassen sie sich im Vergleich zu den echten Boards viel leichter skalieren, verwalten und replizieren.