CI-Runner in der microVM: Docker-Builds mit Kata Containers auf Kubernetes
Zurück zum Blog

CI-Runner in der microVM: Docker-Builds mit Kata Containers auf Kubernetes

10 Min. Lesezeit
Lesen auf English

Architektur-Diagramm: CI-Runner in Kata-microVMs — VM-Grenze, dind-Verkabelung und die fünf Stolpersteine

Warum CI-Jobs nicht auf dem Host-Kernel laufen sollten

Ein CI-Runner ist aus Sicherheitssicht ein unangenehmes Werkstück: Er führt Code aus, der sich schnell ändert, von vielen Menschen stammt und über Abhängigkeiten halbe Registries nachlädt — und für Docker-Builds braucht er dazu einen Docker-Daemon, dessen Container-Chart hartkodiert privileged: true setzt. Auf einem klassischen Kubernetes-Node teilt sich dieser Job den Kernel mit allem anderen, was auf dem Node läuft. Ein einziger Kernel-Exploit in einem Build-Schritt, und die Grenze zwischen CI-Job und Cluster-Node ist Geschichte.

Wir hatten dieses Problem bisher mit Sysbox entschärft: eigene User-Namespaces, virtualisiertes /proc und /sys, dockerd de facto unprivilegiert auf Host-Ebene. Das ist deutlich besser als nacktes privileged — aber der Kernel bleibt geteilt. Namespace-Härtung verschiebt die Angriffsfläche, sie beseitigt sie nicht.

Kata Containers geht den anderen Weg: Jeder Pod bekommt eine eigene leichtgewichtige virtuelle Maschine mit eigenem Gast-Kernel. Ein Container-Escape endet dann nicht am Host-Kernel, sondern an der Hardware-Virtualisierungsgrenze von KVM. Genau das wollten wir für unsere Gitea-Actions-Runner.

Die Integration: eine RuntimeClass, kein neues Deployment-Modell

Das Schöne an Kata ist, wie unspektakulär die Kubernetes-Seite ist. Kata registriert sich als zusätzliche containerd-Runtime neben runc, und im Cluster gibt es dafür eine RuntimeClass:

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: kata
handler: kata
overhead:
  podFixed:
    memory: "160Mi"
    cpu: "250m"
scheduling:
  nodeSelector:
    example.com/kata: "enabled"

Workloads wählen die Runtime mit einer einzigen Zeile: runtimeClassName: kata in der Pod-Spec. Das overhead-Feld sorgt dafür, dass der Scheduler die VM-Kosten pro Pod einkalkuliert, und der nodeSelector verhindert, dass Pods auf Nodes landen, auf denen Kata gar nicht installiert ist — das Label setzen wir zusammen mit der Runtime-Installation, beides kommt bei uns deklarativ aus derselben NixOS-Konfiguration und läuft über denselben GitOps-Workflow wie alles andere in unseren Clustern.

Auf der containerd-Seite ist ein Detail wichtig, wenn man privilegierte Workloads wie Docker-in-Docker plant (siehe CRI-Plugin-Konfiguration):

[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.kata]
  runtime_type = "io.containerd.kata.v2"
  privileged_without_host_devices = true

privileged_without_host_devices sorgt dafür, dass ein privilegierter Container zwar innerhalb seiner VM alle Rechte hat, die Gerätedateien des Hosts aber draußen bleiben — die Kata-Dokumentation empfiehlt genau diese Einstellung für alle Kata-Runtimes. Das privileged: true des dind-Sidecars gilt damit nur noch für die Gast-VM — genau die Semantik, die man will.

Docker-in-Docker in der VM: die fünf Stolpersteine

So weit die Theorie. In der Praxis lief unser Runner-Pod zwar sofort unter Kata — aber der Docker-Daemon im Pod brauchte fünf Anläufe, bis der erste Build durchlief. Die Reihenfolge hier ist die Reihenfolge, in der uns die Probleme begegnet sind. Wer CI-Runner auf Kata umstellt, wird vermutlich jedem einzelnen davon begegnen.

1. overlay2 funktioniert nicht auf virtiofs. Kata reicht das Pod-Dateisystem und Filesystem-Volumes per virtiofs in die VM. Dockers Standard-Storage-Driver overlay2 kann darauf kein Overlay stapeln — dockerd scheitert beim Start mit failed to mount overlay: invalid argument. Die Lösung ist fuse-overlayfs als Storage-Driver. Haken: Das offizielle docker:dind-Image enthält das Binary nicht (nur die rootless-Variante). Wir bauen deshalb ein minimal abgeleitetes Image — ein Zweizeiler-Dockerfile, das fuse-overlayfs nachinstalliert.

2. /dev/fuse existiert im Container nicht. Der Kata-Gast-Kernel hat FUSE eingebaut (virtiofs basiert selbst darauf), aber Kata bestückt Container bewusst nicht mit Gast-Gerätedateien — die Kehrseite von privileged_without_host_devices. fuse-overlayfs bricht mit fuse: device not found ab. Da der Container innerhalb der VM privilegiert ist, hat er CAP_MKNOD: ein mknod /dev/fuse c 10 229 vor dem Entrypoint löst das Problem dauerhaft.

3. Erweiterte Attribute brauchen ein virtiofsd-Flag. Der erste Image-Pull scheiterte mitten im Layer-Extrahieren: lsetxattr ... security.capability: operation not supported. Images, die Dateien mit File-Capabilities enthalten — und das sind viele CI-Basis-Images — brauchen xattr-Unterstützung auf dem Storage-Pfad. virtiofsd hat die standardmäßig aus Performance-Gründen deaktiviert; ein --xattr in den virtio_fs_extra_args der Kata-Konfiguration behebt es.

4. Die MTU-Falle. Der Klassiker in neuem Gewand: Läuft das Pod-Netzwerk über ein Overlay mit kleiner MTU (bei uns 1280), erbt die VM diese MTU auf ihrem Interface — aber die Docker-Bridge in der VM steht auf 1500. Build-Container können dann TCP-Verbindungen öffnen, aber große Pakete verschwinden im Blackhole: apk fetch startet und hängt. Ein dockerd --mtu=1240 beendet den Spuk.

5. Unix-Sockets lassen sich zwischen Pod-Containern nicht teilen. Das war die härteste Lektion, weil gleich drei Mechanismen nacheinander scheitern: virtiofs kann keine Unix-Sockets transportieren. EmptyDir-Volumes — auch mit medium: Memory — materialisiert Kata als separate Instanz pro Mount, sie teilen also nichts zwischen Containern. Und subPath-Mounts löst das Kubelet auf dem Host auf, wo der Gast-Inhalt nicht existiert. Der klassische Trick, dockerds Socket per Volume in den Runner-Container zu reichen, ist unter Kata schlicht unmöglich.

Unsere finale Architektur trennt deshalb zwei Wege: Der Runner-Prozess spricht mit dockerd über TLS auf pod-lokalem TCP (Port 2376 — der dind-Entrypoint erzeugt die Zertifikate selbst, wenn man seinen TLS-Default-Modus nicht abschaltet). Die Zertifikate sind gewöhnliche Dateien und liegen auf einer kleinen geteilten PVC — der einzige Volume-Typ, der unter Kata tatsächlich zwischen Pod-Containern geteilt wird. Die Job-Container dagegen bekommen den echten Unix-Socket per dockerd-seitigem Bind-Mount: dockerd löst den Quellpfad in seinem eigenen Namespace auf, der Mount ist ein Kernel-Bind komplett innerhalb der VM. Kein unverschlüsselter TCP-Listener, keine Socket-Verrenkungen, und der ohnehin als deprecated markierte Klartext-Docker-API-Port bleibt aus.

Sicherheitsbilanz

Am Ende lohnt der ehrliche Vergleich mit dem Ausgangszustand. Gegenüber Sysbox gewinnen wir die Hardware-Grenze: Ein Kernel-Exploit aus einem Build-Job kompromittiert den Gast-Kernel der Wegwerf-VM, nicht den Node. Das hartkodierte privileged des dind-Containers ist auf die VM begrenzt, Host-Devices sind ausgesperrt, der ServiceAccount-Token bleibt unmontiert, und eine Default-Deny-NetworkPolicy schottet den Runner-Namespace zusätzlich ab — eingehenden Traffic braucht ein CI-Runner ohnehin nicht.

Ebenso ehrlich: Die relevante Angriffsfläche verschiebt sich zu QEMU und virtiofsd, die als Host-Prozesse pro Pod laufen. Deshalb ist uns wichtig, dass beide aus der Paketverwaltung kommen und mit jedem regulären System-Update frisch sind — nicht als eingefrorene Binaries, die jahrelang in /opt altern. Und das --xattr-Flag aus Stolperstein 3 vergrößert die Parsing-Oberfläche von virtiofsd minimal; das nehmen wir bewusst in Kauf.

Betrieb und Updates

Im Alltag ist der Unterschied zum runc-Betrieb kleiner als erwartet. Der Gast-Kernel wird mit dem Kata-Paket aktualisiert und fließt über denselben Update-Kanal wie alles andere; laufende Pods behalten ihre VM bis zum nächsten Pod-Neustart, ein Host-Reboot ist dafür nicht nötig. Die Faustregel, die wir uns notiert haben: Nach jedem Kata-Update einmal die langlebigen Kata-Pods durchrollen. Der Ressourcen-Overhead liegt bei rund 160 MiB pro Pod plus wenige Sekunden VM-Start — für CI-Runner, die ohnehin minutenlange Jobs fahren, ist beides irrelevant.

Erfahrungen

Die Kubernetes-Seite ist trivial, die Docker-Seite nicht. RuntimeClass anlegen, eine Zeile in der Pod-Spec — das war ein Nachmittag. Die fünf Stolpersteine oben haben ein Vielfaches davon gekostet, weil jeder einzelne erst nach dem vorherigen sichtbar wurde. Wer den Umstieg plant, sollte diese Liste als Checkliste lesen, nicht als Anekdote.

Volume-Semantik unter Kata ist eine eigene Disziplin. Die Annahme, dass sich Pod-Container ein emptyDir teilen, sitzt tief — unter Kata ist sie falsch. Wer bestehende Multi-Container-Pods migriert, sollte jede Volume-Interaktion zwischen Containern einzeln durchdenken: Dateien über PVCs funktionieren, Sockets gar nicht, und subPath ist tabu.

Ein echter Build ist der einzige valide Test. Unser Smoke-Test — Image bauen, Container starten, Cache-Build — wurde erst grün, nachdem alle fünf Probleme gelöst waren. Danach lief die erste echte Pipeline (ein Keycloak-Image-Build mit Push in die interne Registry) im ersten Anlauf durch. kata-runtime check sagt nur, dass die VM starten kann; ob der Workload darin lebt, zeigt nur der Workload selbst.

Fazit

Kata Containers hält, was es verspricht: CI-Jobs mit privilegiertem Docker-Daemon laufen jetzt hinter einer Hardware-Grenze statt auf dem geteilten Kernel, und im Kubernetes-Alltag fühlt sich das exakt so an wie vorher — eine RuntimeClass, sonst nichts. Der Preis ist keine Performance und kein Betriebsaufwand, sondern eine Handvoll sehr spezifischer Integrationsprobleme rund um Docker-in-Docker, die man einmal lösen und dokumentieren muss. Genau dafür ist dieser Artikel da.

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.