Von Fail2ban-Sperren, Speicherfressern und Automatisierung: Mein HomeLab-Update auf v2.2
Vom eigenen Server ausgesperrt, 70 GB Datenmüll entdeckt und wie ein hybrider Wartungs-Sprint aus Python und Bash mein HomeLab wieder auf Kurs bringt.
Manchmal ist die größte Gefahr für ein HomeLab nicht der ominöse Hacker aus dem Netz – sondern man selbst, wenn im Hintergrund unbemerkt der VPN-Tunnel abreißt und der Browser Amok läuft.
In meinem letzten Artikel zum Server 2.0 Umbau habe ich voller Stolz mein neues „Zwiebel-Modell“ präsentiert: Docker-Container, Nginx-Türsteher, WireGuard-VPN und automatisierte Restic-Backups auf die Hetzner Storage Box. In der Theorie war die Architektur ein Meisterwerk aus Isolierung und Eleganz.
Doch die Realität schlägt die Theorie bekanntlich gern mit dem Holzhammer.
In den vergangenen Wochen hat mein Hetzner-VPS (mittlerweile gab es ein frisches Hardware-Upgrade auf den CPX32 mit 4 vCPUs und 8 GB RAM) einige echte Stresstests durchgemacht. Ich wurde mitten in der Arbeit von meinem eigenen Sicherheitssystem ausgesperrt, habe schweißgebadet vor einer volllaufenden Festplatte gesessen und gelernt, warum Docker-Storage-Engines heimliche Datenfresser sein können.
Willkommen beim HomeLab-Update v2.2 – ein unverschönter Blick hinter die Kulissen, wie aus Kinderkrankheiten eine automatisierte Wartungs-Engine wurde.
Wenn dein eigener Server dich als Feind sieht (Das Fail2ban-Debakel)
Das Drama
Fail2ban ist auf meinem Linux-Host installiert, um Nginx- und SSH-Logs rund um die Uhr zu analysieren. Wer zu oft an der Tür rüttelt oder falsche Passwörter schickt, wird auf Kernel-Ebene über iptables ausgesperrt. Das Prinzip ist brillant – bis der eigene Admin plötzlich als Angreifer eingestuft wird.
Was war passiert? Auf meinem Smartphone und PC habe ich regelmäßig Tabs zu internen Diensten offen, zum Beispiel zu meiner n8n-Workflow-Engine.
Bin ich unterwegs und das WireGuard-VPN trennt sich kurz, senden die Browser-Tabs im Hintergrund munter weiter Polling-Anfragen.
Mein Nginx-Reverse-Proxy reagierte ohne VPN-Verbindung völlig korrekt mit einem HTTP 403 Forbidden. Fail2ban sah diese Flut an 403-Fehlern, witterte eine Brute-Force-Attacke und zückte gnadenlos die rote Karte: IP gebannt!
Da saß ich nun – ausgesperrt aus meinem eigenen digitalen Wohnzimmer.
[Fail2Ban-Log] Ban ip / External IP - IP address blocked by jail 'nginx-proxy'Die Erlösung (Der Whitelist-Fix)
Die Rettung lag in der Anpassung der Datei /etc/fail2ban/jail.local. Über die Direktive ignoreip muss man vertrauenswürdige Netze explizit von der Bestrafung ausnehmen:
# Die folgende Konfiguration ist schematisch; ip muss durch die tatsächlich
# gewünschte vertrauenswürdige IP-Adresse beziehungsweise das passende Netz ersetzt
# werden:
#
[DEFAULT]
# Whitelist für vertrauenswürdige Netzwerke:
ignoreip = ipDie 70-GB-Speicherfalle & das Docker-Mysterium
Die Katastrophe
Ein morgendlicher Blick auf das Dashboard versetzte mir einen Dämpfer: Die Root-Festplatte meines Hetzner-Servers meldete 69 % Belegung (98 GB von 150 GB). Für ein System, das primär Textdateien und schlanke SQLite-Datenbanken verarbeitet, ein alarmierender Wert.
Eine gezielte Spurensuche im Terminal offenbarte den Übeltäter: 42 verwaiste Docker-Images (<none>-Tags) verstopften die Festplatte!
Hauptverantwortlich waren alte Ollama-LLM-Builds (jeweils 8,2–8,3 GB) sowie Zwischenstände meiner lokalen Docker-Builds. Das Rätselelement: Mein automatischer Update-Dienst Watchtower lief doch brav mit WATCHTOWER_CLEANUP=true. Warum räumte er nicht auf?
[ Unabhängige / Nicht-gecoverte Dienste ]
┌───────────────────────────┐
│ Ollama (manueller Pull) │
│ hermes-router (lokal) │
│ silverbullet-mcp (lokal) │
│ anki-sync-server (lokal) │
└─────────────┬─────────────┘
│
Watchtower sieht diese NICHT!
│
▼
[ Verwaiste Images (<none>) ]Die Erkenntnis
Watchtower prüft ausschließlich Container, die aus einer externen Registry (wie Docker Hub oder GHCR) stammen. Lokal gebaute Images (wie mein anki-sync-server oder die hermes-Komponenten) besitzen gar keinen Upstream-Vergleich. Wenn sie neu gebaut oder gestoppt werden, bleiben die alten Image-Layer als Altlasten liegen.
Ein beherzter Befehl im Terminal brachte den Befreiungsschlag:
docker image prune -a(65,55 GB gelöscht)docker builder prune(4,26 GB gelöscht)- Gesamtgewinn: Fast 70 Gigabyte freier Speicherplatz!
Der heimtückische containerd Image Store
Seit Docker Engine 29+ nutzt Docker bei Neuinstallationen standardmäßig den containerd Image Store (Layer liegen unter /var/lib/containerd/, statt unter /var/lib/docker/overlay2/).
- Der Haken: Ein klassisches
du -sh /var/lib/docker/*liefert völlig falsche (viel zu niedrige) Werte! - Die Lösung: Verlasst euch zur Speicherdiagnose auf modernen Docker-Systemen ausschließlich auf
docker system df.
Die Lösung: Mein hybrider Wartungs-Sprint
Um nicht ständig Feuerwehr spielen zu müssen, habe ich meine Wartungs-Infrastruktur komplett neu aufgesetzt – basierend auf der klaren Trennung von Lesen und Schreiben:
- Lesende Status-Prüfung (Automatisch & Gefahrlos): Läuft über ein maßgeschneidertes Python-Skript und systemd-Timer.
- Schreibende Updates (Kontrolliert & Manuell): Führe ich über ein 1-Klick-Bash-Skript aus, wenn ich Zeit dafür habe.
Das Python-Herzstück: weekly_maintenance_check.py
Jeden Sonntag um 03:30 Uhr führt ein systemd-Timer mein Python-Skript aus. Es prüft den Systemzustand, anstehende APT-Updates, erforderliche Reboots und schaut per Watchtower (--monitor-only) passiv nach frischen Docker-Images. Die Ergebnisse wandern atomar per UPSERT in eine SQLite-Datenbank (/dbpfad/dbdaten.db).
Hier ist der vollständige Code – zeilenweise didaktisch für euch kommentiert:
# Die tatsächlichen Adressen wurden durch Platzhalter ersetzt!
#
#!/usr/bin/env python3
# Shebang: Zeigt dem Linux-System, dass dieses Skript mit Python 3 ausgeführt werden soll.
# -*- coding: utf-8 -*-
# Legt UTF-8 als Zeichenkodierung fest, damit deutsche Umlaute im Code sauber verarbeitet werden.
import os # Modul für Interaktionen mit dem Betriebssystem (z.B. Pfadprüfungen)
import re # Modul für Reguläre Ausdrücke (Regex), um Texte nach Mustern zu durchsuchen
import sqlite3 # Integrierte Datenbank-Bibliothek für den Dateizugriff auf SQLite
import subprocess # Ermöglicht das Ausführen von echten Terminal-Befehlen direkt aus Python
from datetime import datetime, timezone # Importiert Präzisions-Zeitfunktionen für universelle UTC-Zeitstempel
# Der absolute Dateipfad zu unserer zentralen SQLite-Wartungsdatenbank auf dem Server
DB_PATH = "dbpfad/daten.db"
def run_command(command):
"""Sicherer Helper: Führt einen Terminal-Befehl aus und fängt Ausgaben sowie Fehler ab."""
try:
# Führt den Befehl in der Shell aus (Timeout 120 Sekunden gegen Endlosschleifen)
result = subprocess.run(
command, shell=True, capture_output=True, text=True, timeout=120
)
# Fügt Standard-Ausgabe und Fehler-Logs zusammen und entfernt führende/folge Leerzeichen
return (result.stdout + "\n" + result.stderr).strip()
except Exception as e:
# Fängt Timeouts oder Systemfehler ab und gibt eine lesbare Fehlermeldung zurück
return f"Fehler: {str(e)}"
def upsert_check(
cursor,
check_type,
component,
status,
installed=None,
latest=None,
severity=None,
detail=None,
):
"""Schreibt ein Prüfergebnis in die Datenbank (Erstellt neu oder aktualisiert existierenden Eintrag)."""
# Generiert den aktuellen UTC-Zeitstempel im standardisierten ISO-Format
now_utc = datetime.now(timezone.utc).isoformat()
# SQL-Anweisung mit 'ON CONFLICT': Verhindert Duplikate und aktualisiert den Datensatz atomar
sql = """
INSERT INTO maintenance_checks (checked_at, check_type, component, status, installed, latest, severity, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(check_type, component) DO UPDATE SET
checked_at = excluded.checked_at,
status = excluded.status,
installed = excluded.installed,
latest = excluded.latest,
severity = excluded.severity,
detail = excluded.detail;
"""
# Führt die SQL-Anweisung mit Platzhaltern (?) aus – Schutz vor SQL-Injection!
cursor.execute(
sql,
(
now_utc,
check_type,
component,
status,
installed,
latest,
severity,
detail,
),
)
def check_docker_containers(cursor):
"""Prüft laufende Container und ermittelt per Watchtower im Prüfmodus verfügbare Updates."""
# Fragt alle aktiven Docker-Container im Format 'Name|Image' ab
ps_output = run_command("docker ps --format '{{.Names}}|{{.Image}}'")
# Bricht ab, falls Docker nicht antwortet
if not ps_output or ps_output.startswith("Fehler:"):
upsert_check(
cursor,
"docker_update",
"docker_daemon",
"error",
detail="Docker-Daemon reagiert nicht.",
)
return
# Wandelt die Textausgabe in ein übersichtliches Python-Dictionary um
running_containers = {}
for line in ps_output.split("\n"):
if "|" in line:
name, image = line.split("|", 1)
running_containers[name.strip()] = image.strip()
# Startet Watchtower EINMALIG und rein LESEND (--monitor-only = führt KEINE Updates aus!)
watchtower_cmd = "docker run --rm -v /var/run/docker.sock:/var/run/docker.sock nickfedor/watchtower:latest --run-once --monitor-only"
wt_output = run_command(watchtower_cmd)
# Extrahiert alle Container-Namen per Regex, für die Watchtower ein neues Image in der Registry findet
updated_containers = set(
re.findall(
r'msg="Found new image".*?container=([a-zA-Z0-9_-]+)',
wt_output,
re.IGNORECASE,
)
)
# Ausnahmeliste: Lokal gebaute Container ohne Registry-Upstream
local_keywords = [
"anki-sync-server",
"hermes-router",
"hermes-redis",
"silverbullet-mcp",
]
# Geht jeden laufenden Container durch und bewertet seinen Zustand
for name, image in running_containers.items():
# Fall 1: Container ist lokal gebaut
if any(
kw in name.lower() or kw in image.lower() for kw in local_keywords
):
upsert_check(
cursor,
"docker_update",
name,
"ok",
installed=image,
latest="local_build",
severity="none",
detail="Lokal gebautes Image (kein Registry-Upstream).",
)
continue
# Fall 2: Neues Image in der Docker Registry verfügbar
if name in updated_containers:
upsert_check(
cursor,
"docker_update",
name,
"update_available",
installed=image,
latest="remote_newer",
severity="info",
detail="Neues Image in der Registry verfügbar.",
)
# Fall 3: Container ist vollkommen aktuell
else:
upsert_check(
cursor,
"docker_update",
name,
"ok",
installed=image,
latest="current",
severity="none",
detail="Container-Image ist auf dem neuesten Stand.",
)
def perform_checks():
"""Hauptfunktion: Führt alle System-Prüfungen nacheinander aus."""
# Stellt die Verbindung zur lokalen SQLite-Datenbank her
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# 1. APT Paket-Updates prüfen
run_command("sudo apt update > /dev/null 2>&1")
updates = run_command(
"apt list --upgradable 2>/dev/null | grep -v 'Listing...'"
)
if updates:
count = len(updates.split("\n"))
upsert_check(
cursor,
"apt_update",
"ubuntu-sys",
"update_available",
installed="current",
latest=f"{count} packages",
severity="info",
detail=updates,
)
else:
upsert_check(
cursor,
"apt_update",
"ubuntu-sys",
"ok",
installed="current",
latest="current",
severity="none",
detail="System ist aktuell.",
)
# 2. Prüfen, ob ein System-Neustart ansteht (Kernel-Updates etc.)
if os.path.exists("/var/run/reboot-required"):
upsert_check(
cursor,
"reboot_check",
"ubuntu-sys",
"action_required",
severity="warning",
detail="Reboot erforderlich nach Kernel/Library-Update.",
)
else:
upsert_check(
cursor,
"reboot_check",
"ubuntu-sys",
"ok",
severity="none",
detail="Kein Neustart erforderlich.",
)
# 3. Docker Container auf Updates prüfen
check_docker_containers(cursor)
# Speichert alle Änderungen dauerhaft und schließt die Datenbankverbindung
conn.commit()
conn.close()
print("Wartungs-Check erfolgreich in maintenance.db geschrieben.")
# Standard-Einsprungpunkt beim direkten Ausführen des Skripts
if __name__ == "__main__":
perform_checks()Der 1-Klick-Update-Sprint (apply_maintenance_updates.sh)
Sobald mein täglicher n8n-Report meldet, dass Handlungsbedarf besteht, führe ich manuell mein Update-Skript aus. Es arbeitet in vier sauberen Sprints:
#!/usr/bin/env bash
# SPRINT 1: System-Updates
sudo apt update && sudo apt upgrade -y && sudo apt autoremove -y
# SPRINT 2: Ghost CMS sicher als unprivilegierter User aktualisieren
cd /ghost-blog.de
sudo npm install -g ghost-cli@latest
sudo -u bloguser ghost update
# SPRINT 3: Docker-Dienste unter /srv/ aktualisieren (Lokale Builds überspringen)
# ... zieht frische Images per 'docker compose pull && docker compose up -d' ...
# SPRINT 4: Bereinigung & Re-Check der Datenbank
docker image prune -f
sudo python3 /skriptpfad/check.pyWhat I Learned (Meine 3 wichtigsten Lektionen)
- Trenne Lese- und Schreibprozesse strikt: Lass Automatisierungen vollautomatisch den Zustand prüfen, aber führt schreibende Updates kontrolliert manuell aus. Das schützt euch vor ungewollten Breaking Changes mitten in der Woche.
- Vertraue keinen alten Linux-Reflexen bei Docker: Bei modernen Docker-Installationen mit
containerd-Backend liefertdufalsche Werte. Nutzt zur Speicherdiagnose immerdocker system df. - Exception-based Reporting spart Nerven: Über einen n8n-Workflow erhalte ich morgens in meinem SilverBullet-Second-Brain nur dann eine Nachricht, wenn in
daten.dbein Status nicht aufoksteht. Kein News-Spam, nur relevante Action-Items.
Das HomeLab v2.2 läuft nun stabiler und transparenter denn je.