Ein Deployment, bei dem nichts kaputtgeht, ist noch keins ohne Downtime. Entscheidend ist, was ein Besucher sieht, der genau in den drei Minuten dazwischen eine Seite aufruft.
Die Minuten dazwischen
Das einfachste Deployment einer Laravel-Anwendung besteht aus ein paar Befehlen
im Projektverzeichnis auf dem Server: git pull,
composer install, npm run build, die Migrationen, die
Caches neu bauen. Das funktioniert, und für viele Projekte reicht es lange.
Das Problem sind nicht die Befehle, sondern dass sie im laufenden Verzeichnis
arbeiten. Während composer install läuft, liegt in
vendor/ ein Gemisch aus alten und neuen Paketen. Nach dem
git pull zeigt der Route-Cache noch auf Controller-Methoden, die es
vielleicht nicht mehr gibt. Und solange npm run build arbeitet,
verweist das ausgelieferte HTML auf Asset-Dateien, die gerade gelöscht und neu
geschrieben werden. Wer in diesem Fenster eine Seite aufruft, bekommt einen
500er oder eine Seite ohne Styles.
php artisan down macht daraus ein sauberes Fenster mit
Wartungsseite. Das ist ehrlicher, aber es bleibt Downtime. Das Ziel ist ein
Deployment, bei dem die alte Version so lange vollständig weiterläuft, bis die
neue vollständig fertig ist, und dann in einem einzigen Schritt umgeschaltet
wird.
Jede Version in ihrem Verzeichnis
Der Kern des Verfahrens: Jedes Deployment bekommt ein eigenes Verzeichnis. Dort
wird die neue Version komplett vorbereitet, während die alte unberührt
weiterläuft. Der Webserver kennt keines dieser Verzeichnisse direkt, sondern
nur einen Symlink namens current, der auf das aktive Release zeigt.
Als Verzeichnisbaum sieht dasselbe so aus:
/var/www/app/
├── current -> releases/20261106143012
├── releases/
│ ├── 20261104091547/
│ ├── 20261105172230/
│ └── 20261106143012/
└── shared/
├── .env
└── storage/
Die Document Root zeigt auf /var/www/app/current/public. In
shared/ liegt alles, was ein Deployment überleben muss: die
.env mit den Zugangsdaten und storage/ mit Logs,
Sessions, hochgeladenen Dateien und dem Datei-Cache. Jedes Release bindet
beides per Symlink ein, statt eine eigene Kopie zu haben. Sonst wären nach dem
Umschalten alle Besucher abgemeldet und die Uploads der letzten Woche weg.
Der Ablauf als Skript
Ein Deployment-Tool braucht es dafür nicht. Das folgende Skript enthält alle Schritte und lässt sich so lesen, wie es abläuft. Wichtig ist die Reihenfolge: Alles, was dauert oder scheitern kann, passiert im neuen Verzeichnis, bevor irgendein Besucher davon etwas merkt.
#!/usr/bin/env bash
set -euo pipefail
APP=/var/www/app
RELEASE="$APP/releases/$(date +%Y%m%d%H%M%S)"
git clone --depth 1 --branch main git@github.com:beispiel/app.git "$RELEASE"
cd "$RELEASE"
# Geteilter Zustand: eine .env und ein storage/ für alle Releases
ln -s "$APP/shared/.env" .env
rm -rf storage
ln -s "$APP/shared/storage" storage
composer install --no-dev --optimize-autoloader --no-interaction
npm ci
npm run build
# Gebaute Assets des alten Releases behalten, damit offene Seiten
# ihre bisherigen Dateien weiter laden können. Gleicher Hash heißt
# gleicher Inhalt, Überschreiben ist also harmlos.
if [ -d "$APP/current/public/build/assets" ]; then
cp -r "$APP/current/public/build/assets/." public/build/assets/
fi
php artisan storage:link
php artisan migrate --force
php artisan optimize
# Umschalten in einem einzigen Schritt
ln -sfn "$RELEASE" "$APP/current_tmp"
mv -T "$APP/current_tmp" "$APP/current"
php artisan queue:restart
# Die letzten fünf Releases bleiben für einen Rollback liegen.
# Die Zeitstempel im Namen sortieren chronologisch.
ls -1d "$APP"/releases/* | head -n -5 | xargs -r rm -rf
Drei Zeilen darin sind leicht zu übersehen. php artisan optimize
baut die Caches für Konfiguration, Events, Routen und Views, und zwar im neuen
Verzeichnis. Würde man sie erst nach dem Umschalten bauen, liefe die neue
Version die ersten Sekunden ohne Cache oder mit dem alten. Das Kopieren der
alten Assets fängt Besucher ab, die die Seite vor dem Deployment geöffnet haben:
Ihr HTML verweist auf Dateinamen mit dem alten Hash, und ein nachgeladener Chunk
liefe sonst ins Leere. Weil Vite jeden Dateinamen mit einem Hash versieht,
kommen sich alte und neue Dateien nicht in die Quere.
queue:restart schließlich ist nötig, weil Queue-Worker
langlebige Prozesse sind, die den Code beim Start einmal laden. Ohne Neustart
arbeiten sie mit der alten Version weiter. Der Befehl beendet sie nicht hart:
Jeder Worker bringt seinen aktuellen Job zu Ende und beendet sich dann. Neu
gestartet wird er von einem Process Manager wie Supervisor. Das Signal läuft
über den Cache, ein funktionierender Cache-Treiber ist also Voraussetzung.
Atomar umschalten
Warum nicht einfach ln -sfn "$RELEASE" current? Je nach Version
der coreutils arbeitet ln dabei in zwei Schritten: Erst wird der
alte Symlink gelöscht, dann der neue angelegt. Dazwischen gibt es
current für einen kurzen Moment nicht. Bei genug Last trifft
genau dieser Moment einen Request.
Der Umweg über current_tmp vermeidet das. Der neue Symlink wird
unter einem anderen Namen angelegt, dann benennt mv -T ihn um. Das
ist ein einzelner rename()-Systemaufruf, und der ist unter Linux
atomar: Jeder Request sieht entweder das alte oder das neue Ziel, nie keines.
Die Option -T sorgt dafür, dass mv das Ziel als Datei
behandelt und den Symlink nicht in das Verzeichnis verschiebt, auf das
current zeigt. Sie gehört zu GNU coreutils, auf jedem üblichen
Linux-Server ist sie also vorhanden.
Der Symlink und PHP-FPM
Nach dem Umschalten kann es passieren, dass die Seite trotzdem die alte Version
ausliefert, manchmal auch eine Mischung aus beiden. Die Ursache ist PHP-FPM, der
FastCGI Process Manager, der die PHP-Prozesse hinter dem Webserver betreibt.
PHP merkt sich, wohin ein Pfad aufgelöst wurde. Dieser Realpath-Cache gilt nach
der Voreinstellung von realpath_cache_ttl 120 Sekunden lang. Bis
er abläuft, führt current/public/index.php für einen Prozess noch
ins alte Release. Und OPcache hält den kompilierten Code unter dem aufgelösten
Pfad vor.
Die sauberste Lösung ist, PHP gar keinen Symlink-Pfad zu geben. Unter nginx gibt
es dafür die Variable $realpath_root, die den Symlink auflöst, bevor
der Request an PHP-FPM geht:
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
# Pfad mit aufgelöstem Symlink: PHP sieht releases/…, nie current/
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
}
Jeder Request nach dem Umschalten bekommt damit den Pfad im neuen Release, und
OPcache legt dafür neue Einträge an. Die alten verdrängen sich mit der Zeit von
selbst. Wo du die Webserver-Konfiguration nicht anfassen kannst, etwa bei einem
Hosting-Panel, ist ein Reload von PHP-FPM nach dem Umschalten die Alternative,
zum Beispiel per systemctl reload php8.4-fpm. Ein Reload lässt
laufende Requests zu Ende arbeiten und startet erst dann neue Prozesse.
opcache_reset() auf der Kommandozeile hilft dagegen nicht: Die CLI
hat ihren eigenen OPcache, der mit dem von PHP-FPM nichts zu tun hat.
Migrationen für zwei Versionen
Die Migrationen laufen im Skript vor dem Umschalten. Für einen Moment arbeitet also die alte Version gegen das neue Schema. Das ist kein Sonderfall, sondern die Regel bei jedem Deployment ohne Wartungsmodus. Daraus folgt eine einfache Bedingung: Jede Migration muss für die alte und die neue Version passen.
Neue Tabellen und neue, nullable Spalten erfüllen das von selbst. Umbenennen und Löschen nicht. Eine umbenannte Spalte fehlt der alten Version sofort, und jeder Request, der sie liest, endet mit einem Fehler. Deshalb wird so etwas auf zwei Deployments verteilt, oft als Expand and Contract bezeichnet:
- Erstes Deployment: neue Spalte anlegen, der Code schreibt in beide Spalten und liest die neue mit Rückfall auf die alte.
- Dazwischen: Bestandsdaten per Command oder Job in die neue Spalte übertragen.
- Zweites Deployment: Code nutzt nur noch die neue Spalte, die Migration löscht die alte.
// Erstes Deployment: nur erweitern, nichts entfernen
return new class extends Migration
{
public function up(): void
{
Schema::table('customers', function (Blueprint $table) {
$table->string('display_name')->nullable()->after('name');
});
}
};
Das kostet ein zweites Deployment, macht aber jeden einzelnen Schritt ungefährlich, auch den Rollback im nächsten Abschnitt.
Zurück in einer Sekunde
Weil die vorigen Releases vollständig im Verzeichnis liegen, inklusive
vendor/, gebauter Assets und Caches, ist ein Rollback nur ein
weiterer Symlink-Wechsel. Kein Build, kein composer install,
nichts, was in diesem Moment scheitern könnte:
#!/usr/bin/env bash
set -euo pipefail
APP=/var/www/app
CURRENT=$(readlink -f "$APP/current")
PREVIOUS=$(ls -1d "$APP"/releases/* | grep -B1 -Fx "$CURRENT" | head -n 1)
if [ "$PREVIOUS" = "$CURRENT" ]; then
echo "Kein älteres Release vorhanden" >&2
exit 1
fi
ln -sfn "$PREVIOUS" "$APP/current_tmp"
mv -T "$APP/current_tmp" "$APP/current"
php "$APP/current/artisan" queue:restart
Die Datenbank fasst der Rollback bewusst nicht an. migrate:rollback
im Ernstfall ist riskant, weil eine Down-Migration selten getestet wird und
Daten löschen kann, die seit dem Deployment geschrieben wurden. Mit Migrationen
nach dem Expand-and-Contract-Muster ist das auch nicht nötig: Das alte Release
kommt mit dem erweiterten Schema zurecht, es kennt die neue Spalte nur nicht.
Fünf Releases aufzuheben, ist ein guter Mittelwert. Mehr kostet nur Speicherplatz, denn wer weiter als fünf Versionen zurück muss, hat ein Problem, das ein Symlink nicht löst.
Fazit
Ein Deployment ohne Downtime ist weniger Werkzeug als Reihenfolge: vorbereiten, wo niemand zusieht, in einem Schritt umschalten, und den Rückweg offen lassen. Die Release-Verzeichnisse liefern alle drei. Dazu kommen zwei Stellen, die man einmal kennen muss: PHP-FPM, das sich an alte Pfade erinnert, und Migrationen, die für einen Moment zwei Versionen bedienen.
Das ist derselbe Gedanke wie bei einer resilienten Systemarchitektur: Fehler lassen sich nicht ausschließen, aber man kann festlegen, wie weit sie reichen. Ein abgebrochener Build erreicht hier keinen Besucher, und ein fehlerhaftes Release ist eine Sekunde später wieder weg. Deployer und Envoyer setzen das gleiche Verfahren als fertiges Werkzeug um. Wer das Skript einmal selbst geschrieben hat, versteht, was sie tun, und merkt schneller, wenn etwas schiefläuft.
Quellen
Zu php artisan optimize und dem Neustart von Diensten die
Laravel-Dokumentation zum Deployment,
zum Verhalten von queue:restart der Abschnitt
Queue Workers and Deployment,
zum Standardwert von realpath_cache_ttl die
PHP-Referenz der php.ini-Direktiven.