Einige Links auf dieser Seite sind Affiliate-Links: bei einer Anmeldung darüber erhält diese Seite eine Provision, für Sie ohne Mehrkosten. Preise und Empfehlungen bleiben davon unberührt.

Letzte Woche stand ein Beitrag mit dem Titel “Docker is weird” auf der Startseite von Hacker News: 880 Punkte, 369 Kommentare. Der Autor schreibt eine eigene Container-Runtime und hat dabei festgestellt, dass Container nichts sind, was der Kernel kennt, dass Images nur Ordner sind und dass runc ein C-Modul braucht, weil Go kein brauchbares fork hat. Gute Lektüre, und alles davon stimmt.

Dieser Beitrag ist die andere Hälfte der Geschichte. Nicht warum Docker innen seltsam ist, sondern wo diese Seltsamkeit auf einem kleinen Produktivserver um zwei Uhr nachts an die Oberfläche kommt. Sieben Verhaltensweisen, jede unten auf einer laufenden Maschine mit Docker 28.3.1 reproduziert, mit echter Ausgabe statt aus dem Gedächtnis. Welche Hardware so eine Kiste braucht, haben wir in Bester VPS für Self-Hosting 2026 verglichen; der Stack selbst steht in Caddy und Docker im Produktivbetrieb.

1. Eine Restart-Policy interessiert sich nicht dafür, dass der Container unhealthy ist

Das kostet die meiste Downtime, weil es aussieht, als wärst du doppelt abgesichert: ein Healthcheck und restart: always.

docker run -d --name dw1 --restart always \
  --health-cmd 'exit 1' --health-interval 2s --health-retries 1 \
  alpine:3 sleep 600
sleep 12
docker inspect -f 'status={{ .State.Status }} health={{ .State.Health.Status }} failing={{ .State.Health.FailingStreak }} restarts={{ .RestartCount }}' dw1
status=running health=unhealthy failing=5 restarts=0

Fünf fehlgeschlagene Checks in Folge, der Container offen als unhealthy markiert, und null Restarts. Eine Restart-Policy reagiert darauf, dass der Hauptprozess endet. Ein Healthcheck schreibt nur einen Status. Im puren Docker verbindet nichts die beiden.

Die Anwendung, die ihren Worker-Pool verklemmt hat, den Port offen hält und nicht mehr antwortet, bleibt also genau dort stehen, mit einer halb grün wirkenden docker ps-Zeile, in der Up 3 days (unhealthy) in einer Spalte steht, die niemand liest. Swarm und Kubernetes handeln nach Health. Compose auf einer Maschine nicht.

Was stattdessen hilft: dafür sorgen, dass der Prozess endet, wenn er kaputt ist. Kann die Anwendung das nicht, gehört der Check außerhalb von Docker. Ein systemd-Timer, der den Endpunkt pollt und docker restart ausführt, sind fünf Zeilen und funktioniert wirklich:

curl -fsS --max-time 5 http://127.0.0.1:3000/health >/dev/null || docker restart app

Nutze den Healthcheck für das, worin er wirklich gut ist: depends_on: condition: service_healthy, damit die Datenbank bereit ist, bevor die App verbindet.

2. Ein veröffentlichter Port läuft direkt an ufw vorbei

Du hast ufw konfiguriert. ufw status sagt, nur 22, 80 und 443 sind erlaubt. Dann veröffentlichst du in Compose kurz einen Port, um auf ein Dashboard zu schauen, und dieser Port steht im offenen Internet.

Warum, auf derselben Maschine:

docker run -d --name pub -p 8099:80 nginx:alpine
iptables -t nat -S | grep 8099
iptables -S FORWARD | head -8
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8099 -j DNAT --to-destination 172.17.0.5:80

-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
-A FORWARD -j ufw-before-logging-forward
-A FORWARD -j ufw-before-forward
...

Lies die Reihenfolge. Verkehr auf einen veröffentlichten Port wird per DNAT umgeschrieben, bevor irgendeine Filterentscheidung von dir greift, und dann an DOCKER-USER und DOCKER-FORWARD übergeben, bevor die erste ufw-Chain erreicht ist. Docker akzeptiert, das Paket wird geforwardet, und die ufw-Chains weiter unten sehen es nie. Auf dieser Maschine ist DOCKER-USER so, wie es auf den meisten Maschinen ist:

-N DOCKER-USER

Leer. Genau diese Chain existiert, damit du Regeln an eine Stelle schreiben kannst, die Docker nicht überschreibt, und fast niemand benutzt sie.

Drei Lösungen, nach Wert sortiert:

  • An localhost binden. -p 127.0.0.1:8099:80 statt -p 8099:80, dann über einen SSH-Tunnel darauf zugreifen. Das ist der richtige Standard für jedes Admin-Interface.
  • Gar nicht veröffentlichen. Container ins Netz des Reverse Proxy, und der Proxy erreicht ihn über den Namen am internen Port. Nichts im Stack hinter unserem Caddy veröffentlicht einen Port.
  • Regeln in DOCKER-USER schreiben, wenn ein Port wirklich öffentlich und eingeschränkt sein muss: iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.0/24 -p tcp --dport 8099 -j DROP.

Wer gerade eine Maschine übernommen hat, prüft das als Punkt eins, und der Rest dieses Durchgangs steht im Audit eines übernommenen Servers.

3. Logs rotieren nicht, bis du es einrichtest

Der Standard-Logging-Treiber ist json-file, und die Standardkonfiguration ist keine:

docker inspect -f '{{ json .HostConfig.LogConfig }}' dw2
{"Type":"json-file","Config":{}}

Kein max-size, kein max-file. Die Datei unter /var/lib/docker/containers/<id>/<id>-json.log wächst, bis die Platte voll ist. Wie schnell? Ein Container, der eine kurze Zeile in einer Schleife ausgibt, zwanzig Sekunden lang:

docker run -d --name chat alpine:3 sh -c \
  'i=0; while [ $i -lt 200000 ]; do echo "line $i some payload here"; i=$((i+1)); done; sleep 60'
# zwanzig Sekunden später
du -sh /var/lib/docker/containers/9ee39f60*/
19.0M	/var/lib/docker/containers/9ee39f60.../

19 MB in zwanzig Sekunden aus einem Container mit einer kurzen Zeile pro Durchlauf. Eine Anwendung, die jeden Request auf Debug-Level loggt und über ein langes Wochenende allein gelassen wird, ist ein vollgelaufenes Root-Filesystem, und das heißt auf einer Einzelmaschine: die Datenbank nimmt keine Writes mehr an, der Proxy kann sein Access-Log nicht schreiben, und die TLS-Erneuerung schlägt fehl.

Einmal global einstellen, bevor du es brauchst:

{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

Das gehört in /etc/docker/daemon.json, danach systemctl restart docker. Es gilt für Container, die nach dem Neustart erzeugt werden, nicht für bestehende, die musst du neu anlegen. Pro Service in Compose:

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Dreißig Megabyte pro Container, begrenzt, und docker logs funktioniert weiter.

4. PID 1 ist eine Shell, und dein SIGTERM landet nirgends

Zwei Container, dasselbe sleep, gleich gestoppt:

docker run -d --name dw2 alpine:3 sh -c 'trap "echo caught; exit 0" TERM; sleep 600'
time docker stop dw2

docker run -d --name dw3 --init alpine:3 sleep 600
time docker stop dw3
dw2    10.11s
dw3     0.09s

Zehn Sekunden gegen neun Hundertstel. Und man beachte: der langsame hat einen TERM-Trap. Shells stellen den Trap zurück, bis das Vordergrundkommando fertig ist, der Handler ist während sleep also nutzlos. docker stop schickt SIGTERM an PID 1, wartet die Gnadenfrist ab und schickt dann SIGKILL.

Hundert von hundert Mal ist das nur nervige Latenz. Beim hundertersten Mal ist es Datenverlust, weil SIGKILL das ist, was deine Datenbank, dein Queue-Consumer oder dein Dateischreiber anstelle einer Shutdown-Sequenz bekommt. Außerdem wird jedes Deployment pro Container zehn Sekunden langsamer, was man in einer Pipeline aus rsync und restart deutlich merkt.

Drei Auswege, alle günstig:

  • Exec-Form im Dockerfile: CMD ["nginx", "-g", "daemon off;"], nie CMD nginx -g 'daemon off;'.
  • exec als letzte Zeile eines Entrypoint-Skripts, damit dein Prozess die Shell ersetzt statt deren Kind zu sein.
  • init: true in Compose, das ein winziges init als PID 1 setzt, Signale weitergibt und Zombies abräumt.

Danach prüfen, denn das ist die Sorte Sache, von der alle glauben, sie hätten sie schon erledigt: docker exec <name> ps -o pid,comm und nachsehen, was PID 1 tatsächlich ist.

5. Container-DNS gibt es nur in Netzen, die du angelegt hast

Dieselbe Engine, zwei Container, zwei verschiedene Resolver. Auf der Default-Bridge:

nameserver 192.168.178.1
nameserver 8.8.8.8

In einem mit docker network create erzeugten Netz:

nameserver 127.0.0.11

127.0.0.11 ist Dockers eingebauter DNS-Server, und er ist der Grund, warum postgres://db:5432 funktioniert. Auf der Default-Bridge ist er nicht da: die /etc/resolv.conf des Hosts wird hineinkopiert, und Containernamen lösen auf nichts auf. Deshalb finden sich docker run-Beispiele aus einem Blogbeitrag nicht gegenseitig, während dieselben Images in Compose es tun: Compose legt dir ein Projektnetz an.

Es heißt auch, dass deine Servicenamen keine stabile API sind. Das alte --link ist weg, und die Default-Bridge gibt dir nur IP-Adressen, was zum nächsten Punkt führt.

6. Compose gibt dir ein anderes Subnetz als das, das du freigegeben hast

docker compose up -d
docker network inspect app_default -f '{{ (index .IPAM.Config 0).Subnet }}'
# 172.19.0.0/16

docker compose down
docker network create decoy1 && docker network create decoy2
docker compose up -d
docker network inspect app_default -f '{{ (index .IPAM.Config 0).Subnet }}'
# 172.21.0.0/16

docker compose down löscht das Projektnetz. Das nächste up erzeugt ein neues und nimmt den nächsten freien Block, und zwar nicht den, den du hattest. Zwei unbeteiligte Netze dazwischen haben gereicht, um das Projekt von 172.19 nach 172.21 zu verschieben.

Alles, was du auf das alte Subnetz festgenagelt hast, ist jetzt stillschweigend falsch: eine Zeile in pg_hba.conf, eine Allowlist in der Anwendung, eine Firewallregel, eine API, die X-Forwarded-For gegen ein CIDR prüft. Der Fehler kommt als “connection refused from unknown host” nach einem routinemäßigen Redeploy, also zum schlechtest möglichen Zeitpunkt, um pg_hba.conf zu lesen.

Also entweder keine Subnetze mehr festnageln und Containernamen in einem eigenen Netz nutzen, oder das Subnetz deklarieren, damit es nicht wandern kann:

networks:
  default:
    ipam:
      config:
        - subnet: 172.28.0.0/24

7. Ein OOM-Kill sieht nicht wie ein Crash aus

Gib einem Container 32 MB und lass ihn allozieren:

docker run --rm -m 32m alpine:3 sh -c 'a=""; while true; do a="$a$(head -c 1000000 /dev/zero | tr "\0" x)"; done'
echo $?
137

Exit-Code 137 ist 128 + 9: SIGKILL. Nicht dein Code, keine Exception, kein Stacktrace, nichts im Anwendungslog. Die cgroup hat ihr Limit erreicht, und der Kernel hat den größten Prozess darin getötet.

Und hier treffen sich der Hacker-News-Beitrag und dieser. Der Kernel tötet keinen “Container”, er tötet einen gewöhnlichen Prozess, den er die ganze Zeit sieht:

hp=$(docker inspect -f '{{ .State.Pid }}' dw4)
ps -o pid,cmd -p $hp
grep NSpid /proc/$hp/status
    PID CMD
1085777 sleep 600
NSpid:	1085777	1

Ein Prozess, zwei Nummern: 1085777 auf dem Host, 1 innen. “Container sind nicht echt” ist keine Provokation, sondern genau das, was du beim Debuggen eines OOM-Kills anschaust. Die PID 1 des Containers steht in der Prozessliste deines Hosts, und dort findest du sie, wenn docker logs nichts hat, weil der Prozess nie dazu kam, eine Zeile zu schreiben.

Wenn ein Container also ohne Erklärung verschwindet: zuerst den Exit-Code lesen (docker inspect -f '{{ .State.ExitCode }} {{ .State.OOMKilled }}'), dann dmesg -T | grep -i oom, und dann für jeden Service ein Memory-Limit setzen, damit der Kernel den Container tötet, der sich falsch verhält, und nicht irgendwas anderes, das auf deinem Host gerade lief.

Die Checkliste

Alles von oben, als Diff für einen Stack, den du schon betreibst:

services:
  app:
    image: app:1.4.2          # nicht :latest
    init: true                # PID 1 gibt Signale weiter
    restart: unless-stopped
    mem_limit: 512m           # der Kernel tötet das, nicht deine Datenbank
    ports:
      - "127.0.0.1:3000:3000" # nie ein nackter Host-Port
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
    depends_on:
      db:
        condition: service_healthy

networks:
  default:
    ipam:
      config:
        - subnet: 172.28.0.0/24   # damit ein Redeploy es nicht verschiebt

Dazu zwei Dinge außerhalb der Datei: max-size in /etc/docker/daemon.json für alles, was du per Hand startest, und ein externer Watchdog für den Health-Status, auf den Docker nicht reagiert.

Nichts davon ist exotisch. Das ist alles Standardverhalten, dokumentiert, in je zwanzig Sekunden reproduzierbar, und jeder Punkt auf der Liste hat diesen Monat irgendwem einen kleinen Produktivserver lahmgelegt. Es passiert immer wieder, weil die Defaults für einen Laptop gemacht sind, wo eine 19-MB-Logdatei und ein zehn Sekunden langer Stop nichts kosten.

Wer den restlichen Stack sehen will, zu dem diese Einstellungen gehören: die vollständige Konfiguration der Maschine, die diese Seite ausliefert, steht in Caddy und Docker im Produktivbetrieb, die Messseite in Analytics selbst hosten mit Umami.