Linux-Virtualisierung ohne Qemu

Linux-Virtualisierung ohne Qemu

Seit vor gut 10 Jahren der kernelbasierte Linux-Hypervisor KVM entwickelt wurde, gehört Virtualisierung auf Linux zum Alltag. Typischerweise wird KVM zusammen mit Qemu verwendet, das aber älter ist und komplett unabhängig von KVM entwickelt wurde. Dieser Artikel beschreibt, wie Qemu und KVM zusammenarbeiten, wie KVM intern funktioniert und stellt mit kvmtool eine schlanke Alternative zu Qemu vor.

KVM steht für "Kernel-based Virtual Machine", wieso also brauchen wir zur Virtualisierung überhaupt eine Userspace-Komponente? Um diese Frage zu beantworten, müssen wir uns zuerst ansehen, wie Virtualisierung auf x86-Prozessoren funktioniert. Um eine CPU zu virtualisieren, braucht man einen Mechanismus, um unter anderem sogenannte "Control-sensitive Instructions" abzufangen, die den Zustand der CPU ändern und somit auch andere VMs auf der gleichen Maschine beeinträchtigen. Offensichtlich ist es nicht wünschenswert, dass Gäste nach Belieben in I/O-Ports schreiben oder beliebige Speicherseiten lesen dürfen. Früher war ein solcher Schutz auf x86-Prozessoren schwierig zu realisieren, aber das änderte sich ums Jahr 2006 herum, als Intel und AMD ihre Virtualisierungserweiterungen namens VMX (VT-x) und SVM (AMD-V) vorstellten. Zwar unterscheiden sie sich in der Implementation, aber die Idee dahinter ist ähnlich.

Bis dahin hatten x86-CPUs vier Berechtigungsringe. Betriebssysteme wie Linux und Windows verwenden davon nur zwei: den Ring 0 für Kernel-Code und Ring 3 für Userspace-Code. Hardwareunterstützte Virtualisierung fügt eine weitere Dimension hinzu: Host Mode und Guest Mode. Jede CPU-Instruktion, die andere Gäste beinträchtigen kann (auch wenn es keine privilegierte Instruktion ist), führt zu einem Übergang vom Guest-Mode in den Host-Mode (sogenannter VM Exit). So kann der Hypervisor die Instruktion prüfen und sie anschließend ausführen oder dem Gast einen Fehler zurückgeben. Solche Wechsel in den Host-Mode sind aufwendig, deshalb versuchen gute Hypervisoren sie zu minimieren.

Der Main-Loop des Hypervisors sieht folgendermaßen aus. Zuerst legt er die Kontrollstrukturen an, die der CPU mitteilen, bei welchen Instruktionen sie eine Exception auslösen soll. Sie speichern auch den aktuellen Gastzustand wie etwa die CPU-Register. Dann führt der Hypervisor eine spezielle Instruktion aus, um in den Gastmodus zu wechseln. Dieser Modus dauert an, bis ein Event eintritt, das die Kontrolle an der Hypervisor übergibt, zum Beispiel ein Interrupt. Der Hypervisor schaut sich an, warum die Exception aufgetreten ist, verändert die Kontrollstrukturen entsprechend und gibt die Kontrolle wieder an den Gast.

Linux führt im Endeffekt nie spezifischen "Gast-Code" aus, sondern immer nur Prozesse. Außerdem braucht man noch einen Weg, um neue Gäste zu starten, ihre Disk-Images zu konfigurieren, festzulegen wieviel Speicher sie besitzen und so weiter. Und dann ist da auch noch die Emulation von Devices: Wenn ein Gast zum Beispiel in einen I/O-Port schreibt, der zu einem PS/2-Controller gehört, muss da etwas sein, was die Register liest und entsprechend darauf reagiert. Dies sind die Dinge, die der Qemu-Userspace-Prozess übernimmt. Ansonsten teilt beispielsweise der Linux Scheduler die Rechenzeit einzelnen Gästen genauso zu wie den Prozessen des Host-Systems und wird damit seinem Namen "Kernel-based Virtual Machine" gerecht. Wenn ein Gast auf einen I/O-Port zugreift, leitet KVM die Anfrage an Qemu weiter, der sie entsprechend emuliert. Dies funktioniert auf der Basis des ioctl(2)-Interface, das wir uns gleich genauer ansehen (Abbildung 1).

Abbildung 1: Viele Anwender bekommen von KVM und Qemu nur grafische Frontends wie den virt-manager zu sehen.

Qemu ist die erste Wahl für eine Linux-basierte Userspace-Komponente zur Virtualisierung. Es kann unmodifizierte Gäste-OSs ausführen, also ist alles schon da, was KVM braucht. Einfach gesagt, genügt es, Qemu dazu zu bringen, immer, wenn es eine CPU-Instruktion ausführen soll, dafür KVM aufzurufen. Die Realität ist natürlich etwas komplexer und auch Qemu ist eine ziemlich komplexe Software. Einfacher wird es, wenn man sich dazu entschließt, nur Linux-Gäste auszuführen, die auf virtuelle Umgebgungen zugeschnitten sind, wird vieles einfacher. Zum Beispiel kann man dann darauf verzichten, Festplatten und Netzwerkkarten zu emulieren und stattdessen gleich virtualisierte I/O-Devices verwenden. Emulierte Hardware ist immer relativ langsam, aber die in Linux implementierten VirtIO-Geräte sind letztlich nichts anderes als dünne Wrapper für Ringpuffer [1]. Das macht VirtIO-Geräte schnell und einfach zu implementieren.

Kvmtool

Kvmtool [2] ist eine leichtgewichtige Alternative zu Qemu, die weitgehend auf Emulation verzichtet und deshalb nur Linux-Gäste unterstützt, die für die gleiche Architektur wie das Host-System compiliert sind. Es emuliert ein Minimum an an Legacy-Devices, etwa eine Real-time Clock, einen seriellen Port und einen Keyboard-Controller. Erstaunlicherweise ist das aber die Konfiguration, die ohnehin auch die meisten Qemu-Anwender verwenden.

Langsam setzt sich Kvmtool auch im professionellen Umfeld durch. Zum Beispiel bietet die Container-Engine Rocket mit Kvmtool eine Hypervisor-basierte Alternative zum klassischen Container [3], der nur mit Cgroups und Namespaces realisiert ist. Auch Google verfolgt in seiner Cloud (Abbildung 2) zur Steigerung der Sicherheit einen technisch ähnlichen Ansatz, auch wenn nicht direkt Kvmtool verwendet wird [4].

Abbildung 2: Auch die Google Cloud verwendet KVM, aber weder Qemu noch Kvmtool.

Der einfachste Weg, an Kvmtool zu kommen, ist es aus dem Github-Repository zu clonen:

git clone git://git.kernel.org/pub/scm/linux/kernel/git/will/kvmtool.git

Obwohl es auf kernel.org zu finden ist, ist es nicht Teil des Linux-Kernels und Linus Torvalds hat sich auch mehrfach dagegen ausgesprochen, es darin aufzunehmen. Das aktuelle Kvmtool ist größer als der ursprüngliche Prototyp, der sich in etwa 5000 Zeilen C-Code erschöpfte. Trotzdem ist es noch so klein und übersichtlich, dass man den Code verstehen kann und es sich auch dafür eignet, mehr über Virtualisierung zu lernen. Wir wollen uns im Folgenden den Code-Pfad anschauen, der verfolgt wird, wenn man eine VM startet.

Unter der Haube

Um eine VM zu starten, verwenden Sie das run-Kommando (siehe unten), das in der Datei "builtin-run.c" implementiert ist. Ein großer Teil dieser Datei kümmert sich darum, Kommandozeilenargumente zu verarbeiten und die VM-Konfiguration vorzubereiten, etwa die RAM-Größe des Gasts (siehe die Funktion "kvm_cmd_run_init()").

Als Teil diese Initialisierung wird auch die Funktion kvm__init() (in "kvm.c") aufgerufen. Dort wird die Device-Datei "/dev/kvm" geöffnet, die als Schnittstelle zum jeweiligen Userspace-Tool dient, sei es Kvmtool oder Qemu. Zur Kommunikation werden Ioctls, wie Sie in Listing 1 sehen können.

Listing 1: kvm__init()

kvm->sys_fd = open(kvm->cfg.dev, O_RDWR);
if (kvm->sys_fd < 0) {
  /* Error handling is omitted for brevity  */
  ret = -errno;
  goto err_free;
}

ret = ioctl(kvm->sys_fd, KVM_GET_API_VERSION, 0);
if (ret != KVM_API_VERSION) {
  pr_err("KVM_API_VERSION ioctl");
  ret = -errno;
  goto err_sys_fd;
}

kvm->vm_fd = ioctl(kvm->sys_fd, KVM_CREATE_VM, KVM_VM_TYPE);
if (kvm->vm_fd < 0) {
  pr_err("KVM_CREATE_VM ioctl");
  ret = kvm->vm_fd;
  goto err_sys_fd;
}

Hier wird überprüft, ob der Kernel die passende KVM-API unterstützt und anschließend eine neue VM erzeugt. kvm__init() löst einige architekturspezifische Initialisierungen aus und reserviert den Speicher für den Gast. Schließlich lädt es das Kernel-Image in den Gastspeicher. Auf echter Hardware macht das ein Bootloader wie Grub und deshalb ist in der Datei "x86/kvm.c" der entsprechende Code zu finden, der das Boot-Protokoll für gepackte bzImage-Kernel emuliert. Die Funktion load_bzimage() lässt den Instruction Pointer dorthin zeigen, wo der Realmode-Initialisieruns-Code im Linux-Kernel zu finden ist:

kvm->arch.boot_selector = BOOT_LOADER_SELECTOR;
kvm->arch.boot_ip = BOOT_LOADER_IP + 0x200;

Die nächste Initialisierungsfunktion ist "kvm_cpu__arch_init()". Sie erzeugt und initialisiert alle virtuellen CPUs (vCPUs), auf denen ein Gast läuft. Die genaue Zahl lässt sich als Kommandozeilenparameter übergeben, sonst verwendet KVM einen Default-Wert. Die Funktion verwendet den KVM_CREATE_VCPU-Ioctl, um die KVM-vCPU-Kernelstrukturen anzulegen und zu initialisieren. Nach dem Mappen eines Speicherbereichs geht es weiter in builtin-run.c und der Funktion kvm_cmd_run_work() , die mit kvm_cpu_thread() einen Thread pro vCPU startet. Das ist ein dünner Wrapper um "kvm_cpu__start()", das wie beschrieben den KVM Main Loop implementiert.

Als erstes setzt "kvm_cpu__start()" die vCPU zurück. Dabei bekommen auf x86-Prozessoren die meisten Register eine Null zugewiesen. Ausnahmen sind der Instruction Pointer und der Stack Pointer, die mit Werten aus "kvm->arch.boot_*" initialisiert werden. Darüber hinaus legt "kvm_cpu__start()" die Signal-Handler für den vCPU-Thread fest. Kvmtool verwendet Unix-Realtime-Signale für das Lifecycle-Management der VM und SIGUSR1 zum Debugging. Listing 2 zeigt den (stark gekürzten) KVM Main Loop.

Listing 2: KVM Main Loop (gekürzt)

while (cpu->is_running) {
  if (cpu->paused) {
    kvm__notify_paused();
    cpu->paused = 0;
  }
  /* Some other checks */
kvm_cpu__run(cpu);

  switch (cpu->kvm_run->exit_reason) {
  case KVM_EXIT_UNKNOWN:
    break;
  case KVM_EXIT_DEBUG:
    /* Handle debugging */
  case KVM_EXIT_IO:
    /* Handle I/O access */
  case KVM_EXIT_MMIO:
    /* Handle memory-mapped I/O */
  case KVM_EXIT_INTR:
    /* Handle a signal */
  case KVM_EXIT_SHUTDOWN:
    /* Exit the loop */
  case KVM_EXIT_SYSTEM_EVENT:
    /* Complain and reboot */
  }
}

Zuerst prüft der Loop "cpu->paused" und ein paar andere Flags, die im Signal-Handler in Reaktion auf Lifecycle-Management-Events gesetzt werden. Dann ruft es "kvm_cpu__run()", das den KVM_RUN-Ioctl aufruft. Im Kernel schaltet KVM in den Guest-Mode und läuft bis zum nächste VM Exit als Gast weiter. Wenn dieser eintritt, schreibt KVM Daten in den reservierten Memory-Block und kehrt von dem Ioctl zurück. Der große Switch-Block im Listing 1 verarbeitet die verschiedenen Ursache des VM Exit, etwa ein Debug- oder ein I/O-Event. Im letzten Fall schaut sich Kvmtool den Memory-Block an, um festzustellen, um welche Port oder welche Speicheradresse es geht und ob sie gelesen oder beschrieben werden soll. Gegebenenfalls werden dann die passenden Emulationsfunktionen aufgerufen (siehe das "hw"-Verzeichnis). KVM_EXIT_INTR bedeutet, dass ein nicht verarbeitetes Signal existiert, und KVM_EXIT_SHUTDOWN zeigt an, dass der Gast herunterfährt und somit der Main Loop enden kann.

Ähnliche Artikel

comments powered by Disqus
Einmal pro Woche aktuelle News, kostenlose Artikel und nützliche ADMIN-Tipps.
Ich habe die Datenschutzerklärung gelesen und bin einverstanden.

Konfigurationsmanagement

Ich konfiguriere meine Server

  • von Hand
  • mit eigenen Skripts
  • mit Puppet
  • mit Ansible
  • mit Saltstack
  • mit Chef
  • mit CFengine
  • mit dem Nix-System
  • mit Containern
  • mit anderer Konfigurationsmanagement-Software

Ausgabe /2023