K3s und KubeVirt: Konvergente Infrastruktur auf Bare Metal
Zurück zum Blog

K3s und KubeVirt: Konvergente Infrastruktur auf Bare Metal

6 Min. Lesezeit
Lesen auf English

Zwei Welten, eine Plattform

In den vergangenen Jahren haben wir bei encircle360 zwei Infrastruktur-Strecken parallel betrieben. Auf der einen Seite unsere K3s-Cluster für Container-Workloads, wie wir sie in unserem K3s-Produktionsbericht beschrieben haben. Auf der anderen Seite dedizierte Hosts für virtuelle Maschinen -- sei es über libvirt, Proxmox oder in Einzelfällen schlicht QEMU auf der Kommandozeile. KubeVirt hat diese Trennung bereits aufgeweicht, wie wir in unseren Artikeln zu KubeVirt und KubeVirt 1.0 in der Praxis beschrieben haben. Aber die Hosts selbst blieben getrennt: VPS-Instanzen für Kubernetes, dedizierte Server für VMs.

Seit Herbst 2024 gehen wir einen Schritt weiter. Wir betreiben K3s und KubeVirt auf Bare-Metal-Servern mit NixOS -- eine konvergente Infrastruktur, die Container und VMs auf denselben physischen Maschinen vereint. Kein separater Hypervisor-Cluster mehr, kein zweiter Satz an Hosts für VM-Workloads. Eine Plattform für alles.

Warum Bare Metal?

Die Antwort ist simpel: KubeVirt braucht KVM, und KVM braucht Hardware-Virtualisierung. Auf VPS-Instanzen bei den gängigen Hostern ist Nested Virtualization entweder nicht verfügbar oder so langsam, dass sie für Produktions-VMs nicht in Frage kommt. Wer KubeVirt ernsthaft nutzen will, braucht Bare Metal mit aktivierter VT-x oder AMD-V.

Unsere aktuellen Nodes sind dedizierte Server mit jeweils 64 GB RAM, 8-Core-CPUs und NVMe-Storage. Das klingt nach viel Hardware für ein Team unserer Größe, ist aber genau der Punkt: Diese Maschinen ersetzen sowohl die bisherigen K3s-VPS-Instanzen als auch die separaten VM-Hosts. Die Konsolidierung auf weniger, leistungsfähigere Maschinen reduziert die Gesamtkosten und den operativen Aufwand.

Die Hardware-Anforderungen im Überblick:

  • CPU: VT-x/AMD-V zwingend erforderlich, idealerweise mit VT-d für Device Passthrough
  • RAM: Mindestens 32 GB pro Node, besser 64 GB -- VMs und Container teilen sich den Speicher
  • Storage: NVMe empfohlen für VM-Disk-I/O, zusätzliche SSDs für Longhorn-Replikation
  • Netzwerk: Mindestens 1 Gbit, für Live Migration zwischen Nodes idealerweise 10 Gbit

NixOS als Fundament

Unsere Bare-Metal-Hosts laufen auf NixOS, das wir seit 2022 als Host-Betriebssystem nutzen. In unserem NixOS-Einführungsartikel und dem Flakes-Folgeartikel haben wir die Grundlagen beschrieben. Für die konvergente Infrastruktur mussten wir die NixOS-Konfiguration um KVM, libvirt und die notwendigen Kernel-Module erweitern.

Das KubeVirt-Modul in unserer Flake-basierten Konfiguration sieht so aus:

# modules/kubevirt-host.nix
{ config, pkgs, ... }:

{
  # KVM und Virtualisierung aktivieren
  virtualisation.libvirtd.enable = true;
  virtualisation.libvirtd.qemu = {
    package = pkgs.qemu_kvm;
    ovmf.enable = true;
  };

  # Kernel-Module für KVM und Netzwerk
  boot.kernelModules = [
    "kvm-intel"    # oder kvm-amd je nach CPU
    "vhost_net"
    "br_netfilter"
    "tun"
  ];

  # /dev/kvm für Container-Runtime zugänglich machen
  services.udev.extraRules = ''
    KERNEL=="kvm", GROUP="kvm", MODE="0666"
  '';

  # Kernel-Parameter für Bridge-Networking und IP-Forwarding
  boot.kernel.sysctl = {
    "net.bridge.bridge-nf-call-iptables" = 1;
    "net.bridge.bridge-nf-call-ip6tables" = 1;
    "net.ipv4.ip_forward" = 1;
  };

  # Hugepages für VM-Performance (optional, aber empfohlen)
  boot.kernelParams = [
    "hugepagesz=2M"
    "hugepages=4096"
  ];

  # Zusätzliche Pakete auf dem Host
  environment.systemPackages = with pkgs; [
    qemu
    libvirt
    virtctl
    bridge-utils
  ];
}

Die entscheidenden Punkte: virtualisation.libvirtd.enable aktiviert den Libvirt-Daemon und sorgt dafür, dass KVM korrekt geladen wird. Die udev-Regel stellt sicher, dass /dev/kvm für alle Prozesse zugänglich ist -- ohne das kann KubeVirt keine VMs starten. Die Hugepages-Konfiguration reserviert Speicher für VMs mit vorhersagbarer Performance. In unserer Flake referenzieren wir dieses Modul neben dem bestehenden k3s-node.nix:

# Auszug aus flake.nix
k3s-bare-01 = nixpkgs.lib.nixosSystem {
  system = "x86_64-linux";
  modules = [
    ./hosts/k3s-bare-01/configuration.nix
    ./modules/base.nix
    ./modules/k3s-node.nix
    ./modules/kubevirt-host.nix
  ];
};

Ein nixos-rebuild switch auf den Bare-Metal-Nodes aktiviert KVM, lädt die Kernel-Module und macht die Maschine bereit für KubeVirt -- reproduzierbar und rollback-fähig. Wenn wir einen neuen Node hinzufügen, ist er nach dem Deployment der Flake-Konfiguration automatisch KubeVirt-fähig.

K3s mit KubeVirt Operator

K3s läuft auf den Bare-Metal-Nodes wie gewohnt als NixOS-Service. Für die KubeVirt-Installation haben wir von den einzelnen kubectl apply-Befehlen auf Helm umgestellt. Das gibt uns bessere Kontrolle über Konfiguration und Upgrades:

# KubeVirt Operator via Helm installieren
helm repo add kubevirt https://kubevirt.github.io/kubevirt-chart
helm repo update

helm install kubevirt kubevirt/kubevirt \
  --namespace kubevirt \
  --create-namespace \
  --version 1.4.0 \
  --set operator.image.tag=v1.4.0

# CDI für Image-Import
helm repo add cdi https://kubevirt.github.io/containerized-data-importer-chart
helm install cdi cdi/cdi \
  --namespace cdi \
  --create-namespace \
  --version 1.60.0

Die Helm-Installation erlaubt es uns, KubeVirt-Konfiguration als Values-Datei zu versionieren und über unsere bestehende Helmfile-Pipeline auszurollen. Nach der Installation prüfen wir, ob alle Komponenten laufen:

# KubeVirt-Status prüfen
kubectl get kubevirt -n kubevirt
kubectl get pods -n kubevirt

# KVM-Fähigkeit der Nodes verifizieren
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}: {.status.allocatable.devices\.kubevirt\.io/kvm}{"\n"}{end}'

Jeder Node sollte devices.kubevirt.io/kvm: "1k" als allocatable Resource melden. Wenn das fehlt, stimmt etwas mit der KVM-Konfiguration auf dem Host nicht -- in der Regel ein fehlendes Kernel-Modul oder falsche Berechtigungen auf /dev/kvm.

MetalLB: LoadBalancer-IPs auf Bare Metal

Auf VPS-Instanzen bei Cloud-Anbietern gibt es in der Regel einen externen LoadBalancer-Service. Auf Bare Metal existiert diese Integration nicht. Kubernetes-Services vom Typ LoadBalancer bleiben im Status Pending hängen, weil kein Controller eine externe IP zuweist.

MetalLB löst dieses Problem. Es implementiert den LoadBalancer-Service-Typ für Bare-Metal-Umgebungen, indem es IP-Adressen aus einem konfigurierbaren Pool zuweist und per ARP oder BGP im Netzwerk annonciert:

# MetalLB installieren
helm repo add metallb https://metallb.github.io/metallb
helm install metallb metallb/metallb \
  --namespace metallb-system \
  --create-namespace

Nach der Installation definieren wir einen IP-Pool und den Annoncierungsmodus:

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: default-pool
  namespace: metallb-system
spec:
  addresses:
    - 10.0.1.200-10.0.1.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: default
  namespace: metallb-system
spec:
  ipAddressPools:
    - default-pool

Damit erhalten Kubernetes-Services vom Typ LoadBalancer automatisch eine IP aus dem Pool. Das gilt gleichermaßen für Container-Services und für KubeVirt-VMs, die über einen Service exponiert werden. Traefik als Ingress Controller bekommt so seine externe IP, und auch SSH-Zugänge zu VMs lassen sich über LoadBalancer-Services elegant lösen.

Storage: Longhorn für die konvergente Plattform

Für Storage nutzen wir Longhorn, das sich auf unseren K3s-Clustern bereits bewährt hat. Auf Bare Metal spielt Longhorn seine Stärken besonders aus: Die NVMe-Disks der Nodes werden direkt genutzt, ohne die I/O-Virtualisierungsschicht eines Hosters dazwischen.

Longhorn repliziert Volumes über Nodes hinweg -- eine Voraussetzung für Live Migration von KubeVirt-VMs. Wenn eine VM von Node A auf Node B migriert wird, muss ihr Storage auf Node B bereits verfügbar sein. Mit Longhorn und einem Replikationsfaktor von 2 ist das automatisch gewährleistet.

Für Workloads, die maximale I/O-Performance brauchen -- etwa Datenbank-VMs --, nutzen wir alternativ den local-path-provisioner, den K3s mitbringt. Local-Path schreibt direkt auf die lokale NVMe-Disk, ohne Replikation. Das ist schneller, aber die VM ist an den Node gebunden und kann nicht live migriert werden. Die Wahl zwischen Longhorn und Local-Path treffen wir pro Workload.

Eine VM auf der konvergenten Plattform

Alles zusammen ergibt eine Plattform, auf der Container-Deployments und VMs nebeneinander existieren. Hier ein Beispiel für eine VM, die auf unserer Bare-Metal-Infrastruktur läuft:

apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
  name: build-agent-01
  namespace: vms
spec:
  running: true
  template:
    metadata:
      labels:
        kubevirt.io/vm: build-agent-01
        role: build-agent
    spec:
      domain:
        cpu:
          cores: 4
          model: host-passthrough
        memory:
          guest: "8Gi"
          hugepages:
            pageSize: "2Mi"
        devices:
          disks:
            - name: rootdisk
              disk:
                bus: virtio
            - name: cloudinitdisk
              disk:
                bus: virtio
          interfaces:
            - name: default
              masquerade: {}
      networks:
        - name: default
          pod: {}
      volumes:
        - name: rootdisk
          dataVolume:
            name: build-agent-01-root
        - name: cloudinitdisk
          cloudInitNoCloud:
            userData: |
              #cloud-config
              hostname: build-agent-01
              users:
                - name: ops
                  sudo: ALL=(ALL) NOPASSWD:ALL
                  ssh_authorized_keys:
                    - ssh-ed25519 AAAA...
              packages:
                - qemu-guest-agent
                - docker.io
                - git
              runcmd:
                - systemctl enable --now qemu-guest-agent
                - systemctl enable --now docker
  dataVolumeTemplates:
    - metadata:
        name: build-agent-01-root
      spec:
        source:
          http:
            url: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
        pvc:
          accessModes:
            - ReadWriteMany
          resources:
            requests:
              storage: 50Gi
          storageClassName: longhorn

Die VM nutzt host-passthrough für CPU, Hugepages für vorhersagbaren Speicher, Longhorn für replizierten Storage und Cloud-Init für automatische Provisionierung. Sie läuft neben unseren Container-Workloads auf demselben Node, teilt sich dasselbe Pod-Netzwerk und wird mit denselben Tools überwacht.

Warum das separate Hosts ersetzt

Die Konsolidierung auf eine Plattform bringt Vorteile, die über die reine Kostenersparnis hinausgehen:

Ein Betriebskonzept. Statt Kubernetes für Container und Proxmox oder libvirt für VMs zu betreiben, gibt es eine Plattform mit einer API, einem Monitoring-Stack und einem Deployment-Workflow. Das reduziert den Schulungsaufwand und die kognitive Last im Team.

Flexible Ressourcennutzung. Auf getrennten Hosts sind Ressourcen statisch zugewiesen. Ein VM-Host mit 64 GB RAM, auf dem nur 20 GB für VMs genutzt werden, verschwendet 44 GB. Auf der konvergenten Plattform nutzen Container den freien Speicher. Die Auslastung steigt, die Kosten pro Workload sinken.

Einheitliches Netzwerk. VMs und Container teilen sich das Kubernetes-Netzwerk. Services können direkt miteinander kommunizieren, ohne NAT-Hacks oder VPN-Tunnel zwischen getrennten Plattformen. MetalLB vergibt LoadBalancer-IPs an beide Welten gleichermaßen.

Deklarativ von oben bis unten. NixOS konfiguriert den Host deklarativ. Kubernetes-Manifeste definieren Container und VMs deklarativ. Helm und Helmfile orchestrieren die Deployments. Der gesamte Stack ist versioniert und reproduzierbar -- vom Kernel-Modul bis zum Cloud-Init-Skript der VM.

Grenzen und Kompromisse

Eine konvergente Plattform ist kein Allheilmittel. Die Komplexität der einzelnen Nodes steigt -- ein Bare-Metal-Node mit K3s, KubeVirt, Longhorn und MetalLB hat mehr bewegliche Teile als ein schlichter K3s-VPS. Wenn ein Node ausfällt, sind sowohl Container als auch VMs betroffen, nicht nur eine der beiden Welten.

KubeVirt auf K3s ist zudem eine Kombination, die weniger breit getestet ist als KubeVirt auf einem vollständigen Kubernetes mit OpenShift oder Rancher. Wir sind auf vereinzelte Randfälle gestoßen -- etwa bei der Konfiguration von Device-Plugins für KVM auf K3s, wo die Dokumentation dünn ist. Aber nichts, was sich nicht lösen ließ.

Die Entscheidung zwischen Longhorn und Local-Path pro Workload erfordert Disziplin. Wer alles auf Longhorn legt, bekommt Live Migration, aber zahlt mit I/O-Overhead. Wer alles auf Local-Path legt, bekommt Performance, aber verliert Flexibilität. Die richtige Balance zu finden, ist eine fortlaufende Aufgabe.

Fazit

K3s und KubeVirt auf Bare-Metal-NixOS-Hosts ergeben eine konvergente Infrastruktur, die für ein Team unserer Größe genau richtig ist. Keine separaten VM-Hosts mehr, keine zweite Verwaltungsoberfläche, kein doppeltes Monitoring. Container und VMs laufen nebeneinander auf denselben Maschinen, verwaltet mit denselben Werkzeugen, überwacht mit demselben Stack.

Der Weg dorthin war nicht trivial. NixOS muss KVM korrekt konfigurieren, MetalLB braucht einen sauberen IP-Plan, Longhorn muss für die Anforderungen von VM-Storage dimensioniert sein. Aber die Einzelteile sind alle ausgereift -- K3s ist ein bewiesener Kubernetes-Distribution, KubeVirt hat die 1.0 hinter sich, NixOS bringt die deklarative Grundlage. Die Kunst liegt in der Integration, und dafür hat sich die Investition gelohnt.

Wer Bare-Metal-Server betreibt und sowohl Container als auch VMs braucht, sollte diesen Ansatz in Betracht ziehen. Die Alternative -- zwei getrennte Plattformen parallel zu betreiben -- kostet auf Dauer mehr Geld und mehr Nerven als die einmalige Konsolidierung auf eine konvergente Plattform.

Teilen

Patrick Hütter

Geschrieben von

Patrick Hütter

Gründer & Software-Architekt

Softwarearchitekt, Ingenieur und Unternehmer. Patrick baut seit über einem Jahrzehnt Produkte und Plattformen — von Enterprise-Backends über Cloud-Native-Infrastruktur bis hin zu KI-gestützten Anwendungen. Als Gründer von encircle360 verbindet er technische Tiefe mit unternehmerischem Denken und treibt Open-Source-Projekte voran, die echten Impact haben.