Das Symptom: 500 überall, ein DNS-Fehler im Log
Ein ganz normaler Tag, dann die Meldung: Die Seite ist offline. Der schnelle Check zeigt keinen Timeout und keinen 502, sondern einen sauberen HTTP 500. Der Webserver läuft also, aber jeder Request stirbt in der Anwendung. Und im Django-Log steht immer dieselbe Zeile:
$ curl -sS -o /dev/null -w "HTTP %{http_code}\n" https://example.net/
HTTP 500
# Django-Log:
OperationalError: could not translate host name "db" to address:
Name or service not knowncould not translate host name "db" schreit förmlich „DNS kaputt“ oder „Netzwerk weg“. Genau das war die falsche Fährte, die mich kurz in die Irre führte.
Die falsche Fährte – und die echte Ursache
In meinem Setup ist db kein DNS-Name, sondern der Netzwerk-Alias eines geteilten Postgres-Containers, den sich mehrere Sites teilen. Ist dieser Container nicht da, existiert der Alias nicht – und der Fehler sieht aus wie ein DNS-Problem, obwohl die Datenbank schlicht fehlt. Also nicht am Netzwerk basteln, sondern zuerst die Container ansehen:
$ docker ps -a --format '{{.Names}}\t{{.Status}}'
...
postgresql-db-1 Restarting (1) 27 seconds ago
$ docker logs postgresql-db-1 --tail 5
PostgreSQL Database directory appears to contain a database; Skipping initialization
FATAL: could not write lock file "postmaster.pid": No space left on deviceDa ist sie: No space left on device. Postgres kann nicht einmal seine postmaster.pid schreiben, crasht sofort, wird von Docker neu gestartet, crasht wieder – eine Endlosschleife mit vierstelligem Restart-Counter. Die Ursache liegt also nicht in der Datenbank, sondern eine Ebene tiefer: die Platte ist voll.
Diagnose: Wer frisst die Platte?
Erst bestätigen, dass wirklich kein Byte frei ist, dann den Verursacher unter /var/lib/docker einkreisen:
$ df -h /var
/dev/vda4 305G 290G 0 100% /var # 0 Byte frei
$ du -sh /var/lib/docker/* | sort -rh | head -4
159G /var/lib/docker/overlay2
83G /var/lib/docker/containers # <- verdächtig groß
27G /var/lib/docker/volumes
# Die größten Container-Logs den Namen zuordnen:
$ for id in $(docker ps -aq); do
f=$(docker inspect --format '{{.LogPath}}' "$id")
[ -f "$f" ] && printf '%s\t%s\n' \
"$(du -m "$f"|cut -f1)MB" "$(docker inspect --format '{{.Name}}' "$id")"
done | sort -rh | head
70981MB /gitlab_ce-web-1
10389MB /app-worker-1Der Fund: ein einzelnes *-json.log von GitLab mit 70 GB, dazu ein Celery-Worker mit 11 GB. Zusammen fast der gesamte belegte Platz im containers-Verzeichnis. Der Grund ist unspektakulär: Docker rotiert die stdout-Logs des json-file-Treibers standardmäßig nicht. Ein geschwätziger Container läuft damit über Wochen ungebremst voll – bis eben nichts mehr geht.
Sofort-Fix: Service in zwei Minuten zurück
Wichtig: die Logdatei truncaten, nicht löschen. Ein laufender Container hält den Filehandle offen – rm gäbe den Platz erst beim Neustart frei, : > datei leert sofort und der Container schreibt einfach weiter.
Nur stdout-Logs anfassen. Datenbank-Volumes und Nutzdaten bleiben tabu – die stecken in /var/lib/docker/volumes, nicht in den json-Logs.
# a) die größten Logs leeren (Pfade aus der Diagnose)
: > /var/lib/docker/containers/<id>/<id>-json.log
df -h /var # Platz ist sofort wieder frei
# b) die gecrashte DB neu starten
docker restart postgresql-db-1
docker exec postgresql-db-1 pg_isready # -> accepting connections
# c) App-Stack neu starten und prüfen
docker restart app worker beat
curl -sS -o /dev/null -w "HTTP %{http_code}\n" https://example.net/ # -> 200Zwei geleerte Logdateien, ein DB-Neustart, ein App-Neustart – und die Seite liefert wieder 200. Das war der Feuerwehr-Teil. Der eigentlich interessante Teil kommt jetzt: dafür sorgen, dass es nicht wiederkommt.
Der Denkfehler bei der Log-Rotation
„Kein Problem, ich hab doch Log-Rotation in der daemon.json“ – dachte ich. Genau da lag der Denkfehler. Die Datei war gesetzt, aber der 70-GB-Ausreißer entstand trotzdem. Der Grund: der laufende Docker-Daemon hatte die Config nie geladen.
$ stat -c '%y' /etc/docker/daemon.json
2026-03-25 15:06:... # Config zuletzt geändert
$ systemctl show docker -p ActiveEnterTimestamp
ActiveEnterTimestamp=2025-12-20 17:21:... # Daemon läuft seit VOR der ÄnderungDie daemon.json ist neuer als der laufende Daemon – also nie eingelesen. Und Achtung: ein systemctl reload docker (SIGHUP) lädt die Default-Log-Optionen nicht nach; nur ein voller systemctl restart docker zieht sie – und der startet alle Container neu. Auf einer Box mit mehreren produktiven Stacks ist das nichts, was man mal eben nebenbei macht.
Der dauerhafte Fix: Rotation pro Stack im Compose
Statt mich auf einen unsichtbaren Daemon-Default zu verlassen, verankere ich die Rotation explizit im Compose jedes Stacks. Vorteile: sie ist im docker inspect sichtbar, überlebt jedes Recreate und braucht keinen globalen Docker-Restart. Ein YAML-Anchor hält es DRY:
# oben im docker-compose.yml
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
web:
logging: *default-logging
# ...
worker:
logging: *default-logging
# ...cp docker-compose.yml docker-compose.yml.bak.$(date +%Y%m%d) # Backup zuerst
docker compose config --quiet && echo OK # YAML validieren
docker compose up -d # recreate
# Verifizieren – muss die Opts zeigen, nicht map[]:
docker inspect web --format '{{.HostConfig.LogConfig.Config}}'
# map[max-file:3 max-size:10m]Der letzte Schritt ist der wichtigste: erst wenn inspect map[max-file:3 max-size:10m] statt map[] zeigt, ist die Rotation wirklich aktiv. Ein leeres map[] heißt „hängt am Daemon-Default“ – und der war ja gerade das Problem.
Prävention: ein simpler Disk-Watch
Rotation verhindert das Wiederkehren dieses konkreten Falls. Aber die nächste Platte läuft aus einem anderen Grund voll (ein Volume, ein Backup, ein Image-Wildwuchs). Deshalb noch ein billiger Wächter per Cron: warnt ab 85 % und leert ab 88 % automatisch durchgelaufene Logs – Selbstheilung für genau diesen Ausfalltyp.
#!/usr/bin/env bash
# /usr/local/bin/disk-watch.sh
set -uo pipefail
LOG=/var/log/disk-watch.log; WARN=85; HEAL=88; LOGMAX_MB=500
CDIR=/var/lib/docker/containers
log(){ echo "$(date '+%F %T') $*" >> "$LOG"; }
for mp in /var /; do
use=$(df --output=pcent "$mp" | tail -1 | tr -dc '0-9')
[ "$use" -lt "$WARN" ] && continue
log "WARN $mp bei ${use}%"
if [ "$use" -ge "$HEAL" ]; then
find "$CDIR" -name '*-json.log' -size +${LOGMAX_MB}M \
| while read -r f; do : > "$f"; log " HEAL geleert: $f"; done
fi
done# /etc/cron.d/disk-watch
*/15 * * * * root /usr/local/bin/disk-watch.shWas ich weggelassen habe
- Kein Push-Alarm (noch nicht). Der Watch loggt nur lokal. Ein Push aufs Handy wäre besser, aber ich wollte dafür keinen fremden Cloud-Dienst – die datenschutzfreundliche Variante (self-hosted ntfy oder eine Nextcloud-Notification) kommt in einem eigenen Schritt.
- Den geteilten DB-Container nicht neu erstellt. Für saubere Rotation müsste auch
postgresql-db-1einmal recreated werden – das hätte aber alle Sites kurz offline genommen. Bei 194 MB Log-Größe war das Risiko den erzwungenen Cross-Site-Ausfall nicht wert; das mache ich im nächsten Wartungsfenster. - Kein Prometheus/Grafana. Für einen einzelnen Homelab-Server ist ein 20-Zeilen-Cron mehr wert als ein Monitoring-Stack, den ich selbst wieder pflegen und dessen Volumes volllaufen könnten. Bewusst pragmatisch, nicht überengineered.
Fazit
Die eigentliche Ursache war langweilig – eine volle Platte. Interessant waren die zwei Fallen drumherum: der irreführende DNS-Fehler, hinter dem eine gecrashte DB steckte, und die scheinbar vorhandene, aber inaktive Log-Rotation in der daemon.json. Zwei Lehren bleiben: Rotation gehört dorthin, wo man sie sieht und prüfen kann – ins Compose, verifiziert per inspect. Und ein dummer, selbstheilender Disk-Watch schlägt ein hübsches Dashboard, das im Ernstfall selbst mit ausfällt. Wer seine geteilte Datenbank wirklich absichern will, kombiniert das mit echten Backups – dazu mehr im Artikel über Postgres-Backups für Self-Hosted Apps.
Anzeige · Affiliate-Link – kaufst du darüber, erhalte ich ggf. eine Provision. Für dich ändert sich am Preis nichts.