# Betrieb: Sicherung, Wiederherstellung, Aufräumen ## Was gesichert werden muss | Was | Wo | Ohne das … | |---|---|---| | Datenbank | Volume `einkaufsapp_db_data` | ist alles weg | | `.env` | Projektverzeichnis | Sessions ungültig, Push-Anmeldungen tot, DB-Zugang verloren | | Projektverzeichnis | Quelltext | neu aus dem Archiv holen – kein Datenverlust | Der Quelltext gehört ins Versionsverwaltungssystem, die `.env` ausdrücklich **nicht**: Sie enthält Datenbankpasswort, `SECRET_KEY`, SMTP-Zugangsdaten und den privaten VAPID-Schlüssel. Sie gehört in den Passwortmanager oder in eine verschlüsselte Sicherung. Warum die `.env` so wichtig ist: Ein verlorener `SECRET_KEY` ist verschmerzbar (alle müssen sich neu anmelden), ein verlorener `VAPID_PRIVATE_KEY` auch (alle müssen Push neu einschalten). Ein verlorenes `DB_PASSWORD` bei erhaltener Datenbank ist dagegen ärgerlich – dann kommt man an die eigenen Daten nur noch über das Root-Passwort heran. ## Datenbanksicherung ```bash cd /opt/einkaufsapp # Konsistenter Auszug ohne Anhalten der Anwendung docker compose exec -T db mariadb-dump \ -u root -p"$(grep '^DB_ROOT_PASSWORD=' .env | cut -d= -f2-)" \ --single-transaction --quick --routines --events \ --default-character-set=utf8mb4 \ einkaufsapp | gzip > "einkaufsapp-$(date +%F).sql.gz" ``` `--single-transaction` ist der entscheidende Schalter: Er nimmt einen konsistenten Stand aus einem Zeitpunkt, ohne die Tabellen zu sperren. Ohne ihn kann eine Sicherung mitten in einer Änderung entstehen – zum Beispiel mit einem Listeneintrag, dessen Artikel noch fehlt. `--quick` verhindert, dass große Tabellen komplett in den Arbeitsspeicher geladen werden. ### Als tägliche Aufgabe ```bash sudo tee /etc/cron.daily/einkaufsapp-backup >/dev/null <<'EOF' #!/bin/sh set -eu cd /opt/einkaufsapp PASS=$(grep '^DB_ROOT_PASSWORD=' .env | cut -d= -f2-) DEST=/var/backups/einkaufsapp mkdir -p "$DEST" docker compose exec -T db mariadb-dump -u root -p"$PASS" \ --single-transaction --quick --routines --events \ --default-character-set=utf8mb4 einkaufsapp \ | gzip > "$DEST/db-$(date +%F).sql.gz" # Vierzehn Tage aufbewahren find "$DEST" -name 'db-*.sql.gz' -mtime +14 -delete EOF sudo chmod +x /etc/cron.daily/einkaufsapp-backup ``` Der Auszug landet damit im Dateisystem und wird von deiner vorhandenen Borgmatic-Sicherung mit erfasst. Das ist der bessere Weg, als das Volume direkt zu sichern: Ein Dateisystemabbild einer laufenden Datenbank ist nicht zuverlässig wiederherstellbar. **Prüfen, dass die Sicherung etwas taugt:** ```bash zcat /var/backups/einkaufsapp/db-$(date +%F).sql.gz | tail -5 ``` Am Ende muss `-- Dump completed on …` stehen. Fehlt die Zeile, ist der Auszug abgebrochen – eine abgeschnittene Sicherung sieht sonst genauso aus wie eine vollständige. ## Wiederherstellung ```bash cd /opt/einkaufsapp docker compose down docker volume rm einkaufsapp_db_data # ACHTUNG: löscht den aktuellen Stand docker compose up -d db sleep 20 # MariaDB initialisiert sich zcat einkaufsapp-2026-08-08.sql.gz | docker compose exec -T db \ mariadb -u root -p"$(grep '^DB_ROOT_PASSWORD=' .env | cut -d= -f2-)" einkaufsapp docker compose up -d docker compose logs --tail=20 api ``` Alembic bringt das Schema beim Start auf den neuesten Stand, falls die Sicherung aus einer älteren Fassung stammt. Der umgekehrte Fall – neuere Sicherung, ältere Anwendung – geht nicht; dann erst den Quelltext aktualisieren. **Einmal im Jahr ausprobieren.** Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung, keine Sicherung. ## Aufräumen Läuft von selbst: einmal 30 Sekunden nach dem Start des `api`-Containers, danach alle `CLEANUP_INTERVAL_HOURS` (Voreinstellung 24). | Was | Frist | Einstellung | |---|---|---| | Weich gelöschte Listen, Einträge, Artikel, Märkte, Warengruppen | 30 Tage | `CLEANUP_DELETED_DAYS` | | Outbox-Quittungen | 7 Tage | `CLEANUP_OPS_DAYS` | | Abgelaufene Sitzungen | sofort | – | | Rate-Limit-Zähler | 1 Tag | – | | Verbrauchte Mail-Token | 7 Tage nach Ablauf | – | | Abgelaufene Einladungen und öffentliche Links | 30 Tage nach Ablauf | `CLEANUP_DELETED_DAYS` | | Produktzwischenspeicher | mindestens 1 Jahr | – | Manuell auslösen (als Administrator angemeldet): ```bash curl -sS -b cookies.txt -H "X-CSRF-Token: $CSRF" \ -X POST https://einkauf.example.de/api/admin/cleanup ``` Im Log erscheint nach jedem Durchlauf eine Zeile wie `Aufräumen: 12 gelöschte Einträge, 340 Outbox-Quittungen`. **Warum die 30 Tage nicht kürzer sein sollten:** So lange kann ein Gerät offline bleiben und beim nächsten Abgleich noch erfahren, dass eine Liste gelöscht wurde. Wird früher aufgeräumt, taucht die Liste auf dem Gerät weiter auf, bis jemand sie von Hand entfernt. ## Aktualisieren ```bash cd /opt/einkaufsapp # Sicherung ZUERST - Migrationen lassen sich nicht immer zurücknehmen /etc/cron.daily/einkaufsapp-backup # Neuen Stand einspielen, dann: ./tools/check-env.sh .env node tools/check-js.mjs python3 tools/check-nginx.py web docker compose up -d --build docker compose logs --tail=30 api # Migrationen beobachten ``` Nach Änderungen an den Dateien unter `web/html/js/` muss `VERSION` in `web/html/sw.js` hochgezählt werden – sonst behalten bereits installierte Clients die alte Fassung. **`docker compose up -d`, nicht `restart`:** Ein Neustart übernimmt keine geänderten Werte aus der `.env`. Das hat in diesem Projekt schon mehrfach für Verwirrung gesorgt. ## Wenn ein Container verschwindet ```bash docker compose ps -a docker inspect einkaufsapp_api --format \ 'Status={{.State.Status}} Exit={{.State.ExitCode}} OOM={{.State.OOMKilled}} Ende={{.State.FinishedAt}}' docker compose logs --tail=50 api ``` Die letzten Zeilen im Log verraten den Grund: | Im Log | Bedeutung | |---|---| | `Shutting down` → `Application shutdown complete` → `Stopping reloader process` | Geordnetes Beenden. Jemand hat den Container gestoppt – SIGTERM an PID 1 | | Nichts, `OOMKilled=true` | Vom Kernel wegen Speichermangels abgeschossen | | Traceback, `Exit=1` | Startfehler, meist Konfiguration oder Migration | ### Geordnetes Beenden aufspüren ```bash journalctl --since "yesterday 22:00" --until "today 07:00" | grep -iE "docker|compose|borg" grep -rniE "docker (compose )?(stop|down)" /etc/cron* /root /opt 2>/dev/null ``` Häufigster Verursacher ist ein Sicherungsskript, das Container anhält. **Für die Datenbanksicherung ist das nicht nötig:** `mariadb-dump --single-transaction` liefert einen konsistenten Stand im laufenden Betrieb. ### Was `restart: unless-stopped` leistet – und was nicht Der `api`-Container startet jetzt nach Abstürzen und nach einem Neustart des Hosts von selbst wieder. **Nach einem ausdrücklichen `docker compose stop` aber nicht.** Das ist die Bedeutung von „unless-stopped": Docker merkt sich, dass der Stopp gewollt war. Wenn dein Sicherungsskript Container anhält, muss es sie danach selbst wieder starten: ```bash docker compose stop # ... sichern ... docker compose start ``` Ein Skript, das nur stoppt und sich auf die Neustartregel verlässt, hinterlässt eine tote Anwendung – genau das ist hier passiert. ### Kehrseite der Neustartregel Bricht der Container beim Start ab, etwa wegen einer fehlgeschlagenen Migration, versucht Docker es endlos erneut und das Log füllt sich mit Wiederholungen. Dann hilft: ```bash docker compose stop api docker compose logs --tail=60 api # in Ruhe lesen ``` ## Speicherplatz im Blick behalten ```bash docker compose exec db mariadb -u root -p einkaufsapp -e " SELECT table_name AS Tabelle, ROUND(data_length/1024/1024, 1) AS 'Daten MB', ROUND(index_length/1024/1024, 1) AS 'Index MB', table_rows AS 'Zeilen (geschätzt)' FROM information_schema.tables WHERE table_schema = 'einkaufsapp' ORDER BY data_length DESC;" ``` Erwartungsgemäß wachsen `price_point` (jeder erfasste Preis) und `product_cache` (jeder gescannte Strichcode) am stärksten. Beide sind gewollt: Die Preisdatenbank ist der Zweck, und der Zwischenspeicher verhindert, dass jeder Scan nach draußen geht. Wächst `applied_op` unerwartet, liegt ein Gerät mit einer festhängenden Outbox vor – dann lohnt ein Blick ins Log des `api`-Containers.