containerd containerd

Runtime-Interna: cgroups, runc und wo containerd hineinpasst

Was einen Container tatsächlich isoliert und begrenzt – vom Kernel-Feature bis zum Daemon, mit dem Kubernetes spricht.

12 min Fortgeschritten

„Container“ beschreibt eine Menge von Linux-Kernel-Features, die zusammengeschaltet werden, und kein eigenes Kernel-Objekt. Diese Anleitung geht den Stack von unten nach oben durch: das Kernel-Feature, das Ressourcen begrenzt, das Werkzeug, das daraus einen Container zusammensetzt, und den Daemon, der dieses Werkzeug im großen Maßstab verwaltet.

cgroups: Ressourcenkontrolle auf Kernel-Ebene

Control Groups (cgroups) sind ein Linux-Kernel-Feature, das auf das Jahr 2008 zurückgeht, um Systemressourcen – CPU, Speicher, I/O – unter einer Menge von Prozessen zuzuteilen und zu begrenzen. Ein Ressourcentyp in diesem System heißt Controller (oder Subsystem), und die von ihm gesteuerten Prozesse sind in einer Hierarchie organisiert, die ein Dateisystem nachbildet: Eine cgroup ist ein Verzeichnis, und ein Prozess tritt ihr bei, indem seine PID in dieses Verzeichnis geschrieben wird.

Zwei Regeln bestimmen, wie cgroups zusammenspielen:

  • An eine einzelne Hierarchie können mehrere Controller angehängt sein, aber ein bestimmter Controller (etwa CPU) kann zu einem Zeitpunkt nur an eine Hierarchie gebunden sein.
  • Ein Prozess gehört innerhalb einer Hierarchie zu genau einer cgroup. Verschiebt man ihn in dieser Hierarchie in eine andere cgroup, wird er aus der alten entfernt; er ist nie gleichzeitig an zwei Stellen derselben Hierarchie.

cgroups gibt es in zwei Versionen, v1 und v2, die sich hauptsächlich darin unterscheiden, wie Hierarchien und Controller zueinander stehen. v2 vereint Controller unter einer einzigen Hierarchie, statt die flexiblere (und komplexere) Anordnung von v1 zu erlauben. Moderne Distributionen und Kubernetes verwenden standardmäßig v2.

Für sich genommen verwalten cgroups nur Ressourcenlimits. Sie sagen nichts über Dateisysteme, Image-Formate oder darüber, die Sicht eines Prozesses auf Netzwerk oder Prozessbaum zu abstrahieren – alles, was etwas eher wie einen isolierten Container wirken lässt als nur wie einen ressourcenbegrenzten.

runc: einen Container aus Kernel-Primitiven zusammensetzen

runc ist die Referenzimplementierung der OCI-Runtime-Spec: ein Low-Level-Werkzeug, dessen gesamte Aufgabe es ist, ein Dateisystem und eine Konfigurationsdatei in einen laufenden, isolierten Prozess zu verwandeln. Um einen Container auszuführen, braucht es zwei Dinge:

  • Ein Root-Dateisystem (rootfs): der Dateisysteminhalt des Containers, extrahiert aus einem Image.
  • Eine config.json: erzeugt mit runc spec, beschreibt alles darüber, wie der Prozess laufen soll – Linux Capabilities, Mount-Punkte, welche Namespaces erstellt oder geteilt werden, über cgroups anzuwendende Ressourcenlimits.
mkdir my-container && cd my-container
mkdir rootfs
# rootfs aus den exportierten Dateisystem-Schichten eines Images befüllen
runc spec
runc run my-container-id

Wenn du schon eine Kubernetes-Pod-Spec geschrieben hast, wirkt die Form der config.json vertraut: Capabilities, Mounts und Namespace-Konfiguration sind dieselben zugrunde liegenden Konzepte, die Kubernetes über seine eigene API bereitstellt, nur eine Ebene näher am Kernel. runc ist bewusst schmal gehalten: Es startet und stoppt den Prozess eines einzelnen Containers gemäß dieser Konfiguration. Es kennt weder das Pullen von Images noch einen dauerhaft laufenden Daemon und spricht überhaupt nicht mit Kubernetes.

containerd: runc im großen Maßstab verwalten

containerd ist der Daemon, der alles rund um runc erledigt: Images pullen und entpacken, die Speicher-Snapshots verwalten, in die diese Images extrahiert werden, laufende Container verfolgen und eine gRPC-API bereitstellen, die sowohl direkte Clients als auch Kubernetes (über CRI) nutzen können. Er ist die Komponente, mit der der kubelet tatsächlich spricht.

containerd führt den Prozess deines Containers nicht selbst aus, sondern delegiert das an einen Shim, einen kleinen langlebigen Prozess zwischen containerd und dem eigentlichen Prozess des Containers. Die Aufgabe des Shims ist es, den Container unabhängig von containerd selbst am Laufen zu halten, sodass ein Neustart oder Upgrade des containerd-Daemons nicht jeden laufenden Container auf dem Knoten beendet. containerd 1.x lieferte drei Shim-Implementierungen mit; containerd 2.0 hat die beiden ältesten entfernt, sodass der runc-basierte Shim v2 als einziger übrig bleibt, was ohnehin seit Jahren der Standard war.

kubelet ── CRI (gRPC) ──▶ containerd ── shim ──▶ runc ──▶ Container-Prozess
                              │
                              └── verwaltet Images, Snapshots, Speicher

Weil das eine Schichtenarchitektur ist, ist containerd nicht speziell auf runc festgelegt. Ein Shim kann eine völlig andere Ausführungs-Engine vorschalten, gVisor für stärkere Sandbox-Isolation, Kata Containers für Isolation auf VM-Niveau und so weiter, während containerds Image-Verwaltung, CRI-Implementierung und alles, was zu Kubernetes gewandt ist, unverändert bleibt.

Den gesamten Pfad zusammensetzen

Der Container eines Pods, Ende zu Ende: Der kubelet sendet eine CRI-Anfrage an containerd. containerd pullt das Image, falls es nicht schon vorhanden ist, entpackt es in einen Snapshot im Dateisystem des Knotens und übergibt an einen Shim. Der Shim ruft runc mit einer erzeugten config.json auf, die auf diesen Snapshot als rootfs zeigt. runc erstellt die Linux-Namespaces, wendet die cgroup-Limits aus den Ressourcen-Requests und -Limits des Pods an und startet den Prozess. Ab da überwacht der Shim den laufenden Prozess und meldet seinen Status über containerd zurück an den kubelet – genau das liest kubectl get pods letztlich aus.

Jede Schicht löst ein Problem: cgroups begrenzen Ressourcen, runc setzt aus einem rootfs und einer Konfiguration einen OCI-konformen Container zusammen, Shims halten diesen Container unabhängig vom Daemon beaufsichtigt, und containerd orchestriert das alles und stellt die API bereit, die Kubernetes braucht. Keines dieser Teile macht die Arbeit der anderen – genau das macht es möglich, runc gegen eine andere Low-Level-Runtime auszutauschen, ohne etwas oberhalb der Shim-Schicht anzufassen.

Beliebte Anleitungen