Docker-Sicherheit härten: Rootless-Modus, Seccomp, AppArmor auf einem VPS

14 Min. Lesezeit·Matthieu·vpscontainersapparmorseccomprootless-dockersecuritydocker|

Sieben Härtungsschichten für Docker auf einem VPS. Jeder Abschnitt erklärt die Bedrohung, zeigt die Lösung mit CLI- und Compose-Syntax und überprüft das Ergebnis.

Die Standardkonfiguration von Docker tauscht Sicherheit gegen Bequemlichkeit. Container laufen als root auf dem Host. Alle 14 Linux Capabilities bleiben aktiv. Seccomp blockiert nur 44 von über 300 Syscalls. Der Netzwerkverkehr zwischen Containern fließt ungehindert.

Auf einem VPS ist das relevanter als auf einer lokalen Entwicklungsmaschine. Sie teilen sich einen physischen Host mit anderen Mietern. Ein Container-Ausbruch bedeutet, dass ein Angreifer als root auf dem Kernel landet, der dem Hypervisor zugewandt ist. Jede Härtungsschicht, die Sie hinzufügen, verkleinert den Schadensradius.

Dieses Tutorial behandelt sieben Härtungsmaßnahmen. Jeder Abschnitt erklärt die Bedrohung, die er verhindert, zeigt die Implementierung (sowohl docker run-Flags als auch Compose-Syntax) und enthält einen Verifikationsschritt. Wir haben jeden Befehl auf Ubuntu 24.04 mit Docker Engine 29.x getestet.

Voraussetzungen: Ein VPS mit Debian 12 oder Ubuntu 24.04 und installierter Docker Engine. SSH-Zugang als Nicht-root-Benutzer mit sudo. Falls Sie den Host selbst noch nicht abgesichert haben, beginnen Sie zuerst mit unserem Linux VPS Security: Bedrohungen, Schutzschichten und Hardening-Guide. Bei Docker-Firewall-Problemen siehe Docker umgeht UFW: 4 getestete Lösungen für Ihren VPS.

Dieser Artikel ist Teil der Docker in Produktion auf einem VPS: Was schiefgeht und wie Sie es beheben Serie.

Wie richte ich Rootless Docker auf Ubuntu 24.04 oder Debian 12 ein?

Rootless Docker führt den Daemon und alle Container unter einem regulären Benutzerkonto aus, nicht als root. Wenn ein Angreifer aus einem Container ausbricht, landet er als unprivilegierter Benutzer auf dem Host. Kein root-Zugang. Von allen Maßnahmen in diesem Guide hat diese die größte Wirkung.

Rootless Docker installieren

Installieren Sie die benötigten Pakete. Das Paket uidmap stellt newuidmap und newgidmap bereit, die das Mapping von Subordinate-UIDs/-GIDs übernehmen:

sudo apt-get update && sudo apt-get install -y uidmap docker-ce-rootless-extras

Prüfen Sie, ob Ihr Benutzer mindestens 65.536 Subordinate-UIDs und -GIDs besitzt:

grep "^$(whoami):" /etc/subuid
grep "^$(whoami):" /etc/subgid

Die Ausgabe sollte etwa deploy:100000:65536 lauten. Falls die Einträge fehlen, fügen Sie sie hinzu:

sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $(whoami)

Stoppen Sie den systemweiten Docker-Daemon. Im Rootless-Modus wird er nicht benötigt:

sudo systemctl disable --now docker.service docker.socket

Führen Sie das Rootless-Setup-Tool als regulärer Benutzer aus (nicht als root):

dockerd-rootless-setuptool.sh install

Das Skript gibt Umgebungsvariablen aus, die Sie setzen müssen. Fügen Sie diese zu Ihrem Shell-Profil hinzu:

echo 'export PATH=/usr/bin:$PATH' >> ~/.bashrc
echo 'export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock' >> ~/.bashrc
source ~/.bashrc

Aktivieren Sie Linger, damit der Rootless-Daemon beim Systemstart startet, nicht erst bei der Anmeldung:

sudo loginctl enable-linger $(whoami)

Rootless-Modus verifizieren

docker context use rootless
docker run --rm hello-world

Prüfen Sie, dass der Daemon-Prozess unter Ihrem Benutzer läuft, nicht als root:

ps aux | grep dockerd

Die Ausgabe sollte Ihren Benutzernamen zeigen, nicht root. Bestätigen Sie außerdem, dass Docker Info rootless meldet:

docker info --format '{{.SecurityOptions}}'

Sie sollten rootless in der Liste sehen.

Wenn Rootless Docker Probleme verursacht

Rootless-Modus hat echte Einschränkungen. Wenn Sie diese kennen, sparen Sie Stunden beim Debugging.

Einschränkung Ursache Workaround
Ports unter 1024 nicht bindbar Nicht-root-Benutzer können keine privilegierten Ports binden sysctl net.ipv4.ip_unprivileged_port_start=0 setzen oder einen Reverse Proxy auf dem Host nutzen
Berechtigungsfehler bei Bind Mounts Host-Dateien im Besitz von root sind für die remappte UID nicht zugänglich Eigentümer auf Ihren Benutzer ändern oder Named Volumes verwenden
Langsameres Overlay-Dateisystem Rootless verwendet fuse-overlayfs statt nativem overlay2 Den Overhead akzeptieren (5-15 % bei I/O-intensiven Workloads) oder natives overlay2 mit --privileged nutzen (macht den Zweck zunichte)
Kein --net=host Rootless-Networking nutzt slirp4netns oder pasta, nicht den Host-Netzwerkstack Port-Mapping (-p) verwenden. Für bessere Performance pasta als Netzwerktreiber installieren
ping schlägt in Containern fehl CAP_NET_RAW ist eingeschränkt slirp4netns >= 0.4.0 installieren oder pasta verwenden

Hinweis zur Netzwerk-Performance: Standardmäßig nutzt Rootless Docker slirp4netns für das Netzwerk, was NAT-Overhead verursacht. Der pasta-Treiber kopiert die Host-Netzwerkkonfiguration in den Container-Namespace ohne NAT und bietet besseren Durchsatz. Auf Debian 12 und Ubuntu 24.04 installieren Sie ihn mit:

sudo apt-get install -y passt

Docker erkennt pasta automatisch, wenn es installiert ist.

Wann sollte ich User Namespace Remapping statt Rootless Docker verwenden?

User Namespace Remapping (userns-remap) bildet UID 0 in Containern auf eine unprivilegierte UID auf dem Host ab. Anders als bei Rootless Docker läuft der Daemon selbst weiterhin als root. Das bedeutet: Sie behalten die volle Docker-Funktionalität (privilegierte Ports, Host-Networking, natives overlay2) und verhindern trotzdem, dass Container-root gleich Host-root ist.

Wählen Sie userns-remap, wenn der Rootless-Modus Ihren Workload beeinträchtigt, Sie aber dennoch UID-Isolation wünschen. Wählen Sie Rootless, wenn Sie mit dessen Einschränkungen leben können.

Feature Rootless Docker userns-remap Standard Docker
Daemon läuft als Benutzer Root Root
Container-root = Host-root Nein Nein Ja
Privilegierte Ports Workaround nötig Funktioniert Funktioniert
--net=host Nein Ja Ja
Storage-Treiber fuse-overlayfs overlay2 overlay2
Setup-Aufwand Mittel Gering Keiner

userns-remap konfigurieren

Erstellen Sie den Benutzer dockremap oder verwenden Sie die Abkürzung default, die ihn automatisch erstellt:

sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
  "userns-remap": "default"
}
EOF

sudo systemctl restart docker

Docker erstellt den Benutzer dockremap und fügt Subordinate-UID/GID-Bereiche zu /etc/subuid und /etc/subgid hinzu.

Verifizieren Sie die Funktion:

sudo ls -ld /var/lib/docker/

Sie sollten ein neues Unterverzeichnis sehen, das nach dem remappten UID-Bereich benannt ist, z. B. /var/lib/docker/100000.100000/. Die genaue Zahl hängt vom Subordinate-UID-Bereich ab, der dem Benutzer dockremap in /etc/subuid zugewiesen ist.

Starten Sie einen Container und prüfen Sie die Prozess-UID auf dem Host:

docker run -d --name test-userns nginx:alpine
ps aux | grep nginx

Der nginx-Prozess sollte eine hohe UID anzeigen (passend zur ersten Zahl aus /etc/subuid für dockremap), nicht 0.

Aufräumen:

docker rm -f test-userns

Hinweis zu Volume-Eigentümerschaft: Bind-gemountete Dateien im Besitz von Host-root (UID 0) erscheinen im Container als nobody, weil UID 0 auf einen anderen Bereich abgebildet wird. Verwenden Sie Named Volumes oder ändern Sie die Eigentümerschaft der Dateien auf die remappte UID mit chown.

Wie entferne ich Linux Capabilities aus einem Docker-Container?

Docker gewährt Containern standardmäßig 14 Linux Capabilities. Jede Capability ist eine Kernel-Berechtigung, die ein Angreifer nach einem Container-Ausbruch oder innerhalb eines kompromittierten Containers ausnutzen kann. Alle Capabilities zu entfernen und nur die benötigten zurückzufügen, verkleinert die Angriffsfläche.

Standard-Docker-Capabilities

Capability Erlaubte Aktion Behalten oder entfernen?
CAP_CHOWN Datei-Eigentümerschaft ändern Entfernen, wenn nicht benötigt
CAP_DAC_OVERRIDE Lese-/Schreibberechtigungen umgehen Entfernen, wenn nicht benötigt
CAP_FOWNER Berechtigungsprüfung auf Datei-Eigentümer umgehen Entfernen, wenn nicht benötigt
CAP_FSETID setuid/setgid-Bits setzen Entfernen
CAP_KILL Signale an beliebige Prozesse senden Entfernen, wenn nicht benötigt
CAP_SETGID Prozess-GID ändern Für die meisten Anwendungen behalten
CAP_SETUID Prozess-UID ändern Für die meisten Anwendungen behalten
CAP_SETPCAP Prozess-Capabilities ändern Entfernen
CAP_NET_BIND_SERVICE Ports unter 1024 binden Behalten, wenn Port 80/443 gebunden wird
CAP_NET_RAW Raw Sockets nutzen (Pakete erzeugen) Entfernen, es sei denn Sie brauchen ping/traceroute
CAP_SYS_CHROOT chroot verwenden Entfernen
CAP_MKNOD Gerätedateien erstellen Entfernen
CAP_AUDIT_WRITE In Kernel-Audit-Log schreiben Entfernen, wenn nicht benötigt
CAP_SETFCAP Datei-Capabilities setzen Entfernen

Alle entfernen, benötigte zurückfügen

CLI-Syntax:

docker run -d \
  --cap-drop ALL \
  --cap-add CHOWN \
  --cap-add NET_BIND_SERVICE \
  --cap-add SETUID \
  --cap-add SETGID \
  --name hardened-nginx \
  nginx:alpine

Nginx benötigt CHOWN, weil sein Entrypoint-Skript beim Start die Eigentümerschaft von Cache-Verzeichnissen ändert. Ohne diese Capability beendet sich der Container sofort mit dem Fehler chown: Operation not permitted.

Compose-Syntax:

services:
  web:
    image: nginx:alpine
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - NET_BIND_SERVICE
      - SETUID
      - SETGID

Verifizieren, dass Capabilities entfernt wurden

docker exec hardened-nginx sh -c 'cat /proc/1/status | grep Cap'

Vergleichen Sie die CapEff (Effective Capabilities) Bitmaske. Mit allen entfernten und vier zurückgefügten ist der Wert deutlich niedriger als der Standardwert 00000000a80425fb.

Für eine lesbare Ausgabe installieren Sie capsh auf dem Host und dekodieren den Hex-Wert:

docker exec hardened-nginx sh -c 'cat /proc/1/status | grep CapEff' | awk '{print $2}' | xargs -I{} capsh --decode=0x{}

In der Ausgabe sollten nur cap_chown,cap_setgid,cap_setuid,cap_net_bind_service erscheinen.

Aufräumen:

docker rm -f hardened-nginx

Was verhindert das no-new-privileges-Flag?

Das no-new-privileges-Flag verhindert, dass Prozesse innerhalb eines Containers durch setuid- oder setgid-Binaries zusätzliche Berechtigungen erlangen. Ohne dieses Flag kann ein kompromittierter Prozess ein setuid-Binary (wie su oder sudo) ausführen und zu root eskalieren. Mit gesetztem Flag verweigert der Kernel die Rechteeskalation.

Pro Container anwenden

CLI:

docker run -d --security-opt no-new-privileges:true --name no-priv-test nginx:alpine

Compose:

services:
  web:
    image: nginx:alpine
    security_opt:
      - no-new-privileges:true

Als Daemon-Standard setzen

Fügen Sie es zur daemon.json hinzu, damit jeder Container dieses Flag automatisch erhält:

{
  "no-new-privileges": true
}

Docker nach der Bearbeitung neu starten:

sudo systemctl restart docker

Verifizieren

docker exec no-priv-test grep NoNewPrivs /proc/1/status

Erwartete Ausgabe:

NoNewPrivs:	1

Der Wert 1 bedeutet, dass keine neuen Berechtigungen erlangt werden können. Der Wert 0 bedeutet, dass das Flag nicht gesetzt ist.

Aufräumen:

docker rm -f no-priv-test

Wie erstelle ich ein benutzerdefiniertes Seccomp-Profil für Docker?

Das Standard-Seccomp-Profil von Docker blockiert etwa 44 von über 300 Syscalls. Ein benutzerdefiniertes Profil ermöglicht es, Container auf die Syscalls einzuschränken, die Ihre Anwendung tatsächlich nutzt. Wenn ein Angreifer den Container kompromittiert, kann er keine Kernel-Schwachstellen über blockierte Syscalls ausnutzen.

Herausfinden, welche Syscalls Ihre Anwendung benötigt

Verwenden Sie strace auf einem laufenden Container, um die Syscalls während des normalen Betriebs zu erfassen:

docker run -d --security-opt seccomp=unconfined --name trace-target nginx:alpine

# Install strace on the host
sudo apt-get install -y strace

# Get the container's PID
PID=$(docker inspect --format '{{.State.Pid}}' trace-target)

# Trace syscalls for 30 seconds during normal operation
sudo strace -f -p "$PID" -o /tmp/nginx-syscalls.log -e trace=all &
STRACE_PID=$!
sleep 30

# Send some test traffic to exercise the application
curl -s http://localhost:80 > /dev/null 2>&1 || true

kill "$STRACE_PID" 2>/dev/null
wait "$STRACE_PID" 2>/dev/null

Die eindeutigen Syscall-Namen extrahieren:

grep -oP '^\[pid \d+\] \K\w+|^\w+' /tmp/nginx-syscalls.log | sort -u

Das liefert Ihnen die Mindestmenge an Syscalls, die Ihre Anwendung benötigt.

Das benutzerdefinierte Profil erstellen

Erstellen Sie eine JSON-Datei. Die defaultAction ist SCMP_ACT_ERRNO (alles ablehnen, was nicht explizit erlaubt ist). Die Syscall-Liste muss sowohl die Anforderungen Ihrer Anwendung als auch die der Container-Runtime (runc) bei der Initialisierung enthalten. Das folgende Profil wurde mit nginx:alpine auf Docker Engine 29.x getestet:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": [
    "SCMP_ARCH_X86_64",
    "SCMP_ARCH_X86",
    "SCMP_ARCH_AARCH64"
  ],
  "syscalls": [
    {
      "names": [
        "accept4",
        "access",
        "arch_prctl",
        "bind",
        "brk",
        "capget",
        "capset",
        "chdir",
        "chown",
        "clone",
        "clone3",
        "close",
        "close_range",
        "connect",
        "copy_file_range",
        "dup",
        "dup2",
        "dup3",
        "epoll_create1",
        "epoll_ctl",
        "epoll_pwait",
        "epoll_pwait2",
        "epoll_wait",
        "eventfd2",
        "execve",
        "exit",
        "exit_group",
        "faccessat",
        "faccessat2",
        "fchmod",
        "fchmodat",
        "fchown",
        "fchownat",
        "fcntl",
        "fork",
        "fstat",
        "fstatfs",
        "futex",
        "getcwd",
        "getdents",
        "getdents64",
        "getegid",
        "geteuid",
        "getgid",
        "getpgrp",
        "getpid",
        "getppid",
        "getrandom",
        "getrlimit",
        "getsockname",
        "getsockopt",
        "gettid",
        "getuid",
        "io_destroy",
        "io_getevents",
        "io_setup",
        "io_submit",
        "ioctl",
        "kill",
        "listen",
        "lseek",
        "lstat",
        "madvise",
        "memfd_create",
        "mkdir",
        "mkdirat",
        "mmap",
        "mount",
        "mprotect",
        "mremap",
        "munmap",
        "nanosleep",
        "newfstatat",
        "open",
        "openat",
        "pipe",
        "pipe2",
        "pivot_root",
        "poll",
        "ppoll",
        "prctl",
        "pread64",
        "prlimit64",
        "pwrite64",
        "read",
        "readlink",
        "readlinkat",
        "recvfrom",
        "recvmsg",
        "rename",
        "renameat",
        "rseq",
        "rt_sigaction",
        "rt_sigprocmask",
        "rt_sigreturn",
        "sched_getaffinity",
        "sched_yield",
        "seccomp",
        "sendfile",
        "sendmsg",
        "sendto",
        "set_robust_list",
        "set_tid_address",
        "setgid",
        "setgroups",
        "sethostname",
        "setitimer",
        "setsockopt",
        "setuid",
        "sigaltstack",
        "socket",
        "socketpair",
        "stat",
        "statfs",
        "statx",
        "symlink",
        "symlinkat",
        "sysinfo",
        "tgkill",
        "umask",
        "umount2",
        "uname",
        "unlink",
        "unlinkat",
        "unshare",
        "utimensat",
        "wait4",
        "write",
        "writev"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

Warum so viele Syscalls? Die Liste enthält Syscalls, die von drei Schichten benötigt werden: der Container-Runtime (runc) für Namespace-Setup (clone3, mount, pivot_root, unshare, seccomp, statx), der Alpine-Shell und Entrypoint-Skripten (fork, open, pipe, wait4) und nginx selbst (accept4, bind, listen, io_setup). Ein glibc-basiertes Image würde weniger Shell-bezogene Syscalls benötigen, dafür aber mehr libc-interne.

Speichern Sie dies als /etc/docker/seccomp-nginx.json.

Das benutzerdefinierte Profil anwenden

CLI:

docker run -d \
  --security-opt seccomp=/etc/docker/seccomp-nginx.json \
  --name seccomp-test \
  nginx:alpine

Compose:

services:
  web:
    image: nginx:alpine
    security_opt:
      - seccomp=/etc/docker/seccomp-nginx.json

Verifizieren, dass das Profil aktiv ist

docker inspect --format '{{.HostConfig.SecurityOpt}}' seccomp-test

Die Ausgabe zeigt das vollständige Seccomp-Profil-JSON, das auf den Container angewendet wurde. Docker liest die Datei bei der Container-Erstellung und bettet den Profilinhalt ein.

Testen, dass eine eingeschränkte Operation fehlschlägt:

docker exec seccomp-test unshare --mount /bin/sh -c 'echo escaped'

Dies sollte mit "Operation not permitted" fehlschlagen. Der Container besitzt kein CAP_SYS_ADMIN (wird standardmäßig nicht gewährt), und das Seccomp-Profil bietet eine zweite Verteidigungsschicht, indem es nur die Syscalls auf die Allowlist setzt, die für den normalen Betrieb nötig sind.

Aufräumen:

docker rm -f seccomp-test
rm -f /tmp/nginx-syscalls.log

Produktionstipp: Starten Sie mit dem Standardprofil von Docker und entfernen Sie Syscalls, anstatt von Grund auf zu bauen. Der strace-Ansatz liefert das strikteste Profil, erfordert aber gründliche Tests. Lassen Sie während der strace-Erfassung jeden Codepfad Ihrer Anwendung durchlaufen: Start, normale Anfragen, Fehlerbehandlung, ordnungsgemäßes Herunterfahren (docker stop) und Log-Rotation.

Iterativer Ansatz: Falls das Erstellen per strace zu riskant erscheint, nutzen Sie diesen sichereren Workflow:

  1. Kopieren Sie das Standard-Seccomp-Profil von Docker als Ausgangspunkt.
  2. Starten Sie Ihren Container mit diesem kopierten Profil. Es verhält sich identisch zum Standard.
  3. Entfernen Sie Syscalls gruppenweise (z. B. alle key*-Syscalls, alle swap*-Syscalls).
  4. Testen Sie den Container nach jeder Entfernung. Falls er nicht mehr funktioniert, fügen Sie den zuletzt entfernten Syscall zurück.
  5. Wiederholen Sie, bis Sie alles entfernt haben, was Ihre Anwendung nicht benötigt.

Das ist langsamer als die strace-Methode, aber sicherer für Produktionscontainer, bei denen ein fehlender Syscall intermittierende Fehler verursachen könnte.

Wie schreibe ich ein AppArmor-Profil für einen Docker-Container?

AppArmor beschränkt, auf welche Dateien, Netzwerkressourcen und Capabilities ein Container-Prozess zugreifen darf. Docker wendet automatisch ein Standard-Profil docker-default an. Ein benutzerdefiniertes Profil erlaubt es, Container weiter einzuschränken auf nur die Dateisystempfade und Netzwerkoperationen, die sie benötigen.

Prüfen, ob AppArmor aktiv ist

sudo aa-status

Sie sollten docker-default in der Liste der geladenen Profile sehen. Falls AppArmor nicht installiert ist:

sudo apt-get install -y apparmor apparmor-utils

Ein benutzerdefiniertes Profil schreiben

Erstellen Sie /etc/apparmor.d/containers/docker-nginx:

#include <tunables/global>

profile docker-nginx flags=(attach_disconnected,mediate_deleted) {
  #include <abstractions/base>
  #include <abstractions/nameservice>

  # Capabilities needed by nginx
  capability chown,
  capability setuid,
  capability setgid,
  capability net_bind_service,
  capability dac_override,

  # Network access
  network inet tcp,
  network inet udp,
  network inet6 tcp,
  network inet6 udp,

  # Shell and entrypoint scripts
  /bin/** rix,
  /usr/bin/** rix,
  /usr/sbin/** rix,
  /lib/** mr,
  /usr/lib/** mr,
  /docker-entrypoint.sh rix,
  /docker-entrypoint.d/ r,
  /docker-entrypoint.d/** rix,
  /dev/null rw,
  /dev/stdout rw,
  /dev/stderr rw,

  # Nginx binary
  /usr/sbin/nginx ix,

  # Config files (read only)
  /etc/ r,
  /etc/nginx/ r,
  /etc/nginx/** r,

  # Web root (read only)
  /usr/share/nginx/html/** r,

  # Temp and cache directories
  /var/cache/nginx/ rw,
  /var/cache/nginx/** rw,
  /var/log/nginx/ rw,
  /var/log/nginx/** rw,
  /run/ rw,
  /run/** rw,
  /tmp/ rw,
  /tmp/** rw,

  # Proc filesystem (needed for nginx worker management)
  /proc/** r,

  # Deny sensitive files
  deny /etc/shadow r,
  deny /etc/passwd w,
  deny /proc/*/mem r,
  deny /sys/** w,
}

Das Profil benötigt capability-Deklarationen, weil AppArmor die Capability-Nutzung unabhängig von Dockers --cap-add-Flags steuert. Die Entrypoint-Skript-Pfade (/docker-entrypoint.sh, /docker-entrypoint.d/) und Shell-Binaries (/bin/**) müssen explizit erlaubt sein, sonst startet der Container nicht. Die rix-Berechtigung bedeutet: lesen, Ausführungskontext erben und Ausführung erlauben.

Profil laden und anwenden

sudo apparmor_parser -r -W /etc/apparmor.d/containers/docker-nginx

Prüfen, ob es geladen wurde:

sudo aa-status | grep docker-nginx

Container mit dem benutzerdefinierten Profil starten:

CLI:

docker run -d \
  --security-opt apparmor=docker-nginx \
  --name apparmor-test \
  nginx:alpine

Compose:

services:
  web:
    image: nginx:alpine
    security_opt:
      - apparmor=docker-nginx

AppArmor-Enforcement verifizieren

docker exec apparmor-test cat /etc/shadow

Dies sollte "Permission denied" zurückgeben, weil das Profil das Lesen von /etc/shadow explizit verweigert.

Den AppArmor-Status des Containers prüfen:

docker inspect --format '{{.AppArmorProfile}}' apparmor-test

Erwartete Ausgabe: docker-nginx.

Aufräumen:

docker rm -f apparmor-test

Wie starte ich einen Docker-Container mit einem schreibgeschützten Dateisystem?

Ein schreibgeschütztes Root-Dateisystem verhindert, dass Angreifer Malware, Backdoors oder veränderte Binaries in einem kompromittierten Container ablegen. Der Container kann weiterhin in explizit gemountete tmpfs-Volumes für temporäre Dateien und Laufzeitdaten schreiben.

CLI:

docker run -d \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --tmpfs /run:rw,noexec,nosuid,size=16m \
  --tmpfs /var/cache/nginx:rw,noexec,nosuid,size=128m \
  -p 8080:80 \
  --name readonly-nginx \
  nginx:alpine

Compose:

services:
  web:
    image: nginx:alpine
    read_only: true
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
      - /run:rw,noexec,nosuid,size=16m
      - /var/cache/nginx:rw,noexec,nosuid,size=128m

Beachten Sie das noexec-Flag bei den tmpfs-Mounts. Dies verhindert das Ausführen von Binaries aus temporären Verzeichnissen, eine gängige Technik, die Angreifer nach Erlangung von Schreibzugriff nutzen.

Verifizieren, dass das Dateisystem schreibgeschützt ist

docker exec readonly-nginx touch /testfile

Erwartete Ausgabe:

touch: /testfile: Read-only file system

Bestätigen, dass tmpfs-Mounts funktionieren:

docker exec readonly-nginx touch /tmp/testfile && echo "tmpfs works"

Prüfen, dass der Container Traffic ausliefert:

curl -s -o /dev/null -w '%{http_code}' http://localhost:8080

Erwartet: 200.

Aufräumen:

docker rm -f readonly-nginx

Wie sollte eine gehärtete Docker daemon.json aussehen?

Eine gehärtete daemon.json wendet Sicherheitsstandards auf jeden Container auf dem Host an. Einzelne Container können einige Einstellungen überschreiben, aber die Daemon-Konfiguration setzt die Basislinie.

Erstellen oder bearbeiten Sie /etc/docker/daemon.json:

{
  "no-new-privileges": true,
  "icc": false,
  "live-restore": true,
  "userland-proxy": false,
  "log-driver": "journald",
  "log-opts": {
    "tag": "{{.Name}}"
  },
  "default-ulimits": {
    "nproc": {
      "Name": "nproc",
      "Hard": 512,
      "Soft": 256
    },
    "nofile": {
      "Name": "nofile",
      "Hard": 65536,
      "Soft": 32768
    }
  },
  "storage-driver": "overlay2"
}

Was jede Einstellung bewirkt:

  • no-new-privileges: Blockiert setuid/setgid-Eskalation in allen Containern standardmäßig.
  • icc: false: Deaktiviert die Inter-Container-Kommunikation im Standard-Bridge-Netzwerk. Container können einander nur über explizit veröffentlichte Ports oder benutzerdefinierte Netzwerke erreichen. Das begrenzt laterale Bewegung, wenn ein Container kompromittiert wird.
  • live-restore: Hält Container während Daemon-Neustarts am Laufen. Verhindert Ausfallzeit bei Docker-Upgrades.
  • userland-proxy: false: Nutzt iptables für Port-Mapping statt eines Userland-Proxy-Prozesses. Bessere Performance, weniger offene Dateideskriptoren.
  • log-driver: journald: Sendet Container-Logs an das Systemjournal, wo sie zentral verwaltet und rotiert werden.
  • default-ulimits: Begrenzt Prozesse und offene Dateien pro Container. Verhindert Fork Bombs und Dateideskriptor-Erschöpfung.

Änderungen anwenden:

sudo systemctl restart docker

Verifizieren, dass der Daemon die Konfiguration übernommen hat:

docker info --format '{{.SecurityOptions}}'

Sie sollten no-new-privileges in der Liste der Sicherheitsoptionen sehen.

Prüfen, dass ICC deaktiviert ist:

docker network inspect bridge --format '{{index .Options "com.docker.network.bridge.enable_icc"}}'

Erwartete Ausgabe: false.

Hinweis zu userns-remap: Wenn Sie User Namespace Remapping statt Rootless-Modus gewählt haben, fügen Sie "userns-remap": "default" zu dieser Konfiguration hinzu. Kombinieren Sie userns-remap nicht mit Rootless Docker.

Versionsinformationen verbergen: Während Sie die daemon.json bearbeiten, sollten Sie auch überlegen, Docker-API-Versionsinformationen zu verbergen. Docker gibt standardmäßig keine Versions-Header preis wie Nginx, aber wenn Sie die Docker-API auf einem TCP-Socket betreiben (tun Sie das nicht, es sei denn es ist unbedingt nötig), schützen Sie sie mit TLS-Client-Zertifikaten. Eine exponierte Docker-API ist gleichbedeutend mit Root-Shell-Zugang.

Setup auditieren: Führen Sie Docker Bench for Security aus, um Ihre Konfiguration gegen den CIS Docker Benchmark zu prüfen. Klonen Sie das Repository und führen Sie das Skript direkt aus, da das Container-Image einen veralteten Docker-Client enthält, der mit Docker Engine 29.x inkompatibel ist:

git clone https://github.com/docker/docker-bench-security.git /tmp/docker-bench
cd /tmp/docker-bench && sudo bash docker-bench-security.sh

Prüfen Sie die Ausgabe auf WARN-Einträge. Die oben gehärtete daemon.json adressiert die meisten davon. Als INFO markierte Einträge sind Empfehlungen, keine Fehler.

Mehrere Härtungsschichten in Docker Compose kombinieren

Eine Produktions-Compose-Datei sollte mehrere Härtungsmaßnahmen zusammenfassen. Hier ein Beispiel für einen Nginx-Container mit allen sieben Schichten:

services:
  web:
    image: nginx:alpine
    read_only: true
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - NET_BIND_SERVICE
      - SETUID
      - SETGID
    security_opt:
      - no-new-privileges:true
      - seccomp=/etc/docker/seccomp-nginx.json
      - apparmor=docker-nginx
    tmpfs:
      - /tmp:rw,noexec,nosuid,size=64m
      - /run:rw,noexec,nosuid,size=16m
      - /var/cache/nginx:rw,noexec,nosuid,size=128m
    ports:
      - "80:80"
    pids_limit: 100
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: '1.0'

Beachten Sie, dass pids_limit Fork Bombs verhindert und deploy.resources.limits Speicher und CPU begrenzt. Das sind keine security-opt-Flags, aber sie verhindern Denial-of-Service aus dem Container heraus. Mehr zu Ressourcenlimits unter Docker Compose Ressourcenlimits, Healthchecks und Neustartrichtlinien.

Verifizieren, dass alle Sicherheitsoptionen auf dem laufenden Container aktiv sind:

docker compose up -d
docker inspect web --format '{{json .HostConfig.SecurityOpt}}' | python3 -m json.tool

Sie sollten no-new-privileges, Ihren Seccomp-Profilpfad und den AppArmor-Profilnamen in der Liste sehen.

Produktions-Härtungs-Checkliste

Maßnahme CLI-Flag Compose-Schlüssel daemon.json Verhinderte Bedrohung
Rootless Docker N/A (Daemon-Ebene) N/A N/A (separater Daemon) Container-Ausbruch gibt Host-root
userns-remap N/A N/A "userns-remap": "default" Container-root = Host-root
Capabilities entfernen --cap-drop ALL --cap-add X cap_drop: [ALL] N/A Kernel-Angriffsfläche
no-new-privileges --security-opt no-new-privileges:true security_opt: [no-new-privileges:true] "no-new-privileges": true Setuid/setgid-Eskalation
Benutzerdefiniertes Seccomp --security-opt seccomp=profile.json security_opt: [seccomp:path] N/A Kernel-Exploit über blockierte Syscalls
AppArmor-Profil --security-opt apparmor=name security_opt: [apparmor:name] N/A Datei-/Netzwerkzugriff über App-Bedarf hinaus
Schreibgeschütztes rootfs --read-only read_only: true N/A Persistente Malware, Binary-Manipulation
ICC deaktivieren N/A N/A "icc": false Laterale Bewegung zwischen Containern
Prozesse begrenzen --pids-limit 100 pids_limit: 100 "default-pids-limit": 100 Fork Bombs
Userland-Proxy deaktivieren N/A N/A "userland-proxy": false Ressourcenverschwendung, verkleinert Angriffsfläche

Fehlerbehebung

Container startet nach dem Hinzufügen eines Seccomp-Profils nicht: Ihrem Profil fehlt ein Syscall, den die Anwendung benötigt. Starten Sie vorübergehend mit --security-opt seccomp=unconfined, tracen Sie den Prozess mit strace und fügen Sie die fehlenden Syscalls Ihrem Profil hinzu.

Kernel-Audit-Logs auf blockierte Syscalls prüfen:

sudo journalctl -k | grep -i seccomp

AppArmor blockiert legitime Operationen: Setzen Sie das Profil in den Complain-Modus, um Ablehnungen zu protokollieren ohne sie durchzusetzen:

sudo aa-complain /etc/apparmor.d/containers/docker-nginx

Logs beobachten:

sudo journalctl -k | grep apparmor | tail -20

Nach dem Hinzufügen der benötigten Berechtigungen zurück in den Enforce-Modus wechseln:

sudo aa-enforce /etc/apparmor.d/containers/docker-nginx

Rootless Docker kann keine Images pullen: Prüfen Sie, ob DOCKER_HOST korrekt gesetzt ist:

echo $DOCKER_HOST

Der Wert sollte auf /run/user/<UID>/docker.sock zeigen. Prüfen Sie außerdem, ob der Rootless-Daemon läuft:

systemctl --user status docker

Bind Mount verweigert Zugriff bei userns-remap: Dateien auf dem Host im Besitz von root (UID 0) sind nicht zugänglich, weil die Container-UID 0 auf eine hohe Host-UID abgebildet wird. Beheben Sie dies durch Änderung der Eigentümerschaft:

# Find the remapped UID
grep dockremap /etc/subuid
# Then chown to that UID
sudo chown -R <remapped-uid>:<remapped-uid> /path/to/bind/mount

Ersetzen Sie <remapped-uid> durch die erste Zahl aus /etc/subuid für den Benutzer dockremap (üblicherweise 100000).

Container-Logs: Bei jedem Docker-Problem beginnen Sie mit den Container-Logs und dem Docker-Daemon-Journal:

docker logs <container-name>
sudo journalctl -u docker -f

Für Rootless Docker prüfen Sie das Journal auf Benutzerebene:

journalctl --user -u docker -f
Docker-Sicherheit härten: Rootless, Seccomp, AppArmor