Änderungsstand: 2022-01-04
Nun ein Backup-Script, welches sehr gut funktioniert und dabei sehr effektiv ist…
Das erste Backup dauert ziemlich lang. Es werden die Verzeichnisse komplett gesichert. Ab dem 2. Backup ist das dann easy. Inkrementelles Backup mit Hardlinks zum ersten erstelltem Backup!
Quelle: https://forums.unraid.net/topic/97958-rsync-incremental-backup/
Ich verwende das Backup-Script von Marc Gutt. Dieses habe ich nach meinen Bedürfnissen angepasst. Ich erwähne explizit, dass ich dieses Script nicht selbst geschrieben habe, sondern es nur für mich etwas angepasst habe! Dieses Script ist in meinen Augen das Non Plus Ultra der rsync-Scripte, einfach erweiterbar und auch für alle anderen Backups verwendbar. Vielen Dank für die Veröffentlichung und kostenlosen Bereitstellung.
Kurze Erklärung: Nun, das erste Backup ist meistens ein Fluch, da es unter Umständen sehr lange, wenn nicht sogar mehrere Stunden, dauern kann. Hat man das erledigt, ist alles Weitere quasi ein Kinderspiel. Es werden dank „rsync“ inkrementelle Backups angelegt. Doch das Script von Marc Gutt geht noch etwas weiter. Es werden Zeitbasierte Backups angelegt, mit verschiedenen Sicherungszuständen. Weiterhin, und das ist sensationell, werden von den nicht geänderten Daten Hardlinks angelegt. Somit ist das Backup am Ende nicht unnötig aufgeblasen. Ohne Hardlinks wären z.B., wenn man 4 verschiedene, zeitbezogene Backupstände hätte, das Backup 4 Mal so groß, wie das Original, weil es schließlich 4 Mal angelegt würde (z.B. täglich, wöchentlich, monatlich, jährlich). Hier ist das nicht der Fall. Dank dieser Hardlinks werden immer harte Verlinkungen der nicht geänderten Daten zum ersten angelegten Backup verwendet. Oder zumindest so ähnlich 🙂 .
Nun das für mich angepasste Script, welches ich unter „User Script“ hinzufüge:
#!/bin/bash
# #####################################
# Name: rsync Incremental Backup
# Description: Creates incremental backups and deletes outdated versions
# Author: Marc Gutt
# Version: 1.3
# Quelle: https://forums.unraid.net/topic/97958-rsync-incremental-backup/
##
# Änderungen: Knilix
#########################################################
BACKUP_ANZAHL="7" # Gilt nur für die aufbewahrten Dumps
#########################################################
## Wartungsmodus einschalten
# Dieser Befehl gilt nur für /linuxserver/nextcloud
sudo docker exec --user abc nextcloud-test php /config/www/nextcloud/occ maintenance:mode --on
wait
## Backup-Verzeichnis für Dump anlegen (wird ignoriert, wenn vorhanden)
sudo mkdir -p /mnt/user/backups/nc-rsbackup/dumpfile
#
## Dumpfile (ALL) anlegen (optional)
## Verwendeter Docker-Name: postgresql14-test
## Verwendeter Datenbank(root)-User: nc
docker exec -t postgresql14-test pg_dumpall -c -U nc | gzip > /mnt/user/backups/nc-rsbackup/dumpfile/dump_$(date +"%Y-%m-%d_%H_%M_%S").gz
wait
# Unnötige Dumps werden entfernt
# Die Anzahl der behaltenen Dumps wurde in Zeile 11 hinterlegt
pushd /mnt/user/backups/nc-backup/dumpfile &> /dev/null; ls -tr /mnt/user/backups/nc-rsbackup/dumpfile/dump* | head -n -${BACKUP_ANZAHL} | xargs rm -f; popd &> /dev/null
# #####################################
# Settings
# #####################################
# backup source to destination
backup_jobs=(
# source # destination
"/mnt/user/data/nextcloud-test-data" "/mnt/user/backups/nc-rsbackup/nextcloud-test-data"
"/mnt/cache/appdata/nextcloud-test" "/mnt/user/backups/nc-rsbackup/nextcloud-test"
"/mnt/cache/appdata/postgresql14-test" "/mnt/user/backups/nc-rsbackup/postgresql14-test"
)
# keep backups of the last X days
keep_days=14
# keep multiple backups of one day for X days
keep_days_multiple=1
# keep backups of the last X months
keep_months=12
# keep backups of the last X years
keep_years=3
# keep the most recent X failed backups
keep_fails=3
# rsync options which are used while creating the full and incremental backup
rsync_options=(
# --dry-run
--archive # same as --recursive --links --perms --times --group --owner --devices --specials
--human-readable # output numbers in a human-readable format
--itemize-changes # output a change-summary for all updates
--exclude="[Tt][Ee][Mm][Pp]/" # exclude dirs with the name "temp" or "Temp" or "TEMP"
--exclude="[Tt][Mm][Pp]/" # exclude dirs with the name "tmp" or "Tmp" or "TMP"
# Folgender Befehl muss bei einem Nextcloud- und WordPress-Backup unbedingt entfernt werden (oder zumindest ein # davor setzen)
# --exclude="Cache/" # exclude dirs with the name "Cache"
)
# notify if the backup was successful (1 = notify)
notification_success=0
# notify if last backup is older than X days
notification_backup_older_days=30
# create destination if it does not exist
create_destination=1
# backup does not fail if files vanished during transfer https://linux.die.net/man/1/rsync#:~:text=vanished
skip_error_vanished_source_files=1
# backup does not fail if source path returns "host is down".
# This could happen if the source is a mounted SMB share, which is offline.
skip_error_host_is_down=1
# backup does not fail if file transfers return "host is down"
# This could happen if the source is a mounted SMB share, which went offline during transfer
skip_error_host_went_down=1
# backup does not fail, if source path does not exist, which for example happens if the source is an unmounted SMB share
skip_error_no_such_file_or_directory=1
# a backup fails if it contains less than X files
backup_must_contain_files=2
# a backup fails if more than X % of the files couldn't be transfered because of "Permission denied" errors
permission_error_treshold=20
# user-defined rsync command
#alias rsync='sshpass -p "<password>" rsync -e "ssh -o StrictHostKeyChecking=no"'
# user-defined ssh command
#alias ssh='sshpass -p "<password>" ssh -o "StrictHostKeyChecking no"'
# #####################################
# Script
# #####################################
# make script race condition safe
if [[ -d "/tmp/${0//\//_}" ]] || ! mkdir "/tmp/${0//\//_}"; then echo "Script is already running!" && exit 1; fi; trap 'rmdir "/tmp/${0//\//_}"' EXIT;
# allow usage of alias commands
shopt -s expand_aliases
# functions
remove_last_slash() { [[ "${1%?}" ]] && [[ "${1: -1}" == "/" ]] && echo "${1%?}" || echo "$1"; }
notify() {
echo "$2"
if [[ -f /usr/local/emhttp/webGui/scripts/notify ]]; then
/usr/local/emhttp/webGui/scripts/notify -i "$([[ $2 == Error* ]] && echo alert || echo normal)" -s "$1 ($src_path)" -d "$2" -m "$2"
fi
}
# check user settings
backup_path=$(remove_last_slash "$backup_path")
[[ "${rsync_options[*]}" == *"--dry-run"* ]] && dryrun=("--dry-run")
# check if rsync exists
! command -v rsync &> /dev/null && echo "rsync command not found!" && exit 1
# check if sshpass exists if it has been used
echo "$(type rsync) $(type ssh)" | grep -q "sshpass" && ! command -v sshpass &> /dev/null && echo "sshpass command not found!" && exit 1
# set empty dir
empty_dir="/tmp/${0//\//_}"
# loop through all backup jobs
for i in "${!backup_jobs[@]}"; do
# get source path and skip to next element
! (( i % 2 )) && src_path="${backup_jobs[i]}" && continue
# get destination path
dst_path="${backup_jobs[i]}"
# check user settings
src_path=$(remove_last_slash "$src_path")
dst_path=$(remove_last_slash "$dst_path")
# get ssh login and remote path
ssh_login=$(echo "$dst_path" | grep -oP "^.*(?=:)")
remote_dst_path=$(echo "$dst_path" | grep -oP "(?<=:).*")
if [[ ! "$remote_dst_path" ]]; then
ssh_login=$(echo "$src_path" | grep -oP "^.*(?=:)")
fi
# create timestamp for this backup
new_backup="$(date +%Y%m%d_%H%M%S)"
# create log file
log_file="$(mktemp)"
exec &> >(tee "$log_file")
# obtain last backup
if last_backup=$(rsync --dry-run --recursive --itemize-changes --exclude="*/*/" --include="[0-9]*/" --exclude="*" "$dst_path/" "$empty_dir" 2>&1); then
last_backup=$(echo "$last_backup" | grep -oP "[0-9_/]*" | sort -r | head -n1)
# create destination path
elif echo "$last_backup" | grep -q "No such file or directory" && [[ "$create_destination" == 1 ]]; then
unset last_backup last_include
if [[ "$remote_dst_path" ]]; then
mkdir -p "$empty_dir$remote_dst_path" || exit 1
else
mkdir -p "$empty_dir$dst_path" || exit 1
fi
IFS="/" read -r -a includes <<< "${dst_path:1}"
for j in "${!includes[@]}"; do
includes[j]="--include=$last_include/${includes[j]}"
last_include="${includes[j]##*=}"
done
rsync --itemize-changes --recursive "${includes[@]}" --exclude="*" "$empty_dir/" "/"
find "$empty_dir" -mindepth 1 -type d -empty -delete
else
rsync_errors=$(grep -Pi "rsync:|fail|error:" "$log_file" | tail -n3)
notify "Could not obtain last backup!" "Error: ${rsync_errors//[$'\r\n'=]/ } ($rsync_status)!"
continue
fi
# create backup
echo "# #####################################"
# incremental backup
if [[ "$last_backup" ]]; then
echo "last_backup: '$last_backup'"
# warn user if last backup is really old
last_backup_days_old=$(( ($(date +%s) - $(date +%s -d "${last_backup:0:4}${last_backup:4:2}${last_backup:6:2}")) / 86400 ))
if [[ $last_backup_days_old -gt $notification_backup_older_days ]]; then
notify "Last backup is too old!" "Error: The last backup is $last_backup_days_old days old!"
fi
# rsync returned only the subdir name, but we need an absolute path
last_backup="$dst_path/$last_backup"
echo "Create incremental backup from $src_path to $dst_path/$new_backup by using last backup $last_backup"
# remove ssh login if part of path
last_backup="${last_backup/$(echo "$dst_path" | grep -oP "^.*:")/}"
rsync "${rsync_options[@]}" --stats --delete --link-dest="$last_backup" "$src_path/" "$dst_path/.$new_backup"
# full backup
else
echo "Create full backup from $src_path to $dst_path/$new_backup"
rsync "${rsync_options[@]}" --stats "$src_path/" "$dst_path/.$new_backup"
fi
# check backup status
rsync_status=$?
# obtain file count of rsync
file_count=$(grep "^Number of files" "$log_file" | cut -d " " -f4)
file_count=${file_count//,/}
[[ "$file_count" =~ ^[0-9]+$ ]] || file_count=0
echo "File count of rsync is $file_count"
# success
if [[ "$rsync_status" == 0 ]]; then
message="Success: Backup of $src_path was successfully created in $dst_path/$new_backup ($rsync_status)!"
# source path is a mounted SMB server which is offline
elif [[ "$rsync_status" == 23 ]] && [[ "$file_count" == 0 ]] && [[ $(grep -c "Host is down (112)" "$log_file") == 1 ]]; then
message="Skip: Backup of $src_path has been skipped as host is down"
[[ "$skip_error_host_is_down" != 1 ]] && message="Error: Host is down!"
elif [[ "$rsync_status" == 23 ]] && [[ "$file_count" -gt 0 ]] && [[ $(grep -c "Host is down (112)" "$log_file") == 1 ]]; then
message="Skip: Backup of $src_path has been skipped as host went down"
[[ "$skip_error_host_went_down" != 1 ]] && message="Error: Host went down!"
# source path is wrong (maybe unmounted SMB server)
elif [[ "$rsync_status" == 23 ]] && [[ "$file_count" == 0 ]] && [[ $(grep -c "No such file or directory (2)" "$log_file") == 1 ]]; then
message="Skip: Backup of $src_path has been skipped as source path does not exist"
[[ "$skip_error_no_such_file_or_directory" != 1 ]] && message="Error: Source path does not exist!"
# check if there were too many permission errors
elif [[ "$rsync_status" == 23 ]] && grep -c "Permission denied (13)" "$log_file"; then
message="Warning: Some files had permission problems"
permission_errors=$(grep -c "Permission denied (13)" "$log_file")
error_ratio=$((100 * permission_errors / file_count)) # note: integer result, not float!
if [[ $error_ratio -gt $permission_error_treshold ]]; then
message="Error: $permission_errors/$file_count files ($error_ratio%) return permission errors ($rsync_status)!"
fi
# some source files vanished
elif [[ "$rsync_status" == 24 ]]; then
message="Warning: Some files vanished"
[[ "$skip_error_vanished_source_files" != 1 ]] && message="Error: Some files vanished while backup creation ($rsync_status)!"
# all other errors are critical
else
rsync_errors=$(grep -Pi "rsync:|fail|error:" "$log_file" | tail -n3)
message="Error: ${rsync_errors//[$'\r\n'=]/ } ($rsync_status)!"
fi
# backup remains or is deleted depending on status
# delete skipped backup
if [[ "$message" == "Skip"* ]]; then
echo "Delete $dst_path/.$new_backup"
rsync "${dryrun[@]}" --recursive --delete --include="/.$new_backup**" --exclude="*" "$empty_dir/" "$dst_path"
# check if enough files have been transferred
elif [[ "$message" != "Error"* ]] && [[ "$file_count" -lt "$backup_must_contain_files" ]]; then
message="Error: rsync transferred less than $backup_must_contain_files files! ($message)!"
# keep successful backup
elif [[ "$message" != "Error"* ]]; then
echo "Make backup visible ..."
# remote backup
if [[ "$remote_dst_path" ]]; then
# check if "mv" command exists on remote server as it is faster
if ssh -n "$ssh_login" "command -v mv &> /dev/null"; then
echo "... through remote mv (fast)"
[[ "${dryrun[*]}" ]] || ssh "$ssh_login" "mv \"$remote_dst_path/.$new_backup\" \"$remote_dst_path/$new_backup\""
# use rsync (slower)
else
echo "... through rsync (slow)"
# move all files from /.YYYYMMDD_HHIISS to /YYYYMMDD_HHIISS
if ! rsync "${dryrun[@]}" --delete --recursive --backup --backup-dir="$remote_dst_path/$new_backup" "$empty_dir/" "$dst_path/.$new_backup"; then
message="Error: Could not move content of $dst_path/.$new_backup to $dst_path/$new_backup!"
# delete empty source dir
elif ! rsync "${dryrun[@]}" --recursive --delete --include="/.$new_backup**" --exclude="*" "$empty_dir/" "$dst_path"; then
message="Error: Could not delete empty dir $dst_path/.$new_backup!"
fi
fi
# use local renaming command
else
echo "... through local mv"
[[ "${dryrun[*]}" ]] || mv -v "$dst_path/.$new_backup" "$dst_path/$new_backup"
fi
fi
# notification
if [[ $message == "Error"* ]]; then
notify "Backup failed!" "$message"
elif [ "$notification_success" == 1 ]; then
notify "Backup done." "$message"
fi
# loop through all backups and delete outdated backups
echo "# #####################################"
echo "Clean up outdated backups"
unset day month year day_count month_count year_count
while read -r backup_name; do
# failed backups
if [[ "${backup_name:0:1}" == "." ]] && ! [[ "$backup_name" =~ ^[.]+$ ]]; then
if [[ "$keep_fails" -gt 0 ]]; then
echo "Keep failed backup: $backup_name"
keep_fails=$((keep_fails-1))
continue
fi
echo "Delete failed backup: $backup_name"
# successful backups
else
last_year=$year
last_month=$month
last_day=$day
year=${backup_name:0:4}
month=${backup_name:4:2}
day=${backup_name:6:2}
# all date parts must be integer
if ! [[ "$year$month$day" =~ ^[0-9]+$ ]]; then
echo "Error: $backup_name is not a backup!"
continue
fi
# keep all backups of a day
if [[ "$day_count" -le "$keep_days_multiple" ]] && [[ "$last_day" == "$day" ]] && [[ "$last_month" == "$month" ]] && [[ "$last_year" = "$year" ]]; then
echo "Keep multiple backups per day: $backup_name"
continue
fi
# keep daily backups
if [[ "$keep_days" -gt "$day_count" ]] && [[ "$last_day" != "$day" ]]; then
echo "Keep daily backup: $backup_name"
day_count=$((day_count+1))
continue
fi
# keep monthly backups
if [[ "$keep_months" -gt "$month_count" ]] && [[ "$last_month" != "$month" ]]; then
echo "Keep monthly backup: $backup_name"
month_count=$((month_count+1))
continue
fi
# keep yearly backups
if [[ "$keep_years" -gt "$year_count" ]] && [[ "$last_year" != "$year" ]]; then
echo "Keep yearly backup: $backup_name"
year_count=$((year_count+1))
continue
fi
# delete outdated backups
echo "Delete outdated backup: $backup_name"
fi
# ssh
if [[ "$remote_dst_path" ]]; then
if ssh -n "$ssh_login" "command -v rm &> /dev/null"; then
echo "... through remote rm (fast)"
[[ "${dryrun[*]}" ]] || ssh "$ssh_login" "rm -r \"${remote_dst_path:?}/${backup_name:?}\""
else
echo "... through rsync (slow)"
rsync "${dryrun[@]}" --recursive --delete --include="/$backup_name**" --exclude="*" "$empty_dir/" "$dst_path"
fi
# local (rm is 50% faster than rsync)
else
[[ "${dryrun[*]}" ]] || rm -r "${dst_path:?}/${backup_name:?}"
fi
done < <(rsync --dry-run --recursive --itemize-changes --exclude="*/*/" --include="[.0-9]*/" --exclude="*" "$dst_path/" "$empty_dir" | grep -oP "[.0-9_]*" | sort -r)
# move log file to destination
log_path=$(rsync --dry-run --itemize-changes --include=".$new_backup/" --include="$new_backup/" --exclude="*" --recursive "$dst_path/" "$empty_dir" | cut -d " " -f 2)
[[ $log_path ]] && rsync "${dryrun[@]}" --remove-source-files "$log_file" "$dst_path/$log_path/$new_backup.log"
[[ -f "$log_file" ]] && rm "$log_file"
done
## Wartungsmodus beenden
sudo docker exec --user abc nextcloud-test php /config/www/nextcloud/occ maintenance:mode --off
- Zeile 11: Ich bewahre die letzten 7 Dump-Befehle auf (ältere werden automatisch gelöscht –> Befehl in Zeile 27)
- Zeile 15: Maintenance-Mode: AN
- Zeile 18: Anlegen des Backup Pfades für Dump (Ziel)
- Zeile 23: PostgreSQL-Dump-Befehl
- Zeile 27: Löschen der unnötigen Dump-Befehle (Anzahl wird in Zeile 11 definiert)
- Zeilen 34-36: Zu sichernde Ordner (# source) und Angabe des jeweiligen Zielverzeichnisses (# destination)
- Zeile 362: Maintenance-Mode: AUS
Dieses Script lasse ich nun täglich, automatisiert, durchlaufen. Fertig.
Da es meist ungünstig erscheint, ein Backup, egal von welchem Datenbestand, auf dem selben Server zu legen, empfehle ich unbedingt, von den angelegten Backup-Daten ein weiteres Backup auf einem externen System anzulegen. Das sollte dann aber Zeitversetzt geschehen.
Das Restore wird hier beschrieben.