Files
einkaufsapp/docs/betrieb.md

8.1 KiB
Raw Permalink Blame History

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

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

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:

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

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):

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

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

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 downApplication shutdown completeStopping 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

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:

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:

docker compose stop api
docker compose logs --tail=60 api      # in Ruhe lesen

Speicherplatz im Blick behalten

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.