Zum Inhalt springen

Backup-Skript

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:

backup.sh (2013, Ausschnitt)
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
}
needRoot
checkEnv
initBackupDir
createTar
#syncToServer
showReport

Lass 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.

Das Skript liegt im Kursrepository unter scripts/linux/10-remote-backup/backup.sh.

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 wird
BACKUP_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 Vollsicherung
KEEP_DAYS=60 # aeltere Archive loeschen
REMOTE="" # 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=0
PARTIAL="" # 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 "$@"

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.

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.

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.

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.

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 wenn tar erfolgreich war, wird sie umbenannt. Scheitert die Vollsicherung, bleibt der alte Stempel, und die nächste Differenzsicherung bezieht sich auf die letzte gültige Vollsicherung.
  • tar liefert 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 mit case ausgewertet: 0 gut, 1 Warnung, alles andere Fehler.

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:

Server-VM
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@backupziel

Ein 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).

  1. Skript nach /usr/local/sbin installieren und Konfiguration anpassen:
    Server-VM
    sudo install -m 755 backup.sh /usr/local/sbin/backup.sh
    sudo vim /usr/local/sbin/backup.sh
  2. Erster Lauf von Hand, einmal voll, dann eine Differenz:
    Server-VM
    sudo backup.sh --full
    echo "nach der Vollsicherung" >> ~/notiz.txt
    sudo backup.sh
    sudo ls -lh /var/backups/tar/
    sudo tar -tzf /var/backups/tar/$(hostname -s)-$(date +%F)-diff.tar.gz
    Was erwartest du im Listing der Differenzsicherung?
  3. ShellCheck darüberlaufen lassen, wie es die Pipeline bei der Abgabe tut:
    Server-VM
    sudo apt install shellcheck
    shellcheck backup.sh

In Modul 9 hast du systemd-Timer kennengelernt. Ein Timer braucht eine Service-Unit, die er auslöst:

/etc/systemd/system/backup.service
[Unit]
Description=Taegliche Sicherung mit backup.sh
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/backup.sh
/etc/systemd/system/backup.timer
[Unit]
Description=backup.sh taeglich um 02:30 starten
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=10min
[Install]
WantedBy=timers.target

Persistent=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.

Server-VM
sudo cp backup.service backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timer
sudo systemctl start backup.service # Testlauf, ohne auf 02:30 zu warten
journalctl -u backup.service -n 20

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

crontab von root
30 2 * * * /usr/local/sbin/backup.sh > /dev/null 2>&1

Oder das Skript nach /etc/cron.daily/backup kopieren, ohne Endung .sh: run-parts ignoriert Dateinamen mit Punkt. Die Uhrzeit bestimmt dann /etc/crontab.

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.

Server-VM
sudo mkdir -p /tmp/restore
cd /tmp/restore
sudo 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:

backup.sh (Ausschnitt)
SOURCES=(/etc /home /var/www)
BACKUP_DIR="/var/backups/tar"
EXCLUDES=("*/.cache" "*/snap" "*/node_modules" "*.tmp")
FULL_DAY="01"
KEEP_DAYS=60
REMOTE="alice@10.0.0.17:backup/server-vm/"

Restore-Protokoll in Kurzform:

Terminal-Fenster
script labs/linux/10-remote-backup/restore.txt
sudo backup.sh --full
echo "geaendert $(date)" >> ~/notiz.txt
sudo backup.sh
sudo mkdir -p /tmp/restore && cd /tmp/restore
sudo 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 identisch
systemctl list-timers backup.timer
journalctl -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.
exit

Selbstcheck · 5 Fragen

1. Wozu dient trap cleanup EXIT?
2. Warum wird der Zeitstempel vor der Vollsicherung gesetzt, aber erst danach umbenannt?
3. Was bedeutet Exit-Code 1 von tar?
4. Warum SOURCES als Array und nicht als String?
5. Welche Aussagen zum Restore stimmen? (mehrere Antworten möglich)
  • 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