Eine Website mit dreihundert Seiten liegt auf einem gewöhnlichen VPS hinter einem Hosting-Panel. Kein git auf dem Server, kein CI-Agent, kein Release-Umschalter. Eine einzige Veröffentlichung berührt dreißig Dateien: Seiten in vier Sprachen, ein Bild, Rubrik-Indizes, Feeds, fünf Sitemaps. Jede Datei geht als eigener scp hoch. Ein Verbindungsabbruch bei der fünfzehnten ist Arbeitsalltag, kein exotischer Sonderfall.
Atomarität für dieses Bündel ist nicht zu haben. Eine andere Eigenschaft schon: „abgebrochen — noch einmal laufen lassen, und das Ergebnis stimmt“. Der Abstand zwischen diesen beiden Zielen prägt das gesamte Design.
Zwei Primitive, und beide muss man genau kennen
rename(2) ersetzt das Ziel atomar: kein Prozess sieht je einen Moment, in dem die Datei fehlt. Daher der Standardgriff — in eine temporäre Datei hochladen, dann umbenennen:
scp file host:/path/page.html.tmp.$RUN_ID
ssh "mv /path/page.html.tmp.$RUN_ID \
/path/page.html"
Die Einschränkung, an der eine saubere Implementierung zerbricht: rename liefert EXDEV, wenn die Pfade auf verschiedenen eingehängten Dateisystemen liegen — selbst wenn es dasselbe Dateisystem an zwei Mountpoints ist. Ein gemeinsames Staging-Verzeichnis wie /tmp/upload wirkt aufgeräumter als eine temporäre Datei neben dem Ziel und macht aus dem Umbenennen stillschweigend ein Kopieren. Kopieren ist nicht atomar.
mkdir(2) liefert die zweite Hälfte: das Verzeichnis wurde entweder von Ihnen angelegt oder existiert bereits (EEXIST). Ein atomares Test-and-Set und der einzige Mutex, der über nacktes ssh verfügbar ist, ohne auf der Gegenseite irgendetwas zu installieren.
Wechselseitiger Ausschluss ist hier keine Formalie. Der Deploy-Zustand liegt in einer Datei, die gelesen, geändert und zurückgeschrieben wird. Zwei gleichzeitige Läufe erzeugen ein Lost Update, und das sieht harmlos aus: die Dateien des ersten Deploys bleiben liegen und sind unter ihren Adressen erreichbar. Er verschwindet nur aus den Indizes und der Sitemap — und kommt nie zurück, weil der nächste Lauf den bereits beschädigten Zustand als Grundlage nimmt.
Atomar ist die Datei, nicht das Bündel
Dreißig aufeinanderfolgende Uploads lassen sich nicht zu einer Operation zusammenfassen. Die erste von zwei Entscheidungen: in Abhängigkeitsreihenfolge ausrollen, damit nichts vor dem erscheint, worauf es verweist — zuerst Assets, dann Seiten, dann Indizes und Feeds, und erst danach die Sitemap.
Ein Abbruch mittendrin hinterlässt eine unfertig ausgerollte Website statt einer Website mit Links ins Leere. Prüfen muss man das anhand einer Adressliste, nicht mit dem Auge: lässt man das Bild aus der Prüfung heraus, bekommt man leicht eine Seite mit Code 200, deren og:image 404 liefert. Genau aus diesem Grund überlebte dieser Defekt bei mir auf vier Rubrikseiten — die Prüfung sah auf Dokumente und nicht auf Assets.
Der Zustandseintrag wird zuletzt geschrieben
Die zweite Entscheidung läuft unter „das Manifest ist der Commit-Marker“, und die naheliegende Lesart dieses Satzes ist falsch.
Falsch: vor dem Zustandseintrag sei nichts sichtbar. Indizes, Hubs und Sitemap werden aus dem lokal geänderten Zustand gerendert und gehen vor ihm auf den Server. Zur letzten Stufe zeigt die Live-Website das neue Material bereits vollständig, während der Eintrag auf dem Server noch der alte ist.
Richtig: der Zustandseintrag ist ein dauerhaftes Faktum — „die Transaktion ist bis zum Ende gekommen“ — und die Eingabe für den nächsten Lauf. Die Invariante, die tatsächlich trägt, lautet:
Nehmen wir die umgekehrte Reihenfolge. Der Eintrag ist zuerst committet, der Upload scheitert. Der nächste Lauf sieht das Objekt im Eintrag und weigert sich, es als neu auszurollen — bleibt der Update-Modus, der eine lebende Vorgängerversion voraussetzt. Jedes weitere Rendern der Indizes greift das Objekt aus dem Eintrag und verlinkt auf eine Seite, die es nicht gibt. Die Sitemap meldet Suchmaschinen eine Adresse, die 404 liefert.
In der richtigen Reihenfolge hinterlässt ein Fehlschlag den Zustand „nicht ausgerollt“: das Objekt fehlt im Eintrag, ein Wiederholungslauf nimmt den normalen Weg und schreibt alles neu. Wo das Ziel ein echtes Umschalt-Primitiv besitzt, erledigen dieselbe Aufgabe Canary-Releases und Progressive Delivery — hier übernimmt die Reihenfolge der Operationen diese Rolle.
Die Fehlerfenster gehören aufgeschrieben
| Abbruch bei | Website | Eintrag | Behebung |
|---|---|---|---|
| Seiten-Upload | Seiten leben, kein Index verlinkt sie | alt | Lauf als neu wiederholen |
| Index-Upload | Indizes verlinken, Sitemap veraltet | alt | dito |
| Prüfung | visuell konsistent | alt | dito |
| nach dem Commit | konsistent | neu | nichts |
Die Fehlermeldung sollte den Zustand direkt benennen. „Seiten können live, aber nicht indexiert sein“ nützt mehr als „deploy failed“: der Operator liest sie im denkbar schlechtesten Moment und sollte das Fehlermodell nicht aus dem Quelltext rekonstruieren müssen. Es ist dieselbe Frage wie bei der Definition von „wiederhergestellt“ im Incident — benennt man den Zustand nicht, ergänzt ihn jeder für sich.
Die Prüfung kommt vor dem Commit
Alle betroffenen Adressen werden auf 200 geprüft, bevor der Zustand geschrieben wird. Eine einzige Nicht-200 und es gibt keinen Commit; das System bleibt im Zustand „nicht ausgerollt“. Die umgekehrte Reihenfolge macht aus einem Gate einen Bericht über Geschehenes: dieselben Defekte, nur nichts mehr zu reparieren.
Ein Antwortcode bestätigt Erreichbarkeit, nicht Korrektheit. Prüfungen können durchaus Sicherheit vermitteln, wo keine besteht — ein grüner CSP-Header ist ein Beispiel dafür.
Warum kein Symlink und kein rsync
Symlink-Umschaltung verlangt Kontrolle über das Verzeichnislayout, die panelverwaltetes Hosting nicht hergibt. Nebenbei taucht ein Detail auf: der vertraute Befehl tut nicht das, was man von ihm erwartet. Eine strace-Messung auf uutils coreutils 0.8.0:
$ ln -sfn <new> current
unlink("current") = 0
symlink("<new>", "current") = 0
Zwei Operationen, und dazwischen existiert der Pfad nicht. Eine Anfrage, die in dieses Fenster fällt, bekommt 404. Die GNU-coreutils-Dokumentation beschreibt -f wörtlich als Entfernen des vorhandenen Ziels und verspricht keine Atomarität. Die atomare Variante macht ein einziges Umbenennen:
ln -s <new> current.tmp \
&& mv -T current.tmp current
rsync --delay-updates ist das richtige Werkzeug, wenn ein Deploy die Synchronisation eines ganzen Verzeichnisses bedeutet. Die Man-Page ist ehrlich über die Garantie: Dateien sammeln sich in einem Zwischenverzeichnis und werden am Ende „in rascher Folge“ an ihren Platz umbenannt, was das Update „a little more atomic“ macht. Keine Transaktion, nur ein kürzeres Fenster.
Was dieses Design ehrlicherweise nicht leistet
Ein automatisches Rollback gibt es nicht: hochgeladene Dateien werden nicht zurückgenommen, der Rollback läuft von Hand über Backups. kill -9 lässt den Lock hängen — die Freigabe lebt im Cleanup-Block des Prozesses, und der Lock hat kein TTL. Und rename(2) garantiert, dass niemand einen Zwischenzustand sieht, aber nicht, dass das Umbenennen einen Stromausfall auf dem Weg zur Platte übersteht: dafür braucht es ein fsync des Verzeichnisses. Atomarität und Dauerhaftigkeit sind verschiedene Eigenschaften.
Keine dieser Grenzen behindert die Arbeit. Zu tun, als gäbe es sie nicht, schon.