Container sind heute die Standardform, Anwendungen auszuliefern. Hinter dem einfachen Befehl docker run stehen Kernel-Funktionen, die es seit Jahrzehnten gibt. Dieser Artikel erklärt, wie Container technisch funktionieren – ohne die üblichen Bilder von „leichtgewichtigen virtuellen Maschinen“, die mehr verwirren als helfen.
Die entscheidende Abgrenzung
Eine virtuelle Maschine bringt einen eigenen Kernel mit und täuscht vollständige Hardware vor. Ein Container teilt den Kernel des Wirtsystems und nutzt ihn nur anders. Daraus folgt alles Weitere:
- Start in Millisekunden statt Sekunden – es muss kein Betriebssystem gebootet werden.
- Wenige Megabyte Speicher statt Gigabyte – kein zusätzlicher Kernel, keine zusätzlichen Systemdienste.
- Keine andere Betriebssystemfamilie: Ein Linux-Container läuft nur auf einem Linux-Kernel. Windows-Container brauchen einen Windows-Kernel.
- Der Kernel ist gemeinsame Angriffsfläche: Eine Lücke im Kernel betrifft alle Container auf dem Wirt. Deshalb ist die Isolation schwächer als bei einer virtuellen Maschine.
Die zwei tragenden Kernel-Funktionen
Namespaces: die Sicht einschränken
Ein Namespace gibt einem Prozess eine eigene Sicht auf eine Systemressource. Der Prozess sieht nur seine eigene – alles andere existiert für ihn nicht.
# Namespace-Arten und was sie trennen
PID Prozess-IDs Container sieht sich als PID 1
NET Netzwerkschnittstellen, Adressen, Ports
MNT Einhängepunkte des Dateisystems
UTS Hostname und Domainname
IPC Zwischenprozess-Kommunikation (Shared Memory)
USER Benutzer- und Gruppen-IDs (root im Container != root am Wirt)
CGROUP Sicht auf die cgroup-Hierarchie
TIME Systemzeit (seit Linux 5.6)
# Namespaces eines laufenden Prozesses ansehen
ls -l /proc/<pid>/ns/
# lrwxrwxrwx ... net -> 'net:[4026532500]'
# lrwxrwxrwx ... pid -> 'pid:[4026532503]'
# Gleiche Nummer in eckigen Klammern = gleicher Namespace
# Selbst einen Namespace betreten (nur zum Ansehen!)
sudo nsenter -t <pid> -n ip addr
sudo nsenter -t <pid> -p ps aux
# Einen einfachen Container von Hand bauen - ohne Docker
sudo unshare --pid --net --mount --uts --ipc --fork --mount-proc bash
hostname mein-container
ps aux # zeigt nur die eigene Shell
ip addr # nur eine Loopback-Schnittstelle
Dieser letzte Aufruf ist der lehrreichste: Man befindet sich in einem „Container“, ohne dass Docker installiert ist. Genau das macht eine Container-Laufzeit im Kern.
Cgroups: die Nutzung begrenzen
Namespaces regeln, was ein Prozess sieht. Cgroups regeln, wie viel er verbrauchen darf – Prozessorzeit, Speicher, Ein-/Ausgabe, Anzahl Prozesse.
# Cgroup-Version pruefen (v2 ist der moderne Standard)
stat -fc %T /sys/fs/cgroup/
# cgroup2fs -> v2
# tmpfs -> v1
# Cgroups v2: Hierarchie ansehen
systemd-cgls # Baum aller Cgroups
systemd-cgtop # Verbrauch je Cgroup (wie top)
cat /sys/fs/cgroup/system.slice/cpu.max
# Grenzen eines Containers auslesen
cat /sys/fs/cgroup/.../memory.max
cat /sys/fs/cgroup/.../cpu.max
cat /sys/fs/cgroup/.../memory.current
# Was ein Prozess verbraucht
cat /proc/<pid>/cgroup
# cgroup v1 (aeltere Systeme): Verzeichnisse je Ressource
ls /sys/fs/cgroup/memory/
ls /sys/fs/cgroup/cpu/
Das Dateisystem: Schichten statt Kopien
Ein Container-Image besteht aus einer Reihe schreibgeschützter Schichten (Layers). Beim Start legt die Laufzeit eine dünne, beschreibbare Schicht obenauf. Diese Technik heißt Overlay oder Union Mount:
Container-Sicht (von oben nach unten):
[ beschreibbar ] <- Aenderungen des Containers
[ Schicht 3 ] <- z.B. Anwendungscode (schreibgeschuetzt)
[ Schicht 2 ] <- z.B. Abhaengigkeiten (schreibgeschuetzt)
[ Schicht 1 ] <- Basissystem (schreibgeschuetzt)
# Folge: Zehn Container aus demselben Image teilen sich
# alle unteren Schichten auf der Platte. Nur die Aenderungen
# kosten zusaetzlich Speicher.
# Aenderungen eines laufenden Containers ansehen
docker diff mein-container
# A /var/log/app.log hinzugefuegt
# C /etc/nginx/nginx.conf geaendert
# D /tmp/alt.txt geloescht
# Konsequenz fuer die Praxis:
# - Daten in der beschreibbaren Schicht sind weg, wenn der Container entfernt wird
# - Datenbanken und Uploads gehoeren in ein Volume, nicht in die Schicht
# - Das Image ist unveraenderlich -> derselbe Container startet immer gleich
Capabilities: Rechte aufteilen
Das klassische Unix-Modell kennt nur „root“ oder „nicht root“. Linux teilt die Rechte des Benutzers root in über 40 einzelne Capabilities auf. Eine Container-Laufzeit gibt standardmäßig nur einen Teil davon.
# Capabilities eines Prozesses ansehen
getpcaps <pid>
capsh --print
# Wichtige Capabilities
# CAP_NET_BIND_SERVICE Ports unter 1024 binden
# CAP_SYS_ADMIN umfassende Systemverwaltung (sehr gefaehrlich!)
# CAP_SYS_MODULE Kernelmodule laden (sehr gefaehrlich!)
# CAP_CHOWN Besitzer von Dateien aendern
# CAP_SETUID / CAP_SETGID Benutzer-ID wechseln
# Gefaehrliche Capabilities abnehmen (Docker)
# docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
# In Kubernetes
# securityContext:
# capabilities:
# drop: ["ALL"]
# add: ["NET_BIND_SERVICE"]
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 10001
Ein Container, der mit --privileged läuft, bekommt alle Capabilities und den Zugriff auf alle Geräte – faktisch keine Isolation mehr. In Sicherheitsprüfungen ist das einer der häufigsten und schwerwiegendsten Befunde.
Der OCI-Standard
Container sind heute standardisiert. Die Open Container Initiative legt drei Spezifikationen fest:
- Image-Spezifikation: Wie ein Container-Image aufgebaut ist (Schichten, Konfiguration). Deshalb kann ein mit Docker gebautes Image in Podman, containerd oder Kubernetes laufen.
- Runtime-Spezifikation: Wie eine Laufzeit einen Container erstellt – die Aufgabe von
runc. - Distribution-Spezifikation: Wie Images zwischen Registry und Laufzeit übertragen werden.
Erst diese Standards haben das Ökosystem möglich gemacht: Bauen, Verteilen und Ausführen sind getrennte Aufgaben mit klaren Schnittstellen.
Der Weg des docker run
Was zwischen Befehl und laufendem Prozess passiert, in sieben Schritten:
- Client sendet die Anfrage an den Daemon.
- Image wird geprüft: lokal vorhanden oder aus der Registry geladen (mit Prüfung des Inhaltsverzeichnisses).
- Schichten werden zu einem Overlay-Dateisystem verbunden, eine beschreibbare Schicht kommt obenauf.
- Namespaces werden erzeugt (PID, NET, MNT, UTS, IPC, USER).
- Cgroups werden angelegt und Grenzen gesetzt.
- Capabilities werden reduziert, das Sicherheitsprofil (AppArmor/SELinux/seccomp) wird gesetzt.
runcstartet den Prozess als PID 1 im neuen Namespace und beendet sich selbst.
Worauf es in der Praxis ankommt
- Der Kernel ist die Grenze, nicht die Laufzeit. Zwei Container auf demselben Wirt sind weniger getrennt als zwei virtuelle Maschinen. Für unterschiedliche Vertrauensstufen braucht es getrennte Wirte oder zusätzliche Isolation (gVisor, Kata Containers).
- Kein
--privileged, kein Host-Netzwerk, kein Docker-Socket im Container. Diese drei Einstellungen heben die Isolation praktisch auf. Ein Container mit Zugriff auf/var/run/docker.sockkann beliebige Container mit/des Wirts starten. - Als Nicht-Root betreiben. Rootless-Modi oder ein eigener Benutzer im Container. Ein Prozess, der im Container als root läuft, ist über eine Lücke schneller am Wirt.
- Daten aus dem Container heraus. Volumes für alles, was bleiben muss. Die beschreibbare Schicht ist für Daten der falsche Ort.
- Ein Prozess je Container. Signale, Protokolle und Neustartlogik setzen einen Hauptprozess voraus, der als PID 1 läuft und Signale verarbeitet.
- Rollen kennen. Ein Container ist keine Sicherheitsgrenze für feindlichen Code – eine virtuelle Maschine ist es. In Mandantenumgebungen, in denen fremder Code läuft, ist das entscheidend.
Verwandte Grundlagen: Docker-Grundlagen, Container, OCI-Standard, Kubernetes.