Deployment per FTP: vom Kopieren von Hand zu Releases mit Rückweg

Ein abgebrochener FTP-Upload ließ alten und neuen Code gemischt auf dem Server, Buchungen fielen aus. Build in der CI, Umschalten in einem Schritt, Rückweg.

Firma
Reisebüro mit Online-Buchung, 20 Beschäftigte
Team
Ein Entwickler und ein Webdesigner
Technik
Laravel 11, MySQL, Nginx und PHP-FPM auf einem VPS
Rahmen
Keine Testumgebung, Änderungen gehen direkt in die Produktion

01Ausgangslage

Der Entwickler ändert den Code lokal und lädt die geänderten Dateien mit einem FTP-Client direkt in das Verzeichnis, aus dem die Website läuft. Danach meldet er sich per SSH am Server an und führt nach Bedarf composer install und Migrationen von Hand aus. Kleine Korrekturen entstehen manchmal direkt auf dem Server.

Die FTP-Zugangsdaten sind im FTP-Client auf zwei Rechnern gespeichert. FTP schickt sie unverschlüsselt über das Netz.

  1. Rechner des Entwicklers
  2. FTP-Client (unverschlüsselt)
  3. VPS: Verzeichnis der laufenden Website
  4. Nginx / PHP-FPM
  5. MySQL

02Das Problem

Mitten in der Saison brach beim Hochladen einer größeren Änderung die Verbindung ab. Auf dem Server blieb die Hälfte der neuen und die Hälfte der alten Dateien. Die Buchungsseite endete mit einem fatalen PHP-Fehler, bis der Rest hochgeladen war. Das Nginx-Log zeigte, dass jede Buchung in diesem Zeitraum betroffen war.

Einige Tage später zeigte sich ein zweiter Fehler: Eine Datei war beim Hochladen vergessen worden. Der Fehler trat nur auf einem Weg durch die Buchung auf, und ein Kunde fand ihn.

Zurück zur vorherigen Version ging es nicht. Niemand wusste genau, was auf dem Server lag, weil ein Teil der Korrekturen dort entstanden und nicht in Git war.

03Technische Ursache

Das Deployment war nicht atomar. Dateien wurden eine nach der anderen ersetzt, während die Website Anfragen bediente, also lief während des Uploads und nach dessen Abbruch eine Mischung aus zwei Versionen.

Es gab keine gebaute und getestete Version. Auf den Server kam, was der Entwickler gerade auswählte, ohne Tests und ohne Prüfung auf Vollständigkeit.

Der Server wich vom Repository ab, das Repository war also keine verlässliche Quelle der Wahrheit, und es gab nichts, wohin man zurückkehren konnte.

Daneben ein Sicherheitsproblem: FTP schickt das Passwort unverschlüsselt, und der FTP-Client speichert es auf der Platte. Wer es bekommt, kann den Code der laufenden Website ändern.

04Untersuchung

Der Serverinhalt wurde in einen eigenen Zweig geholt. git diff zeigte direkt auf dem Server geänderte Dateien, die in Git fehlten. Sie wurden in den Hauptzweig übernommen, damit nichts verloren geht.

Die Logs von Nginx und PHP-FPM zeigten, wie lange der Ausfall dauerte und welche Wege er traf.

Die Konfiguration des FTP-Servers bestätigte, dass er keine verschlüsselte Verbindung verlangte.

05Behebung

Alles liegt in Git, und der Server ändert sich ausschließlich durch ein Deployment. FTP ist abgeschaltet und sein Passwort geändert.

GitHub Actions bei jedem Merge in den Hauptzweig: composer install --no-dev --optimize-autoloader, Build des Frontends, php artisan test und Packen der fertigen Version. Schlagen die Tests fehl, wird nichts ausgeliefert.

Das Paket kommt per SSH auf den Server, mit einem Schlüssel, der nur der CI gehört, und einem eigenen Benutzer mit Rechten nur auf das Anwendungsverzeichnis. Jede Version wird in ein eigenes Verzeichnis entpackt, und die Website schaltet in einem Schritt darauf um.

deploy/release.sh
#!/usr/bin/env bash
set -euo pipefail

APP=/srv/booking
REL="$APP/releases/$(date -u +%Y%m%d%H%M%S)"

mkdir -p "$REL"
tar -xzf /tmp/release.tar.gz -C "$REL"      # built and tested in CI
ln -s "$APP/shared/.env" "$REL/.env"
rm -rf "$REL/storage" && ln -s "$APP/shared/storage" "$REL/storage"

cd "$REL"
php artisan migrate --force
php artisan config:cache
php artisan route:cache

# atomic switch: rename(2) replaces the link in one step, ln -sfn does not
ln -s "$REL" "$APP/current.tmp"
mv -T "$APP/current.tmp" "$APP/current"

sudo systemctl reload php8.3-fpm            # drops OPcache entries of the old release
curl -fsS --retry 3 https://rezervacie.example.sk/up > /dev/null

# keep the last five releases for rollback
ls -1d "$APP"/releases/* | head -n -5 | xargs -r rm -rf

Nginx muss den symbolischen Link bei jeder Anfrage auflösen. Sonst würde PHP sich die Pfade der alten Version merken.

nginx: server block
root /srv/booking/current/public;

location ~ \.php$ {
    include fastcgi_params;
    # resolve the symlink per request, so PHP never mixes two releases
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_param DOCUMENT_ROOT $realpath_root;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}

Der Rückweg ist derselbe letzte Schritt mit dem vorherigen Verzeichnis plus ein Reload von PHP-FPM, also Sekunden. Der Benutzer, unter dem die CI ausliefert, darf per sudo nur diesen einen Reload ausführen, sonst nichts.

Migrationen werden so geschrieben, dass auch die vorherige Codeversion mit dem neuen Schema funktioniert: Spalten werden zuerst ergänzt und alte erst in einem späteren Deployment entfernt. Sonst hilft das Zurücksetzen des Codes nicht.

06Warum die Lösung wirkt

Auf demselben Dateisystem ruft mv -T rename auf, das den Link current in einem Schritt ersetzt. Jede Anfrage sieht entweder die ganze alte oder die ganze neue Version, nie eine Mischung. ln -sfn würde den Link erst löschen und dann neu anlegen, mit einem kurzen Moment ohne ihn.

$realpath_root in Nginx und der Reload von PHP-FPM sorgen dafür, dass PHP nach dem Umschalten die Dateien der neuen Version liest und OPcache nicht an den alten Pfaden festhält.

Die Version wird in der CI gebaut und getestet, auf den Server kommt also immer ein Ganzes, das die Tests bestanden hat, keine Auswahl von Dateien.

Alte Versionen bleiben auf der Platte, der Rückweg baut und lädt also nichts. Er schaltet nur den Link um.

Ein SSH-Schlüssel nur für die CI ersetzt ein FTP-Passwort, das unverschlüsselt durchs Netz ging.

Was das nicht löst: Das Umschalten des Links macht keine Datenbankmigration rückgängig. Deshalb werden Migrationen abwärtskompatibel geschrieben.

07Überprüfung

  • Ein absichtlich kaputter Test stoppte das Deployment schon in der CI.
  • Während eines Deployments lief eine Schleife von Anfragen auf die Buchungsseite, keine lieferte einen Fehler.
  • Der Rückweg zur vorherigen Version wurde echt ausprobiert und in den Ablauf aufgenommen.
  • Der FTP-Port antwortet auf dem Server nicht mehr.

08Ergebnisse

Behoben

  • Ein Deployment kann keine Mischung aus zwei Versionen mehr auf dem Server hinterlassen.
  • Nichts kommt auf den Server, ohne die Tests zu bestehen.
  • Das FTP-Passwort geht nicht mehr übers Netz, FTP ist abgeschaltet.

Risiko gesenkt

  • Die Rückkehr zur vorherigen Version dauert Sekunden.
  • Server und Repository stimmen überein, wir wissen immer, was läuft.

Weiter offen

  • Es gibt keine Testumgebung, Änderungen treffen echte Daten erst in der Produktion.
  • Migrationen lassen sich nur von Hand zurücknehmen.

09Grenzen

  • Der SSH-Schlüssel in der CI ist eine starke Berechtigung. Wer das Repository oder seine Geheimnisse kontrolliert, kann alles ausliefern.
  • Tests sind nur so gut wie ihre Abdeckung. Ein Teil der Buchung hat noch keine.
  • Es gibt einen Server. Deployment ohne Ausfall heißt nicht Betrieb ohne Ausfall.

10Erkenntnisse

  • Ein Deployment sollte ein atomarer Schritt sein, keine Folge von Kopien.
  • Die einzige Quelle der Wahrheit ist das Repository. Was direkt auf dem Server geändert wird, verschwindet beim nächsten Deployment.
  • Den Rückweg ausprobieren, bevor man ihn braucht.
  • Migrationen so schreiben, dass auch die vorherige Codeversion mit ihnen läuft.
  • FTP mit Passwort gehört der Vergangenheit an. SSH mit einem Schlüssel für einen einzigen Zweck ist sicherer und bequemer.

11Nächste Schritte für eine kleine Firma

  1. 1.Eine Testumgebung auf demselben Server mit eigener Datenbank.
  2. 2.Vor jeder Migration automatisch ein Datenbank-Backup.
  3. 3.Auf GitHub vor dem Merge in den Hauptzweig einen Pull Request und bestandene Tests verlangen.
  4. 4.Tests für den gesamten Buchungsablauf ergänzen.
Kontakt aufnehmen

Erkennen Sie Ihre eigene Firma darin?

Schreiben Sie uns, worum es geht. Wir antworten innerhalb eines Werktags und sagen Ihnen, ob es Arbeit für uns ist, auch wenn die Antwort nein lautet.