Files
einkaufsapp/docs/betrieb.md

233 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.