Backup-Skript
Fremden Code lesen
Abschnitt betitelt „Fremden Code lesen“Im Berufsalltag schreibst du selten ein Skript von null. Meistens findest du eines, das jemand vor Jahren geschrieben hat, das «irgendwie läuft», und das du anpassen musst. Genau das üben wir hier mit einem echten Beispiel: backup.sh aus dem Jahr 2013, das monatlich eine Vollsicherung und täglich eine Differenzsicherung mit tar erstellt und die Archive optional per rsync auf einen Server kopiert. Die Idee war gut, die Umsetzung ist in die Jahre gekommen:
| Im alten Skript | Heute | Warum |
|---|---|---|
HOSTNAME=`hostname -s` |
HOST="$(hostname -s)" |
Backticks sind schwer lesbar und nicht schachtelbar. HOSTNAME ist zudem eine Bash-Variable |
mkdir -p $BACKUPDIR |
mkdir -p "$BACKUP_DIR" |
Ungequotete Variablen zerbrechen bei Leerzeichen |
EXCLUDETHESE="--exclude=... --exclude=..." |
Bash-Array | Ein String mit mehreren Optionen wird bei Wildcards falsch zerlegt |
TAR=`which tar` |
command -v tar |
which ist nicht standardisiert, command -v ist eingebaut |
function needRoot() { |
needRoot() { |
function ist Bash-Dialekt, die POSIX-Form reicht |
[ ... ] |
[[ ... ]] |
Sicherer mit leeren Variablen, keine Quoting-Fallen |
| Fehler werden ignoriert | set -euo pipefail, trap |
Ein halbes Archiv ohne Fehlermeldung ist das Schlimmste, was ein Backup-Skript tun kann |
Archivname host-TAG |
host-DATUM-full/diff |
Überschreiben nach einem Monat war Absicht, ist aber schwer nachzuvollziehen |
cat $LOGFILE | mail |
journalctl -u backup |
Der systemd-Timer protokolliert ohnehin |
So sah das Herzstück des Originals aus. Lies es einmal durch und versuche zu erklären, was createTar tut, bevor du weiterliest:
BACKUPTHESE="/etc /home"BACKUPDIR="/backups"EXCLUDETHESE="--exclude=/dev/* --exclude=/proc/* --exclude=/sys/* --exclude=/tmp/*"DATEFILE="$BACKUPDIR/etc/$HOSTNAME.ts"TAROPTIONS="--warning=no-file-ignored -cjf"FULLDAY="01"TAR=`which tar`
function createTar() { if [ $DOM = $FULLDAY ] || [ ! -f "$DATEFILE" ]; then BACKUPFILE="$BACKUPDIR/$HOSTNAME-$MONTH-$DOM.tar.bz2" touch $DATEFILE $TAR --exclude=$BACKUPDIR $EXCLUDETHESE $TAROPTIONS $BACKUPFILE $BACKUPTHESE >>$LOGFILE 2>&1 else BACKUPFILE="$BACKUPDIR/$HOSTNAME-$DOM.tar.bz2" $TAR --newer=$DATEFILE --exclude=$BACKUPDIR $EXCLUDETHESE $TAROPTIONS $BACKUPFILE $BACKUPTHESE >>$LOGFILE 2>&1 fi}
needRootcheckEnvinitBackupDircreateTar#syncToServershowReportLass shellcheck über diesen Ausschnitt laufen, wenn du magst: Er findet über zwanzig Meldungen. Die neue Fassung behält die Struktur bei, Konfiguration oben, dann Funktionen, dann ein kurzer Hauptteil, der die Funktionen der Reihe nach aufruft, und räumt den Rest auf.
Die modernisierte Fassung
Abschnitt betitelt „Die modernisierte Fassung“Das Skript liegt im Kursrepository unter scripts/linux/10-remote-backup/backup.sh.
#!/usr/bin/env bash## backup.sh - Voll- und Differenzsicherung mit tar, optional rsync auf einen Server## Aufruf: sudo ./backup.sh Differenzsicherung, am FULL_DAY eine Vollsicherung# sudo ./backup.sh --full Vollsicherung erzwingen## Einrichtung: Block KONFIGURATION anpassen, Sicherung UND Restore testen,# dann per systemd-Timer oder Cron taeglich starten.## Modernisierte Fassung des backup.sh v0.3 (2013) aus dem Kurs LSP.
set -euo pipefail
### KONFIGURATION: anpassen ###SOURCES=(/etc /home) # was gesichert wirdBACKUP_DIR="/var/backups/tar" # wohin (idealerweise eine andere Disk)EXCLUDES=( # Muster, die tar auslaesst "*/.cache" "*/snap" "*.tmp")FULL_DAY="01" # Tag im Monat fuer die VollsicherungKEEP_DAYS=60 # aeltere Archive loeschenREMOTE="" # z.B. "alice@server:backup/" oder leer
### ab hier normalerweise nichts aendern ###readonly PROG="${0##*/}"HOST="$(hostname -s)"TODAY="$(date +%F)"DOM="$(date +%d)"STATE_DIR="$BACKUP_DIR/state"LOG_DIR="$BACKUP_DIR/log"STAMP_FILE="$STATE_DIR/$HOST.full.stamp"LOG_FILE="$LOG_DIR/$HOST-$TODAY.log"FORCE_FULL=0PARTIAL="" # Archiv, das gerade geschrieben wird
log() { local line line="$(date '+%F %T') $PROG: $*" echo "$line" if [[ -d "$LOG_DIR" ]]; then echo "$line" >> "$LOG_FILE" fi}
die() { log "FEHLER: $*" exit 1}
cleanup() { local rc=$? if (( rc != 0 )); then log "abgebrochen mit Exit-Code $rc" if [[ -n "$PARTIAL" && -f "$PARTIAL" ]]; then rm -f "$PARTIAL" log "unvollstaendiges Archiv $PARTIAL entfernt" fi fi}trap cleanup EXIT
needRoot() { [[ $EUID -eq 0 ]] || die "bitte mit sudo ausfuehren"}
checkEnv() { local prog src for prog in tar gzip; do command -v "$prog" > /dev/null || die "Programm $prog nicht gefunden" done if [[ -n "$REMOTE" ]]; then command -v rsync > /dev/null || die "rsync nicht gefunden" fi for src in "${SOURCES[@]}"; do [[ -e "$src" ]] || die "Quelle $src existiert nicht" done}
initBackupDir() { mkdir -p "$BACKUP_DIR" "$STATE_DIR" "$LOG_DIR" chmod 700 "$BACKUP_DIR" [[ -w "$BACKUP_DIR" ]] || die "$BACKUP_DIR ist nicht beschreibbar"}
createArchive() { local mode="diff" local pattern rc=0 local -a tar_opts=()
if (( FORCE_FULL )) || [[ "$DOM" == "$FULL_DAY" ]] || [[ ! -f "$STAMP_FILE" ]]; then mode="full" else tar_opts+=(--newer="$STAMP_FILE") fi for pattern in "${EXCLUDES[@]}"; do tar_opts+=(--exclude="$pattern") done
PARTIAL="$BACKUP_DIR/$HOST-$TODAY-$mode.tar.gz" log "$mode: Sicherung nach $PARTIAL gestartet"
# Zeitstempel VOR dem Lauf setzen: was sich waehrend der Vollsicherung # aendert, landet so sicher in der naechsten Differenzsicherung. if [[ "$mode" == "full" ]]; then touch "$STAMP_FILE.new" fi
tar --create --gzip --file="$PARTIAL" \ --exclude="$BACKUP_DIR" "${tar_opts[@]}" \ --warning=no-file-changed \ -- "${SOURCES[@]}" 2>> "$LOG_FILE" || rc=$?
case "$rc" in 0) ;; 1) log "Warnung: Dateien haben sich waehrend der Sicherung geaendert" ;; *) die "tar ist mit Exit-Code $rc fehlgeschlagen" ;; esac
if [[ "$mode" == "full" ]]; then mv "$STAMP_FILE.new" "$STAMP_FILE" fi log "$mode: fertig, $(du -h "$PARTIAL" | cut -f1)" PARTIAL=""}
pruneOld() { local count count="$(find "$BACKUP_DIR" -maxdepth 1 -name "$HOST-*.tar.gz" \ -mtime +"$KEEP_DAYS" -print -delete | wc -l)" log "$count Archive aelter als $KEEP_DAYS Tage geloescht"}
syncToServer() { [[ -n "$REMOTE" ]] || return 0 log "rsync nach $REMOTE gestartet" rsync -a --delete "$BACKUP_DIR/" "$REMOTE" >> "$LOG_FILE" 2>&1 \ || die "rsync nach $REMOTE fehlgeschlagen" log "rsync fertig"}
main() { case "${1:-}" in --full) FORCE_FULL=1 ;; "") ;; *) echo "Aufruf: $PROG [--full]" >&2; exit 2 ;; esac needRoot checkEnv initBackupDir createArchive pruneOld syncToServer log "Sicherung abgeschlossen"}
main "$@"Abschnitt für Abschnitt
Abschnitt betitelt „Abschnitt für Abschnitt“Kopf und Sicherheitsnetz
Abschnitt betitelt „Kopf und Sicherheitsnetz“set -euo pipefail bricht ab, sobald ein Befehl scheitert (-e), eine undefinierte Variable benutzt wird (-u) oder ein Glied in einer Pipeline fehlschlägt (pipefail). Für ein Backup-Skript ist das Pflicht: Lieber laut abbrechen als leise ein kaputtes Archiv schreiben.
trap cleanup EXIT sorgt dafür, dass cleanup immer läuft, auch nach einem Abbruch durch -e oder die. Die Funktion liest den Exit-Code ($?), und wenn er nicht 0 ist, löscht sie das halbfertige Archiv, dessen Namen createArchive in PARTIAL abgelegt hat. Sonst läge ein Archiv herum, das wie ein Backup aussieht, aber keines ist.
Konfiguration
Abschnitt betitelt „Konfiguration“Alles, was du anpassen musst, steht oben in einem Block. SOURCES und EXCLUDES sind Arrays, keine Strings: So bleiben Pfade mit Leerzeichen und Muster mit * intakt, wenn sie später mit "${SOURCES[@]}" eingesetzt werden. Die Muster in EXCLUDES werden von tar auf den ganzen Pfad angewendet, * passt dabei auch auf /.
readonly PROG="${0##*/}" ist der Dateiname des Skripts ohne Pfad, dasselbe wie basename "$0", nur ohne Programmaufruf.
log und die
Abschnitt betitelt „log und die“Jede Meldung geht auf den Bildschirm und, sobald das Logverzeichnis existiert, in die Logdatei. die ist das Muster «melden und mit Fehler beenden», das du aus Modul 8 kennst. Weil die mit exit 1 endet, greift anschliessend der trap.
needRoot, checkEnv, initBackupDir
Abschnitt betitelt „needRoot, checkEnv, initBackupDir“Die drei Funktionen heissen wie im Original und tun dasselbe, nur kompakter. [[ $EUID -eq 0 ]] ist der Standardtest auf root. checkEnv prüft nicht nur die Programme, sondern auch, ob alle Quellen existieren: Ein Tippfehler in SOURCES soll sofort auffallen, nicht erst beim Restore.
createArchive
Abschnitt betitelt „createArchive“Das Herzstück. Die Entscheidung Voll oder Differenz fällt an einer Stelle: erzwungen mit --full, am FULL_DAY oder wenn noch keine Zeitstempeldatei existiert. Bei einer Differenzsicherung bekommt tar die Option --newer=STAMP_FILE und packt nur, was sich seit der Vollsicherung geändert hat.
Zwei Details, die im alten Skript fehlten:
- Der Zeitstempel wird vor der Vollsicherung gesetzt, aber erst in eine Datei
.new. Erst wenntarerfolgreich war, wird sie umbenannt. Scheitert die Vollsicherung, bleibt der alte Stempel, und die nächste Differenzsicherung bezieht sich auf die letzte gültige Vollsicherung. tarliefert Exit-Code 1, wenn sich eine Datei während des Lesens geändert hat. Das ist auf einem laufenden System normal und darf das Skript nicht abbrechen. Darum wird der Exit-Code mit|| rc=$?eingefangen und mitcaseausgewertet: 0 gut, 1 Warnung, alles andere Fehler.
pruneOld und syncToServer
Abschnitt betitelt „pruneOld und syncToServer“find -mtime +60 -delete räumt alte Archive weg. Weil jeden Monat eine neue Vollsicherung entsteht, ist die jüngste immer jünger als 60 Tage, und die Differenzsicherungen verlieren nie ihre Basis.
syncToServer spiegelt das ganze Backup-Verzeichnis per rsync --delete auf den Server. Das Skript läuft als root, also verbindet sich root per SSH. Root braucht dafür einen eigenen Schlüssel:
sudo ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519 -C "backup@$(hostname)"sudo ssh-copy-id -i /root/.ssh/id_ed25519.pub alice@backupzielEin Schlüssel ohne Passphrase, weil niemand nachts um zwei eine eingibt. Dafür darf dieser Schlüssel auf dem Ziel nur genau das dürfen, was er muss. Für den Kurs reicht ein normales Benutzerkonto auf einem zweiten System (etwa deiner lokalen VM oder der VM eines Mitlernenden).
Einrichten und testen
Abschnitt betitelt „Einrichten und testen“- Skript nach
/usr/local/sbininstallieren und Konfiguration anpassen:Server-VM sudo install -m 755 backup.sh /usr/local/sbin/backup.shsudo vim /usr/local/sbin/backup.sh - Erster Lauf von Hand, einmal voll, dann eine Differenz:
Was erwartest du im Listing der Differenzsicherung?
Server-VM sudo backup.sh --fullecho "nach der Vollsicherung" >> ~/notiz.txtsudo backup.shsudo ls -lh /var/backups/tar/sudo tar -tzf /var/backups/tar/$(hostname -s)-$(date +%F)-diff.tar.gz - ShellCheck darüberlaufen lassen, wie es die Pipeline bei der Abgabe tut:
Server-VM sudo apt install shellcheckshellcheck backup.sh
Täglich laufen lassen
Abschnitt betitelt „Täglich laufen lassen“In Modul 9 hast du systemd-Timer kennengelernt. Ein Timer braucht eine Service-Unit, die er auslöst:
[Unit]Description=Taegliche Sicherung mit backup.shAfter=network-online.targetWants=network-online.target
[Service]Type=oneshotExecStart=/usr/local/sbin/backup.sh[Unit]Description=backup.sh taeglich um 02:30 starten
[Timer]OnCalendar=*-*-* 02:30:00Persistent=trueRandomizedDelaySec=10min
[Install]WantedBy=timers.targetPersistent=true holt einen verpassten Lauf nach, wenn die VM um 02:30 aus war. RandomizedDelaySec verhindert, dass alle VMs im Cluster zur selben Sekunde loslegen.
sudo cp backup.service backup.timer /etc/systemd/system/sudo systemctl daemon-reloadsudo systemctl enable --now backup.timersystemctl list-timers backup.timersudo systemctl start backup.service # Testlauf, ohne auf 02:30 zu wartenjournalctl -u backup.service -n 20Wer lieber Cron nimmt: sudo crontab -e und diese Zeile eintragen. Die Umleitung verhindert, dass Cron bei jeder Ausgabe eine lokale Mail erzeugt, das Log schreibt das Skript selbst:
30 2 * * * /usr/local/sbin/backup.sh > /dev/null 2>&1Oder das Skript nach /etc/cron.daily/backup kopieren, ohne Endung .sh: run-parts ignoriert Dateinamen mit Punkt. Die Uhrzeit bestimmt dann /etc/crontab.
Identisch, systemd ist systemd. shellcheck kommt aus den Standard-Repos (dnf install ShellCheck).
Der Restore-Test
Abschnitt betitelt „Der Restore-Test“Ein Restore bei differenzieller Sicherung heisst: zuerst die Vollsicherung auspacken, dann die jüngste Differenzsicherung darüber. tar -x überschreibt dabei ältere Versionen mit neueren.
sudo mkdir -p /tmp/restorecd /tmp/restoresudo tar -xzf "/var/backups/tar/$(hostname -s)-$(date +%F)-full.tar.gz"sudo tar -xzf "/var/backups/tar/$(hostname -s)-$(date +%F)-diff.tar.gz"diff "/tmp/restore/home/$USER/notiz.txt" ~/notiz.txt && echo "Restore identisch"Lösung anzeigen
Das Skript selbst steht oben. Angepasster Konfigurationsblock, zum Beispiel:
SOURCES=(/etc /home /var/www)BACKUP_DIR="/var/backups/tar"EXCLUDES=("*/.cache" "*/snap" "*/node_modules" "*.tmp")FULL_DAY="01"KEEP_DAYS=60REMOTE="alice@10.0.0.17:backup/server-vm/"Restore-Protokoll in Kurzform:
script labs/linux/10-remote-backup/restore.txtsudo backup.sh --fullecho "geaendert $(date)" >> ~/notiz.txtsudo backup.shsudo mkdir -p /tmp/restore && cd /tmp/restoresudo tar -xzf /var/backups/tar/$(hostname -s)-$(date +%F)-full.tar.gzsudo tar -xzf /var/backups/tar/$(hostname -s)-$(date +%F)-diff.tar.gzdiff "/tmp/restore/home/$USER/notiz.txt" ~/notiz.txt && echo identischsystemctl list-timers backup.timerjournalctl -u backup.service -n 20# Nicht abgedeckt: 1. Löschungen (gelöschte Dateien kommen zurück).# 2. Alles, was nach der letzten Differenzsicherung geändert wurde.# Und ohne REMOTE liegt das Archiv auf derselben VM wie die Daten.exitSelbstcheck · 5 Fragen
Weiterführend
Abschnitt betitelt „Weiterführend“man tar(Abschnitt «Incremental dumps» für--listed-incremental, die eingebaute Alternative zu--newer)- systemd.timer
- restic und BorgBackup, wenn das Skript nicht mehr reicht