Automatisches indi-allsky Backup auf externem Laufwerk (Fritzbox NAS) – Phase 2: Dateien

Nachdem Datenbank, Konfiguration und Migrations von indi-allsky bereits automatisiert auf ein externes Laufwerk gesichert werden, habe ich mir im nächsten Schritt die eigentlichen Dateien vorgenommen: die Night-Images, aus denen später Timelapse, Keogramm und Startrails entstehen.

Die Basis dafür bildet Phase 1 des Backups, die ich hier beschrieben habe:

Automatisches indi-allsky Backup auf externem Laufwerk (Fritzbox NAS) – Phase 1

Phase 2 ist deutlich anspruchsvoller. Während Datenbank-Backups vergleichsweise klein und deterministisch sind, reden wir bei den Bilddaten schnell über mehrere Gigabyte pro Nacht – und über Daten, die nicht alle gleich wertvoll sind.

Warum nicht einfach „alles sichern“?

Ein Allsky-System produziert jede Nacht Bilder – unabhängig davon, ob der Himmel klar, teilweise bewölkt oder komplett unbrauchbar ist. Würde man alle Dateien blind sichern, wäre selbst eine große externe Festplatte sehr schnell voll.

Deshalb war von Anfang an klar: Das Backup muss qualitätsbasiert arbeiten.
Nicht jede Nacht ist gleich wertvoll – und das lässt sich bei indi-allsky sehr gut aus der Datenbank ableiten.

Und so bin ich vorgegangen…

Qualitätskriterien direkt aus der indi-allsky Datenbank

indi-allsky speichert pro Bild unter anderem:

  • die Anzahl erkannter Sterne (stars)
  • ob Moon-Mode aktiv war (moonmode)
  • ob es sich um eine Nachtaufnahme handelt

Für jede Nacht berechne ich daraus:

  • die durchschnittliche Sternanzahl
  • den durchschnittlichen Mondanteil

Nur Nächte, die diese Mindestkriterien erfüllen, werden überhaupt gesichert. Schlechte Nächte fallen automatisch durchs Raster.

Ich habe hierfür zunächst geschaut, wie viele Sterne in besonders klaren Nächten pro Bild detektiert worden sind um ein Gefühl zu bekommen, was „gigantisch“ ist und was „ganz okay“ und daraus die Grenzwerte ermittelt.

Gesichert werden nur die Original-Fotos und Timelapses, keine Keogramme und Startrails (beides ist auskommentiert im Script enthalten!) sowie Thumbnails (die lassen sich in indi-allsky per Script ganz easy neu generieren).

„Geile Nächte“ vs. Rolling-Nächte

Aus den realen Daten hat sich sehr schnell eine saubere Trennung ergeben:

  • Geile Nächte: Ø ≥ 1000 Sterne im Schnitt → dauerhaft aufbewahren
  • Okay-Nächte: gute, aber nicht perfekte Bedingungen → dürfen später gelöscht werden

Diese Klassifizierung wird pro Nacht fest im Backup hinterlegt und ist Grundlage für die spätere Retention.

Hard-Check  – passt die Dateianzahl?

Ein Backup ist nur dann ein Backup, wenn es auch vollständig ist.
Deshalb gibt es nach jeder gesicherten Nacht einen harten Verifikationsschritt:

Anzahl der Bilder laut Datenbank = Anzahl der Dateien im Backup

Stimmen diese Zahlen nicht exakt überein, gilt das Backup als fehlgeschlagen.
Es gibt kein „fast vollständig“.

Damit wird verhindert, dass:

  • unvollständige Nächte still akzeptiert werden
  • Retention auf fehlerhaften Backups basiert
  • Datenverlust erst Wochen später auffällt

Dynamische Retention statt fixer Grenzen

Da auf dem gleichen Laufwerk auch Datenbank-Backups liegen, arbeitet die Retention bewusst dynamisch:

  • Löschen ab > 80 % Plattenbelegung
  • oder wenn weniger als 20 GB frei sind

So funktioniert das Script unabhängig davon, ob jemand eine kleine SSD oder eine große externe HDD nutzt.

Gelöscht werden ausschließlich:

  • verifizierte Rolling-Nächte
  • nie die letzte (noch laufende) Nacht
  • nie als „geil“ klassifizierte Nächte

Jede Löschung wird per Mail dokumentiert – inklusive Nacht, Sternanzahl, Mondanteil und freigegebenem Speicherplatz.

Automatische Bestätigungsmail

Nach jedem Backup (manuell oder per Cronjob) wird eine Mail mit Statistiken verschickt – u.a. Größe des Backups, bisher beste Sternennacht (gemessen am Durchschnitt der Sterne), freier Speicherplatz sowie ob etwas gelöscht wurde:

indi-allsky Batch-Backup abgeschlossen

Host: allsky
Zeit: So 28. Dez 00:01:30 CET 2025

Gesicherte Nächte:
2025-12-24 | 1902 Bilder | 2915 MB Bilder | Ø Sterne 1170.4
2025-12-25 | 1854 Bilder | 3038 MB Bilder | Ø Sterne 1509.3
2025-12-26 | 1677 Bilder | 2783 MB Bilder | Ø Sterne 1483.3

Timelapse gesamt:
501 MB

Geilste Nacht bisher:
2025-12-25 | Ø Sterne 1509.3

Belegung Backup-HDD:
Gesamt: 232G
Belegt: 11G (5%)
Frei:   221G

Das vollständige Backup-Script (Phase 2)

Das folgende Script ist produktiv einsetzbar und kann 1:1 übernommen werden.
Ein Dry-Run ist über --dry-run möglich.

#!/bin/bash
set -euo pipefail

# =========================================================
# Konfiguration
# =========================================================
DB="/var/lib/indi-allsky/indi-allsky.sqlite"
SRC_BASE="/var/www/html/allsky/images"

BACKUP_MOUNT="/mnt/backup_allsky"
BACKUP_BASE="$BACKUP_MOUNT/backup_allsky"
NIGHTS_DIR="$BACKUP_BASE/nights"
TL_DIR="$BACKUP_BASE/timelapse"

LOG="/var/log/backup_allsky_phase2_batch.log"
MAIL_TO="mail@domain.tld"
# Anpassen
HOST="$(hostname)"
LOCKFILE="/var/lock/backup_allsky_phase2.lock"

START_DATE="2025-12-24"   # optional, leer lassen für alle: START_DATE=""

# Kriterien
MIN_AVG_STARS_PRIMARY=700
MIN_AVG_STARS_SECONDARY=500
MIN_MOON_PCT_SECONDARY=50
GREAT_NIGHT_MIN_AVG_STARS=1000
# ggf anpassen

# Retention
RETENTION_MAX_USED_PCT=80
# ggf anpassen

DRY_RUN=0
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1

RSYNC_OPTS=(-a)
[[ "$DRY_RUN" -eq 1 ]] && RSYNC_OPTS=(-a --dry-run)

exec >>"$LOG" 2>&1

# =========================================================
# Fehler-Reporting (damit nicht mehr "Start -> Ende" ohne Info)
# =========================================================
fail() {
  local rc=$?
  {
    echo "indi-allsky Batch-Backup FEHLGESCHLAGEN"
    echo "Host: $HOST"
    echo "Zeit: $(date)"
    echo "Exit-Code: $rc"
    echo "Zeile: ${BASH_LINENO[0]}"
    echo "Command: ${BASH_COMMAND}"
    echo
    echo "Letzte Logzeilen:"
    tail -n 80 "$LOG" || true
  } | mail -s "indi-allsky Batch-Backup FEHLER ($HOST)" "$MAIL_TO"
  exit "$rc"
}
trap fail ERR

# =========================================================
# Helper
# =========================================================
disk_used_pct() { df -P "$BACKUP_MOUNT" | awk 'NR==2 {gsub("%","",$5); print $5}'; }
disk_human()    { df -h "$BACKUP_MOUNT" | awk 'NR==2 {print $2, $3, $4, $5}'; }

echo "=== Phase 2 Batch-Start: $(date) ==="

# =========================================================
# Locking (kein Parallel-Lauf)
# =========================================================
exec 9>"$LOCKFILE" || exit 1
if ! flock -n 9; then
  echo "Lock aktiv, beende: $(date)"
  exit 0
fi

# =========================================================
# Backup-Mount sicherstellen
# =========================================================
mountpoint -q "$BACKUP_MOUNT" || mount "$BACKUP_MOUNT"
mountpoint -q "$BACKUP_MOUNT" || { echo "FEHLER: $BACKUP_MOUNT nicht gemountet"; exit 1; }

mkdir -p "$NIGHTS_DIR" "$TL_DIR"

# =========================================================
# Aktive Night-Kamera ermitteln (aus DB)
# =========================================================
ACTIVE_CCD="$(
  sqlite3 -readonly -noheader "$DB" \
  "SELECT substr(filename,1,instr(filename,'/')-1)
   FROM image
   WHERE night=1 AND exclude=0
   ORDER BY dayDate DESC, filename DESC
   LIMIT 1;"
)"
[[ -z "$ACTIVE_CCD" ]] && { echo "FEHLER: Keine aktive Night-Kamera gefunden."; exit 1; }

SRC_CCD="$SRC_BASE/$ACTIVE_CCD"

# =========================================================
# Nächte bestimmen
# =========================================================
DATE_FILTER=""
[[ -n "${START_DATE:-}" ]] && DATE_FILTER="AND dayDate>='$START_DATE'"

mapfile -t NIGHTS < <(
  sqlite3 -readonly -noheader "$DB" "
    SELECT DISTINCT dayDate
    FROM image
    WHERE night=1 AND exclude=0 $DATE_FILTER
    ORDER BY dayDate;
  "
)

LAST_NIGHT="${NIGHTS[-1]:-}"

MAIL_LINES=""
BEST_NIGHT=""
BEST_STARS="0"
TL_TOTAL_MB=0

# =========================================================
# Verarbeitung pro Nacht
# =========================================================
for NIGHT in "${NIGHTS[@]}"; do
  # Stats sauber mit | trennen und robust einlesen
  IFS='|' read -r AVG_STARS MOON_PCT IMG_COUNT <<<"$(
    sqlite3 -readonly -noheader -separator '|' "$DB" "
      SELECT
        COALESCE(ROUND(AVG(stars),1),0),
        COALESCE(ROUND(100.0*AVG(moonmode),1),0),
        COUNT(*)
      FROM image
      WHERE night=1 AND exclude=0 AND dayDate='$NIGHT'
        AND filename LIKE '$ACTIVE_CCD/%';
    "
  )"

  AVG_STARS="${AVG_STARS:-0}"
  MOON_PCT="${MOON_PCT:-0}"
  IMG_COUNT="${IMG_COUNT:-0}"

  # Sicherungsentscheidung (bc bekommt garantiert Zahlen)
  SECURE_NIGHT=0
  if (( $(echo "$AVG_STARS >= $MIN_AVG_STARS_PRIMARY" | bc -l) )); then
    SECURE_NIGHT=1
  elif (( $(echo "$AVG_STARS >= $MIN_AVG_STARS_SECONDARY && $MOON_PCT >= $MIN_MOON_PCT_SECONDARY" | bc -l) )); then
    SECURE_NIGHT=1
  fi

  if [[ "$SECURE_NIGHT" -eq 0 ]]; then
    continue
  fi

  # Ziel
  NIGHT_DST="$NIGHTS_DIR/$NIGHT"
  mkdir -p "$NIGHT_DST"

  # Files aus DB (nur aktive Kamera)
  mapfile -t FILES < <(
    sqlite3 -readonly -noheader "$DB" "
      SELECT filename
      FROM image
      WHERE night=1 AND exclude=0 AND dayDate='$NIGHT'
        AND filename LIKE '$ACTIVE_CCD/%';
    "
  )

  # Copy
  for FILE in "${FILES[@]}"; do
    SRC="$SRC_BASE/$FILE"
    DST="$NIGHT_DST/$FILE"
    mkdir -p "$(dirname "$DST")"
    rsync "${RSYNC_OPTS[@]}" "$SRC" "$DST"
  done

  # Größe Images (nur exposures, keine thumbnails)
  IMG_MB="$(du -sm "$NIGHT_DST/$ACTIVE_CCD/exposures" 2>/dev/null | awk '{print $1+0}')"
  IMG_MB="${IMG_MB:-0}"

  # Geilste Nacht
  if (( $(echo "$AVG_STARS > $BEST_STARS" | bc -l) )); then
    BEST_STARS="$AVG_STARS"
    BEST_NIGHT="$NIGHT"
  fi

  # Manifest (für Retention-Schutz)
  CLASS="ROLLING"
  if (( $(echo "$AVG_STARS >= $GREAT_NIGHT_MIN_AVG_STARS" | bc -l) )); then
    CLASS="FOREVER"
  fi

  cat >"$NIGHT_DST/manifest.txt" </dev/null | awk '{print $1+0}')"
TL_TOTAL_MB="${TL_TOTAL_MB:-0}"

# =========================================================
# Retention (nur wenn >80% belegt)
# - löscht nie FOREVER
# - löscht nie die letzte Nacht
# =========================================================
USED_PCT="$(disk_used_pct)"
if (( USED_PCT >= RETENTION_MAX_USED_PCT )); then
  while (( USED_PCT >= RETENTION_MAX_USED_PCT )); do
    CANDIDATE="$(
      ls -1 "$NIGHTS_DIR" 2>/dev/null | sort | while read -r N; do
        [[ -z "$N" ]] && continue
        [[ "$N" == "$LAST_NIGHT" ]] && continue
        [[ -f "$NIGHTS_DIR/$N/manifest.txt" ]] && grep -q "^class=FOREVER$" "$NIGHTS_DIR/$N/manifest.txt" && continue
        echo "$N"
        break
      done
    )"

    [[ -z "$CANDIDATE" ]] && break

    SIZE_H="$(du -sh "$NIGHTS_DIR/$CANDIDATE" | awk '{print $1}')"

    if [[ "$DRY_RUN" -eq 1 ]]; then
      echo "[DRY-RUN] Retention würde löschen: $CANDIDATE ($SIZE_H)"
    else
      rm -rf "$NIGHTS_DIR/$CANDIDATE"
      {
        echo "indi-allsky Retention: Nacht gelöscht"
        echo
        echo "Host: $HOST"
        echo "Zeit: $(date)"
        echo
        echo "Gelöscht: $CANDIDATE"
        echo "Freigegeben: $SIZE_H"
        echo
        echo "Platte (Gesamt Belegt Frei Nutzung%):"
        disk_human
      } | mail -s "indi-allsky Retention: Nacht $CANDIDATE gelöscht ($HOST)" "$MAIL_TO"
    fi

    USED_PCT="$(disk_used_pct)"
  done
fi

# =========================================================
# Abschluss-Mail
# =========================================================
read -r SIZE USED AVAIL PERC <<<"$(df -h "$BACKUP_MOUNT" | awk 'NR==2 {print $2,$3,$4,$5}')"

mail -s "indi-allsky Batch-Backup abgeschlossen" "$MAIL_TO" <}

Timelapse gesamt:
$TL_TOTAL_MB MB

Geilste Nacht bisher:
${BEST_NIGHT:-} | Ø Sterne ${BEST_STARS:-0}

Belegung Backup-HDD:
Gesamt: $SIZE
Belegt: $USED ($PERC)
Frei:   $AVAIL

Hinweis:
Thumbnails werden nicht gesichert.
EOF

echo "=== Phase 2 Batch-Ende: $(date) ==="
exit 0

Läuft?

Die eher ungeduldigen Menschen können mit ps -ef | grep backup_allsky | grep -v grep den Status Quo prüfen.

Cronjob

Ich lasse das Script 1x täglich gegen 11:30 tagsüber laufen:

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
30 11 * * * root /usr/local/bin/backup_allsky_phase2.sh >> /var/log/backup_allsky_phase2_cron.log 2>&1

Logrotation in /etc/logrotate.d/backup_allsky

/var/log/backup_allsky_phase2*.log {
    weekly
    rotate 4
    compress
    missingok
    notifempty
    copytruncate
}

Fazit

Phase 2 des indi-allsky Backups ist bewusst komplexer gehalten als ein simples Kopieren von allen Dateien per rsync.
Dafür ist sie:

  • datenbasiert
  • verifizierend
  • speichereffizient
  • und langfristig wartbar

Für mich ist das der entscheidende Unterschied zwischen „irgendwie gesichert“ und einem Backup, dem man im Ernstfall vertraut.

Hat dir dieser Beitrag gefallen?

Du kannst allsky-rodgau.de mit einem kleinen Kaffee auf BuyMeACoffee unterstützen.

Jetzt Kaffee spendieren!