Der Rollback zog das richtige Image aus der Registry. Der Dienst startete. Und trotzdem endete der Lauf mit rc=1 und der Zeile ROLLBACK fehlgeschlagen -- MANUELLER EINGRIFF. Der Grund war eine einzige Zeile in einer Datei, die niemand zurückgerollt hatte: der Host-Port 8209:8202 in der Compose-Datei des kaputten Commits.
Die Lücke entsteht, wenn man Deployment mit Image gleichsetzt: Ein Container ist Image plus Compose-Datei plus Umgebung. Rollt man nur den Tag zurück, rollt man ein Drittel zurück.
Das Problem
Die MCP-Backends auf srv-rebellion liefen am Git vorbei. Live-Patches waren direkt im Checkout gemacht und nie committet — zweimal war eine server.py.bak der einzige Hinweis darauf, dass jemand den Code von Hand angefasst hatte. Ein Rebuild zog dann eine neue Major-Version von PyPI und lief in einen Crash-Loop: rund zehn Minuten down, weil requirements.txt nur mcp[cli]>=1.0.0 sagte und die Bibliothek inzwischen ihren Namen gewechselt hatte. Das alte Image war zu dem Zeitpunkt schon verdrängt, :latest zeigte auf einen vier Wochen alten kaputten Build.
Dazu kam ein älterer Fund: In der ersten gepushten Historie lag eine Backup-Datei mit allen Secrets; das Repo musste neu angelegt werden. Und ein früherer Crash-Loop lief 17 Stunden unbemerkt, weil niemand den Dienst überwachte.
Drei Probleme, eine Ursache: Es gab keinen Ort für den Soll-Zustand. Wenn der laufende Container die Wahrheit ist, ist jeder Rollback ein Ratespiel.
Die Diagnose
Der Reconciler ist bewusst klein: Backend-Liste lesen, origin holen, mit dem zuletzt deployten SHA vergleichen, sonst nichts. flock sorgt dafür, dass immer nur ein Lauf aktiv ist. Der Fetch hat 30 Sekunden Zeit. Ist der SHA gleich, endet der Lauf ohne Aktion. Steht origin auf der Bad-Liste, wird nicht erneut deployt. Ist der Checkout dirty, wird nicht deployt — dann hat jemand live gepatcht.
if ! ferr=$(timeout 30 git -C "$dir" -c safe.directory="$dir" fetch --quiet origin "$branch" 2>&1); then
log "WARN $name: fetch fehlgeschlagen -- skip (${ferr//$'\n'/ })"; return 0
fi
if [[ "$origin_sha" == "$deployed_sha" && "$FORCE" -eq 0 ]]; then return 0; fi
if [[ "$origin_sha" == "$bad_sha" && "$FORCE" -eq 0 ]]; then
log "ERROR $name: origin=$origin_sha steht auf der Bad-Liste (letzter Rollback) -- uebersprungen, Fix noetig"; return 1
fi
Die Timeouts sind der eigentliche Blast-Radius-Schutz: Build 1800 Sekunden, Push 300 Sekunden und fail-open — ein Registry-Push-Fehler blockiert den Deploy nicht, er nimmt nur die Registry als Rollback-Quelle weg —, up 300 Sekunden. Ohne diese Grenzen hängt ein kaputter Build den nächsten Lauf mit auf.
Danach entscheidet ein Health-Gate, nicht das Skript. Die Stufenzahl ist je Backend verschieden — drei, vier oder fünf. Mal reicht „Container läuft, SHA stimmt, MCP antwortet", mal kommt ein Versand-Gate oder ein Inhaltsbeweis dazu, damit „gesund, aber leere Datenbasis" nicht als Erfolg durchgeht.
Die Backend-Tabelle im Repo ist der Stand vom 19.09. Eines der Backends ist seit dem 26.09. abgeschaltet und läuft nicht mehr im Regelbetrieb.
Die Ursache
Der Fault-Injection-Test am Canary: lokales Rollback-Image löschen, dann einen Fehler einbauen, der das Health-Gate reißt. Der Reconciler hat nachweislich aus der Registry gepullt — Pulling/Pulled im Build-Log. Der Registry-Rollback funktioniert also.
Der Gesamt-Rollback funktionierte nicht: rc=1, MANUELLER EINGRIFF. Der Testfehler lag nicht im Image, sondern in der Compose-Datei — der Host-Port war auf 8209:8202 verlegt worden. Der Reconciler verwendete für den Rollback aber weiterhin die Compose des neuen, kaputten Commits. Image alt, Portzuordnung neu, Dienst down.
Ein Image-Tag-Rollback rollt die Compose nicht mit. Und die Compose kann genauso die Ursache sein wie der Code. Ports, Volumes, env_file-Pfade, Netzwerke — alles lebt in einer Datei, die im selben Commit steckt wie der Fehler.
Dazu kam eine zweite Falle, die nichts mit Logik zu tun hat: docker image prune -a löscht die last_good-Tags. Damit ist die Rollback-Fähigkeit weg — genau in dem Moment, in dem man sie braucht. Das ist der Standardbefehl aus jedem „Docker aufräumen"-Tutorial.
Der Fix
Vor dem up wird der Deploy-Checkout auf den last_good-Commit zurückgesetzt:
if git -C "$dir" -c safe.directory="$dir" cat-file -e "${last_good}^{commit}" 2>/dev/null \
&& git -C "$dir" -c safe.directory="$dir" checkout --detach --quiet "$last_good" 2>/dev/null; then
log "INFO $name: Checkout auf last_good $last_good zurueckgesetzt"
else
log "WARN $name: Checkout auf last_good nicht moeglich -- nutze Compose des neuen Commits"
fi
Der cat-file-Guard ist wichtig: Ist der Commit im Checkout nicht vorhanden — etwa weil force-gepusht wurde —, läuft der Rollback trotzdem weiter, nur mit einem WARN und der Compose des neuen Commits.
Der Rest der Reihenfolge bleibt: erst das lokale Image, dann der Registry-Pull als Fallback. Erfolg ist rc=2, Misserfolg bleibt MANUELLER EINGRIFF mit rc=1. Die Wiederholung des Fault-Tests ergab ROLLBACK ok, rc=2, Dienst wieder gesund. Der Unterschied zwischen dem ersten und dem zweiten Lauf war eine Zeile vor dem up.
Für die Retention gilt dieselbe Logik in klein: gelöscht werden ausschließlich 40-stellige SHA-Tags der Backend-Repos, benannte Tags wie :latest oder :rollback nie. Default ist --dry-run, erst der systemd-Service ruft --apply. Bei kaputtem State wird das Backend übersprungen. Der Erstlauf entfernte 18 Tags; frei wurde 7,2 GB, danach 7,3 GB.
Betrieben wird das auf einem kleinen Host: srv-rebellion trägt die Backends, srv-coruscant die Registry. Dafür reicht ein Mini-PC — Minisforum MS-01 (i5-12600H, Barebone ohne RAM/SSD) ist die Klasse, in der Reconciler plus ein paar Container bequem Platz haben. Wer kleiner anfängt, kommt mit einem gebrauchten Lenovo ThinkCentre M720q Tiny (i5-8400T, 16 GB, generalüberholt) oder einem Mini-PC N100 Beelink S12 Pro hin; die obere Klasse lohnt erst, wenn mehrere Backends und Builds parallel laufen.
Was du mitnimmst
Git ist die Wahrheit, nicht der Container. Solange Live-Patches im Checkout liegen, ist jeder automatische Deploy ein Glücksspiel. Der Dirty-Check ist der Schutz davor, dass ein Rebuild die einzige Kopie einer Änderung überschreibt.
Health entscheidet, nicht der Exit-Code von up. Ein gestarteter Container ist kein gesunder Dienst. Die Prüfstufen dürfen je Backend unterschiedlich sein — wichtig ist, dass die letzte Stufe das prüft, wofür der Dienst existiert.
Ein Rollback muss den Checkout mitnehmen. Ein Rollback, der nur das Image zurückholt, ist ein halber Rollback. Die Compose-Datei steckt im selben Commit wie der Code, und sie kann die Ursache sein. Deshalb: git checkout --detach <last_good> vor dem up, nicht danach und nicht gar nicht.
Und prüfe deine Aufräumbefehle. docker image prune -a ist der schnellste Weg, die eigene Rollback-Fähigkeit zu löschen. Wer sie braucht, merkt es erst, wenn es zu spät ist.