Фактично, це вже остання велика задача – налаштувати автоматичне створення бекапів.
В пості FreeBSD: Home NAS, part 13: планування зберігання даних та бекапів описав загальну ідею детальніше – як і що бекапиться, де, що, як зберігається, а сьогодні – вже суто технічна частина про саму реалізацію.
Про що буде йти мова в цьому пості – як робилась автоматизація збору даних з Linux-хостів на NAS, трохи про підводні камені rsync, і як всі бекапи з самого NAS синхронізуються в Rclone remotes.
Всі описані тут скрипти і приклади конфігураційних файлів є в GitHub setevoy2/nas-backup.
Всі частини серії по налаштуванню домашнього NAS на FreeBSD:
- FreeBSD: Home NAS, part 1 – налаштування ZFS mirror (RAID1) (тест на віртуальній машині)
- FreeBSD: Home NAS, part 2 – знайомство з Packet Filter (PF) firewall
- FreeBSD: Home NAS, part 3 – WireGuard VPN, Linux peer та routing
- FreeBSD: Home NAS, part 4 – локальний DNS з Unbound
- FreeBSD: Home NAS, part 5 – ZFS pool, datasets, snapshots та моніторинг
- FreeBSD: Home NAS, part 6 – Samba server та підключення клієнтів
- FreeBSD: Home NAS, part 7 – NFSv4 та підключення до Linux
- FreeBSD: Home NAS, part 8 – backup даних NFS та Samba з restic
- FreeBSD: Home NAS, part 9 – backup даних з rclone до AWS S3 та Google Drive
- FreeBSD: Home NAS, part 10 – моніторинг з VictoriaMetrics
- FreeBSD: Home NAS, part 11 – extended моніторинг з додатковими експортерами
- FreeBSD: Home NAS, part 12: синхронізація даних з Syncthing
- FreeBSD: Home NAS, part 13: планування зберігання даних та бекапів
- FreeBSD: Home NAS, part 14 – логи з VictoriaLogs і алерти з VMAlert
- (current) FreeBSD: Home NAS, part 15: автоматизація бекапів – скрипти, rsync, rclone
- (далі буде)
Зміст
Короткий опис ідеї та реалізації
Взагалі, спочатку ідея була все робити з restic і NFS: мати на NAS окрему NFS share, яка б підключалась до хостів, потім на хостах в цю шару з restic робити бекапи, і після цього з rclone копіювати дані в Google Drive та/або AWS S3.
Але чим більше думав – тим більше розумів, що це не найкраще рішення:
- по-перше – зав’язуватись на NFS, яка має бути постійно підключена – треба перевіряти, чи вона є, чи активна, ну і взагалі – це прив’язка до постійного network connection
- по-друге – сам
restic, як система бекапу для домашнього використання – трошки overkill:- snapshots – да, круто, але ті самі снапшоти робляться із ZFS
- другий момент – це те, що
resticпрацює виключно з crypted data у своїх репозиторіях – а я хотів мати можливість просто зайти в каталог бекапів, і подивитись що там є
Тому врешті-решт замість restic вирішив робити зі звичайним rsync, а замість restic remotes – взяти rclone, і заливати в клауди дані з ним.
Але і тут виникли свої нюанси і зміни в планах.
Спершу ідея була мати shell-скрипти з викликом rsync на Linux-хостах, ці скрипти запускати по cron, робити бекапи і заливати їх на NAS – і навіть написав такі скрипти на робочому ноутбуці.
Втім, коли вже почав збирати всю систему, то постало питання – а коли саме з NAS запускати rclone, щоб вже оновлені з rsync бекапи залити в клауд?
Власне, тоді і прийшло розуміння, що потрібен якийсь “control loop”, який буде і запускати копіювання даних з інших хостів, і на самому NAS, і після завершення копіювання даних – буде заливати апдейти в Google Drive та Backblaze, ще і виконувати якісь додаткові дії.
Тобто загальна схема тепер така: запускати rsync прямо з NAS, з цього “control loop бекап-скрипта”, з rsync по SSH підключатись до віддалених хостів, збирати дані, і в кінці, точно знаючи, що всі дані зібрані – вже можна спокійно запускати rclone.
Коротко про мою мережу і хости взагалі – детальніше було в пості FreeBSD: Home NAS, part 13:
- є кілька хостів – ноутбуки/ПК з Arch Linux, сам хост NAS з FreeBSD, окрема машинка Raspberry PI, сервер rtfm.co.ua на Debian
- всі хости об’єднані з WireGuard в єдину мережу (див. MikroTik: налаштування WireGuard та підключення Linux peers)
- з усіх систем треба робити резервні копії даних – з домашніх директорій та/або якісь системні конфіги
- ці всі бекапи треба зберігати на ZFS-датасетах на NAS
- треба робити ZFS snapshots
- і окремо заливати копію даних до Google Drive та Backblaze (див. Backblaze: знайомство з B2 Cloud Storage – перші враження)
Архітектура реалізації
Є три системи, які керують даними:
- Syncthing: синхронізує частину даних з
/home/setevoyміж ноутбуками, NAS і телефоном (див. FreeBSD: Home NAS, part 12: синхронізація даних з Syncthing) - Rsync: основна “робоча лошадка” для копіювання даних між хостами – збирає з Linux, Raspberry PI, DigitalOcean, та з самого NAS/FreeBSD
- Rclone: займається синхронізацією даних в клауди
rclone робить sync в Google Drive та Backblaze з опцією --backup-dir – тому навіть якщо Syncthing щось наламає і видалить, а потім ці зміни синхронізуються в клауд – то все одно залишаться копії видалених даних.
І плюс в самому Syncthing для всіх shared директорій включена “Trash Can Versioning”.
Загальна схема виглядає так:
Як писав в попередньому пості – є кілька різних “класів даних” які зберігаються в окремих датасетах, і кожен датасет мапиться на власний rclone remote з власними налаштуваннями шифрування.
Якщо спростити схему і відобразити тільки потік даних – то це виглядає так:
Структура каталогів і файлів
Взагалі, в чорнетці був розписаний весь процес створення “утиліти”, але вирішив вже просто описати фінальне рішення (і то вийшло нічого собі тексту).
Всі операції виконуються кількома shell-скриптами, всі потрібні налаштування – описані в конфіг-файлах.
Структура файлів та каталогів:
root@setevoy-nas:~ # tree -L 3 /opt/nas_backup/ /opt/nas_backup/ ├── backup.sh ├── config │ ├── hosts.conf │ └── rclone-remotes.conf ├── excludes │ └── global.exclude ├── includes │ ├── setevoy-nas │ │ ├── etc.include │ │ ├── opt.include │ │ ├── root.include │ │ ├── user-home-Data.include │ │ ├── usr-local-bin.include │ │ ├── usr-local-etc.include │ │ └── var.include │ ├── setevoy-pi │ │ ├── opt.include │ │ └── system.include │ ├── setevoy-work │ │ ├── user-home-Films.include │ │ ├── user-home-Media.include │ │ └── user-home-Vault.include │ └── template.include ├── validate-config.sh ├── vmbackup-backup.sh └── web-backup.sh
Скрипти тут:
backup.sh: основний скрипт, який виконує перевірки, запускаєrsync, запускає інші скриптиvalidate-config.sh: перевіряє синтаксис файлів конфігурації з каталогаconfig/vmbackup-backup.sh: виконує бекап бази VictoriaMetrics зvmbackupweb-backup.sh: виконує бекап локального WordPress – файлів та бази даних MariaDB
Про каталог config/ – трохи далі, а каталоги excludes/ та includes/ містять файли для rsync, і для кожного хоста власний каталог з власними налаштуваннями include/exclude.
Тепер трохи про файли і організацію, а потім вже до скриптів.
rsync, include та exclude
Наприклад, файл includes/setevoy-work/user-home-Media.include описує дані, які треба скопіювати з хоста work.setevoy (робочий ноутбук) і каталогу /home/setevoy:
root@setevoy-nas:~ # cat /opt/nas_backup/includes/setevoy-work/user-home-Media.include # Syncthing: # - Books/ => /nas/media # - Documents/ => /nas/media # - Music/ => /nas/media # - Photos/ => /nas/media # - Pictures => /nas/media # - Videos =>/nas/media # Rsync: # - Vault/ => /nas/vault/ ! # - Films/ => /nas/private/ ! # - Drobox/ => /nas/media # - Ops/ => /nas/media # - Projects/ => /nas/media # - To-Sort => /nas/media # - VMs => /nas/media # - Work => /nas/media # - Backups/ => /nas/media ############ ### ROOT ### ############ /home/ /home/setevoy/ ### Backups ### /home/setevoy/Backups/ /home/setevoy/Backups/** ... ### Work ### /home/setevoy/Work/ # <COMPANY_NAME> /home/setevoy/Work/<COMPANY_NAME>/ /home/setevoy/Work/<COMPANY_NAME>/** ..
Файл exclude – один на всіх із загальними шаблонами того, що треба виключити з даних, які включені в include:
root@setevoy-nas:~ # cat /opt/nas_backup/excludes/global.exclude ###################### ### Exclude Global ### ###################### # Syncthing **/.stversions/ **/.git/ **/logs/ **/log/ # Vim temp files **/*.swp **/*.swo **/*.swx **/.*.sw? ...
Сам rsync запускається з exclude=all, але про це детальніше буде далі, бо там є свої нюанси.
Каталог config та файли з налаштуваннями
Тут два файли: один для rsync – hosts.conf, другий, для rclone – rclone-remotes.conf.
Файли перевіряються валідатором – validate-config.sh, а потім парсяться основним скриптом backup.sh.
hosts.conf – параметри для rsync
Файл hosts.conf виглядає так:
root@setevoy-nas:~ # cat /opt/nas_backup/config/hosts.conf ############## ### Syntax ### ############## # hostname|user|include_file|exclude_file|destination|delete=yes/no # Notes: # - include/exclude files can be in subdirectories (e.g., 'setevoy-work/user-home-Vault.include') # - multiple lines for the same host are allowed (different sources to different destinations) # - destination directories will be created automatically if they don't exist # - delete field format: delete=yes or delete=no (explicit format required!) # - delete=yes: rsync will use --delete-delay --delete-excluded (removes files on destination that don't exist on source) # - delete=no: rsync will only copy/update files (no deletion) # IMPORTANT! For system backups and multiple hosts with same username: # - Always include hostname/machine identifier in the destination path # - Example: /nas/systems/work.setevoy/thinkpad-t14-g5/ (not just /nas/systems/) # - This prevents mixing configs from different machines ############################# ### work.setevoy - laptop ### ############################# ### HOME ### # Syncthing: # - Books/ # - Documents/ # - Music/ # - Photos/ # - Pictures # - Videos # Rsync: # - Vault/ => /nas/vault/ # - Films/ => /nas/private/ # - Drobox/ => /nas/media # - Ops/ => /nas/media # - Projects/ => /nas/media # - To-Sort => /nas/media # - VMs => /nas/media # - Work => /nas/media # '/home/setevoy/ALL' => '/nas/media/home/setevoy/ALL/' work.setevoy|setevoy|setevoy-work/user-home-Media.include|global.exclude|/nas/media/|delete=yes ... ################################# ### pi.setevoy - Raspberry PI ### ################################# # '/opt/' => '/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/opt/' pi.setevoy|root|setevoy-pi/opt.include|global.exclude|/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/|delete=yes ... ############################# ### nas.setevoy - FreeBSD ### ############################# # '/opt/' => '/nas/systems/setevoy-nas/thinkcentre-10SUSCF000/opt/' nas.setevoy|root|setevoy-nas/opt.include|global.exclude|/nas/systems/setevoy-nas/thinkcentre-10SUSCF000/|delete=yes ...
Власне, в ньому параметри для запуску rsync:
- ім’я хоста, з якого буде виконуватись бекап
- ім’я юзера для підключення – бо не всюди один, і до деяких взагалі треба root – коли бекапляться якісь системні файли
- третім – відносний шлях до файлу include
- четвертий параметр – exclude, якщо треба задати окремий
- п’ятий – локальний каталог на самому NAS, в який будуть копіюватись дані (і який буде використовуватись для створення ZFS snapshots)
- останній параметр – чи включати опцію
rsync --delete, якщо треба в бекапах на NAS видаляти дані, які були видалені на source
rclone-remotes.conf – параметри для rclone
Синтаксис rclone-remotes.conf аналогічний:
root@setevoy-nas:~ # cat /opt/nas_backup/config/rclone-remotes.conf # used for rclone sync only ############## ### Syntax ### ############## # set as: # dataset|rclone_remote # - no leading and closing slashes on the 'dataset' # - no closing ":" on the rclone_remote # use commands: # - rclone listremotes # - rclone listremotes nas-google-drive-crypted-test # - rclone config show nas-google-drive-crypted-test: ############# ### Media ### ############# # Google nas/media|nas-google-drive-media # Backblaze nas/media|nas-backblaze-crypted-media ...
Тут:
- першим заданий ZFS dataset, з якого будуть копіюватись дані
- другим –
rcloneremote config name, в який дані заливаються
Скрипти
Скрипти 4, поділені по функціональності:
backup.sh: основний скрипт, головний “control loop” – запускає всі інші скрипти таrsync&&rclonevalidate-config.sh: перевіряє синтаксис файлів конфігурації, про які писав вищеvmbackup-backup.sh: запускаєvmbackupдля VictoriaMetricsweb-backup.sh: створює архів файлів мого щоденнику на WordPress таmysqldumpйого бази
Скрипт backup.sh розглянемо останнім, аби спочатку подивитись на те, що він запускає, а вже потім – як він це запускає.
Скрипт validate-config.sh: перевірка синтаксису config-файлів
Запускається з backup.sh самим першим і виконує такий собі “preflight check”.
В глобальних змінних має два конфіг-файли, які йому треба перевірити.
Перевірки для hosts.conf та rclone-remotes.conf трохи відрізняються, бо у них вочевидь різний зміст:
- для
hosts.conf:- перевіряє чи в ньому вказані всі необхідні поля
- виконує перевірку, що include/exclude файли, задані для хостів, реально існують
- хост пінгується (якщо ні – просто видає WARNING, а не ERROR)
- важливо – виконує перевірку синтаксису поля
delete=yes/no, бо це найбільш “болюча” опція (хоча ще єzfs destroy:trollface: )
- для
rclone-remotes.conf:- перевіряє наявність ZFS dataset
- перевіряє наявність
rcloneremote в його конфігу
Цей скрипт ніяких алертів не шле – це виконується в самому backup.sh, якщо валідатор повернув помилку.
Валідація файлу hosts.conf
Перевірка наявності всіх необхідних параметрів доволі проста – маємо файл, читаємо кожен рядок, маємо список полів.
Поля в файлі конфігурації розділені символом “|” – використовуємо його в while IFS='|'.
IFS – це вбудована змінна shell, Internal Field Separator, якій можна перевизначити символ, за яким буде розбиватись зміст строки чи файлу.
Якщо поле пусте – повертаємо помилку:
...
while IFS='|' read -r hostname user include_file exclude_file destination delete_field; do
LINE_NUM=$((LINE_NUM + 1))
# Skip comments and empty lines
case "$hostname" in
\#*|'') continue ;;
esac
echo "Validating line $LINE_NUM: $hostname"
# Check if all fields are present
if [ -z "$hostname" ] || [ -z "$user" ] || [ -z "$include_file" ] || [ -z "$exclude_file" ] || [ -z "$destination" ] || [ -z "$delete_field" ]; then
echo " ERROR: Missing field(s) in line $LINE_NUM"
ERRORS=$((ERRORS + 1))
continue
fi
...
Перевірка опції delete=yes/no розбита на дві окремі перевірки:
- спершу перевіряємо, що опція задана саме як
delete=, а не просто “yes” чи просто “delete“ - потім перевіряємо значення після “
=“, має бути або саме “yes“, або “no“
Виглядає це так:
...
# Validate delete field format
if ! echo "$delete_field" | grep -q '^delete='; then
echo " ERROR: Invalid delete field format. Expected 'delete=yes' or 'delete=no', got: $delete_field"
ERRORS=$((ERRORS + 1))
else
delete_value=$(echo "$delete_field" | cut -d'=' -f2)
if [ "$delete_value" != "yes" ] && [ "$delete_value" != "no" ]; then
echo " ERROR: Invalid delete value. Expected 'yes' or 'no', got: $delete_value"
ERRORS=$((ERRORS + 1))
fi
fi
...
Валідація файлу rclone-remotes.conf
Тут аналогічно: читаємо файл, перевіряємо, що отримали саме дві опції, які розділені символом “|“.
Потім перевіряємо ZFS dataset із zfs list "$dataset", і перевіряємо rclone remote з rclone listremotes:
...
while IFS='|' read -r dataset remote; do
...
# Check if all fields are present
if [ -z "$dataset" ] || [ -z "$remote" ]; then
echo " ERROR: Missing field(s) in rclone config line $RCLONE_LINE_NUM"
ERRORS=$((ERRORS + 1))
continue
fi
# Check if dataset exists
if ! zfs list "$dataset" > /dev/null 2>&1; then
echo " ERROR: Dataset $dataset does not exist"
ERRORS=$((ERRORS + 1))
fi
# Check if rclone remote exists
if ! rclone listremotes | grep -q "^${remote}:$"; then
echo " ERROR: Rclone remote $remote not found"
ERRORS=$((ERRORS + 1))
fi
...
В кінці скрипта рахуємо помилки і виходимо з помилкою, якщо $ERRORS більше нуля:
... if [ $ERRORS -gt 0 ]; then echo "=== Validation FAILED with $ERRORS error(s) ===" exit 1 else echo "=== Validation PASSED ===" exit 0 fi
Скрипт vmbackup-backup.sh
“Під капотом” використовує власну утиліту VictoriaMetrcis vmbackup. Єдиний мінус утиліти – поки що не підтримує VictoriaLogs, але PR є, скоро, мабуть, додадуть.
Хоча особисто мені бекап логів і непотрібний, а от бекап бази – треба, бо в мене там дані мого “Self Monitoring Project”, де я записую дані по тому, як спав, який настрій – і ці дані записую з 2023 року, втратити їх не хочеться.
Скрипт виконує два типи бекапів – інкрементальний по буднях, і повний – в неділю, плюс видаляє старі бекапи.
На відміну від валідатора – тут вже свій обробник алертів, який шле нотифікації на ntfy.sh.
ntfy.sh – дуже класний сервіс для таких випадків, дуже простий, і, сподіваюсь, я таки запущу self hosted версію і напишу про нього окремо.
Для алертів в скрипті описана окрема функція, яка просто з curl шле POST-запит до сервісу:
...
# Alerts configuration (same as backup.sh)
NTFY_TOPIC="my-alerts"
NTFY_URL="https://ntfy.sh/$NTFY_TOPIC"
NTFY_TOKEN_FILE="/root/ntfy.token"
HOSTNAME=$(hostname)
...
NTFY_TOKEN=$(cat "$NTFY_TOKEN_FILE" | tr -d '\n')
send_alert() {
TITLE="$1"
MESSAGE="$2"
TAGS="${3:-warning,backup}"
curl -s \
-H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: $TITLE" \
-H "Tags: $TAGS" \
-d "$MESSAGE" \
"$NTFY_URL" >/dev/null
}
...
В параметрах vmbackup задаються дві опції:
... # VictoriaMetrics settings VM_DATA_PATH="/var/db/victoria-metrics" VM_SNAPSHOT_URL="http://localhost:8428/snapshot/create" ...
VM_DATA_PATH використовується для того, щоб, власне, скопіювати дані, а через ендпоінт VM_SNAPSHOT_URL – vmbackup передає команду до VictoriaMetrics на “заморозку” операцій, аби створити консистентний snapshot.
Запуск самих бекапів і відправка алертів виглядають так:
... vmbackup \ -storageDataPath="$VM_DATA_PATH" \ -snapshot.createURL="$VM_SNAPSHOT_URL" \ -dst="fs://$BACKUP_BASE/latest" >> "$LOGFILE" 2>&1 INCREMENTAL_EXIT=$? if [ $INCREMENTAL_EXIT -ne 0 ]; then echo "ERROR: Daily incremental backup failed with exit code $INCREMENTAL_EXIT" | tee -a "$LOGFILE" send_alert "VMBackup: Incremental backup failed" "❌ VictoriaMetrics incremental backup failed on $HOSTNAME Exit code: $INCREMENTAL_EXIT Log: $LOGFILE" FAILED=$((FAILED + 1)) else echo "Daily incremental backup completed successfully" | tee -a "$LOGFILE" fi ...
В результаті є кілька директорій – latest для інкрементальних бекапів, та <DATE> для weekly:
root@setevoy-nas:~ # tree -d -L 2 /nas/services/victoriametrics/
/nas/services/victoriametrics/
├── 20260222
│ ├── data
│ ├── indexdb
│ └── metadata
├── 20260301
│ ├── data
│ ├── indexdb
│ └── metadata
└── latest
├── data
├── indexdb
└── metadata
Видалення старих бекапів виконується з find, як і в інших скриптах.
В цьому прикладі спеціально залишаю першу, тестову версію – без реального rm -rf:
...
# Calculate cutoff date (RETENTION_WEEKS weeks ago, in YYYYMMDD format)
CUTOFF=$(date -v-${RETENTION_WEEKS}w +%Y%m%d 2>/dev/null || date -d "${RETENTION_WEEKS} weeks ago" +%Y%m%d)
find "$BACKUP_BASE" -maxdepth 1 -type d -name '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]' | while read dir; do
DIR_DATE=$(basename "$dir")
if [ "$DIR_DATE" -lt "$CUTOFF" ]; then
echo "Deleting old weekly backup: $dir" | tee -a "$LOGFILE"
# TODO: uncomment when tested
#rm -rf "$dir"
echo "[DRY-RUN] would delete: $dir"
fi
done
...
Скрипт web-backup.sh
Тут задача – створити архів файлів та зробити дамп бази даних.
Дуже простий, бекапить тільки один сайт, але мені поки більше і не треба.
Також має власний алертинг.
Бекап файлів створюється з tar:
...
SITE_DIR="/usr/local/www/blog.setevoy"
DB_NAME="nas_blog_setevoy_production_db"
DB_CREDENTIALS="/root/.my.cnf.blog-setevoy"
FILES_DEST="$BACKUP_BASE/setevoy/files/${DATE}-blog-setevoy.tar.gz"
DB_DEST="$BACKUP_BASE/setevoy/databases/${DATE}-blog-setevoy.sql"
# Backup files
echo "Archiving files: $SITE_DIR -> $FILES_DEST" | tee -a "$LOGFILE"
tar -czf "$FILES_DEST" --exclude="$SITE_DIR/wp-content/updraft" "$SITE_DIR" >> "$LOGFILE" 2>&1
TAR_EXIT=$?
if [ $TAR_EXIT -ne 0 ]; then
echo "ERROR: Failed to archive blog.setevoy files" | tee -a "$LOGFILE"
send_alert "Web Backup: Failed" "❌ Failed to archive blog.setevoy files on $HOSTNAME
Log: $LOGFILE"
FAILED=$((FAILED + 1))
else
echo "Files archived successfully" | tee -a "$LOGFILE"
fi
...
А база MariaDB – з mysqldump:
... DB_CREDENTIALS="/root/.my.cnf.blog-setevoy" ... # Backup database echo "Dumping database: mysqldump --defaults-file="$DB_CREDENTIALS" "$DB_NAME" > "$DB_DEST" 2>> "$LOGFILE"" mysqldump --defaults-file="$DB_CREDENTIALS" "$DB_NAME" > "$DB_DEST" 2>> "$LOGFILE" DB_EXIT=$? if [ $DB_EXIT -ne 0 ]; then echo "ERROR: Failed to dump database $DB_NAME" | tee -a "$LOGFILE" send_alert "Web Backup: Failed" "❌ Failed to dump database $DB_NAME on $HOSTNAME Log: $LOGFILE" FAILED=$((FAILED + 1)) else echo "Database dumped successfully" | tee -a "$LOGFILE" fi ...
Тут mysqldump без додаткових опцій, бо це чисто мій власний щоденник, де окрім мене нікого не буває.
Але взагалі варто мати на увазі такі параметри:
--single-transaction: тільки для InnoDB – виконати дамп одною транзакцією без блокування таблиць, бо це може заафектити юзерів--routinesта--triggers: бекапити процедури і тригери – це не WordPress case, але можуть бути корисним--add-drop-table: дефолтне значення true, додає в sql-дампіDROP TABLE IF EXISTSперед кожнимCREATE TABLE– спрощує відновлення в існуючу базу даних
В скрипті використовується опція --defaults-file, через яку передається шлях до файлу з юзером та паролем:
root@setevoy-nas:~ # cat /root/.my.cnf.blog-setevoy [mysqldump] user=mysql-username password="mysql-password" host=localhost
Видалення старих бекапів – аналогічно до попереднього скрипту, просто з find:
... find "$BACKUP_BASE" -type f \( -name "*.tar.gz" -o -name "*.sql" \) -mtime "+$RETENTION_DAYS" | while read f; do echo "Deleting old backup: $f" | tee -a "$LOGFILE" # TODO: uncomment when tested #rm -f "$f" ls -l "$f" done ...
І самі бекапи виглядають так:
root@setevoy-nas:/opt/nas_backup # tree -L 3 /nas/services/web/
/nas/services/web/
└── setevoy
├── databases
...
│ ├── 2026-03-05-03-00-blog-setevoy.sql
│ ├── 2026-03-06-03-00-blog-setevoy.sql
│ └── 2026-03-07-03-00-blog-setevoy.sql
└── files
...
├── 2026-03-05-03-00-blog-setevoy.tar.gz
├── 2026-03-06-03-00-blog-setevoy.tar.gz
└── 2026-03-07-03-00-blog-setevoy.tar.gz
Скрипт backup.sh
Ну і, нарешті, основний скрипт backup.sh, який, власне, і займається “оркестрацією” всього процесу.
Тут по черзі виконуються всі необхідні дії і запускаються скрипти, про які говорили вище.
Логіка виконання
- створюємо lock-файл: корисно, якщо попередній запуск скрипта завис – щоб не запустити одночасно два процеси виконання
- скриптом
validate-config.shвиконується перевірка файлівhosts.confтаrclone-remotes.conf - по черзі запускаємо скрипти бекапів:
- з
web-backup.sh– бекапиться WordPress - з
vmbackup-backup.sh– бекапиться VictoriaMetrics
- з
- далі читаємо конфіг
hosts.confдляrsync, для кожного хоста визначаємо потрібні параметри, і в циклі для кожного хоста:- виконуємо
rsync– спершу з--dry-run, потім вже реальний запуск - якщо
rsyncвиконався без помилок – то створюємо ZFS снапшот
- виконуємо
- вже не в циклах – видаляємо старі ZFS снапшоти
- читаємо конфіг
rclone-remotes.confдляrclone- в циклі запускаємо
rclone syncдля кожного заданого в конфігу ZFS dataset та відповідногоrcloneremote
- в циклі запускаємо
- і в кінці з ntfy.sh відправляємо результат виконання
Step 1: створення lock file
... LOCKFILE="/var/run/nas-backup.lock" ... # Check if another instance is running if [ -f "$LOCKFILE" ]; then echo "ERROR: Another backup is already running (lock file exists: $LOCKFILE)" | tee -a "$LOGFILE" send_alert "NAS Backup: Already running" "⚠️ Another backup instance is already running on $HOSTNAME Lock file: $LOCKFILE" exit 1 fi # Create lock file echo $$ > "$LOCKFILE" # Remove lock file on exit trap 'echo ""; echo "Caught interrupt, cleaning up..."; kill $(jobs -p) 2>/dev/null; rm -f $LOCKFILE; exit 130' INT TERM trap 'rm -f $LOCKFILE' EXIT ..
Тут:
- перевіряємо, що файлу зараз нема – тобто попередній запуск скрипта вже завершено
- створюємо файл
/var/run/nas-backup.lock, з$$в файл записуємо PID процесу - запускаємо
trap, який перехопить Ctrl+C (Interrupt) або SIGTERM і видалить lock file
Step 2: запуск валідатора validate-config.sh
Тут все просто – після створення lock file запускаємо validate-config.sh, з if перевіряємо його код виконання:
... # Run validator first echo "Running configuration validator..." | tee -a "$LOGFILE" if ! /opt/nas_backup/validate-config.sh >> "$LOGFILE" 2>&1; then echo "ERROR: Configuration validation failed" | tee -a "$LOGFILE" send_alert "NAS Backup: Config validation failed" "❌ Config validation failed on $HOSTNAME Script: backup.sh Log: $LOGFILE" exit 1 fi echo "" | tee -a "$LOGFILE" ...
Steps 3 та 4: запуск бекапів Web та VictoriaMetrics
Аналогічно – запускаються з if:
... echo "=== Starting web backups ===" | tee -a "$LOGFILE" if ! /opt/nas_backup/web-backup.sh >> "$LOGFILE" 2>&1; then echo "WARNING: web_backup.sh failed, continuing..." | tee -a "$LOGFILE" send_alert "NAS Backup: Web backup failed" "⚠️ web_backup.sh failed on $HOSTNAME, continuing with rsync\nLog: $LOGFILE" exit 1 fi echo "" | tee -a "$LOGFILE" # Step 2: VictoriaMetrics backup echo "=== Starting VictoriaMetrics backup ===" | tee -a "$LOGFILE" if ! /opt/nas_backup/vmbackup-backup.sh >> "$LOGFILE" 2>&1; then echo "WARNING: vmbackup-backup.sh failed, continuing..." | tee -a "$LOGFILE" send_alert "NAS Backup: VMBackup failed" "⚠️ vmbackup-backup.sh failed on $HOSTNAME, continuing with rsync\nLog: $LOGFILE" fi echo "" | tee -a "$LOGFILE" ...
Step 5: запуск циклу з hosts.conf
Сама, мабуть, важлива і цікава частина – тут починається процес збору даних з усіх хостів, які задані в hosts.conf.
Спершу в циклі читається файл конфігу, заповнюються всі “локальні” змінні:
...
while IFS='|' read -r hostname user include_file exclude_file destination delete_field; do
# Skip comments and empty lines
case "$hostname" in
\#*|'') continue ;;
esac
# Parse delete option
delete_value=$(echo "$delete_field" | cut -d'=' -f2)
...
Далі хост пінгується, і, якщо ping не пройшов – то цикл while переходить до наступного рядка з файлу конфігурації.
Реалізовано це за допомогою оператора continue, який є в тому числі в “\#*|'') continue ;;“: якщо в hosts.conf строка – це коментар, то скіпаємо її і переходимо до наступної.
Аналогічно continue використовується для ping:
...
# Check if host is reachable
if ! ping -c 3 "$hostname" > /dev/null 2>&1; then
echo "WARNING: Host $hostname is not reachable, skipping" | tee -a "$LOGFILE"
send_alert "NAS Backup: Host unreachable" "⚠️ Host $hostname is not reachable on $HOSTNAME
Skipping backup
Log: $LOGFILE"
echo "" | tee -a "$LOGFILE"
continue
fi
...
Тут – якщо ping повернув не success – то шлемо алерт і через continue переходимо до наступного хоста.
Аналогічно в наступній дії – перевіряється локальна директорія, в яку будуть копіюватись дані, якщо її нема – вона створюється, якщо створення завершилось з помилкою – то переходимо до наступного хоста:
...
# Create destination directory if it doesn't exist
if [ ! -d "$destination" ]; then
echo "Creating destination directory: $destination" | tee -a "$LOGFILE"
mkdir -p "$destination" >> "$LOGFILE" 2>&1
if [ $? -ne 0 ]; then
echo "ERROR: Failed to create destination directory" | tee -a "$LOGFILE"
send_alert "NAS Backup: Failed to create destination" "❌ Failed to create destination directory on $HOSTNAME
Host: $hostname
Destination: $destination
Log: $LOGFILE"
echo "" | tee -a "$LOGFILE"
continue
fi
fi
...
Step 6: запуск rsync
Після цього, власне, починається процес копіювання даних з кожного хоста – і тут є кілька підводних каменів.
rsync та опції –delete
Дуже важлива – бо небезпечна – опція: чи видаляти на NAS дані, які були видалені на source.
В hosts.conf вона задається в кінці строки:
... work.setevoy|setevoy|setevoy-work/user-home-Media.include|global.exclude|/nas/media/|delete=yes ...
В самому backup.sh перевіряється її значення і, якщо delete=yes – то в змінну $RSYNC_DELETE_OPTS задається значення --delete-delay:
...
# Build rsync options based on delete setting
# default is empty, i.e. no delete
# IMPORTANT: DON NOT SET '--delete-excluded' if using multiply .includes: `rsync` is running with the `--exclude='*'` and will wipe all other data
RSYNC_DELETE_OPTS=""
if [ "$delete_value" = "yes" ]; then
RSYNC_DELETE_OPTS="--delete-delay"
fi
...
Тут в коментарі до перевірки записав, і ще раз підкреслю окремо – бо я з цим трохи мав проблему:
rsyncзапускається опцією--exclude='*'(про це трохи далі)- якщо в
$RSYNC_DELETE_OPTSвказати--delete-excluded– то, відповідно,rsyncна NAS почне видаляти всі дані, які явно не задані в include-файлі
А так як файли include можуть бути різними для різних даних на source, але при цьому на destination – тобто самому NAS, каталог може бути єдиним – то rsync при кожній ітерації видалить дані іншої строки з конфігу.
Ось приклад з Raspberry PI:
... # '/opt/' => '/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/opt/' pi.setevoy|root|setevoy-pi/opt.include|global.exclude|/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/|delete=yes # '/etc/systemd/' => '/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/etc/' pi.setevoy|root|setevoy-pi/system.include|global.exclude|/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/|delete=yes ...
І тут rsync:
- візьме все, що дозволено в
opt.include - скопіює в
/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/ - перейде до наступної строки, візьме все з
system.include - почне копіювати в
/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/– і видалить звідти те, що скопіював при запуску зopt.include
rsync та –exclude=’*’
Чому rsync запускається з --exclude='*'?
Бо, по-перше, особисто я віддаю перевагу підходу “заборонити все і копіювати тільки те, що дозволено явно“.
По-друге – простіший конфіг hosts.conf та сам скрипт backup.sh – достатньо передати тільки ім’я хоста, а далі rsync рекурсивно від / файлової системи проходиться по каталогам які явно дозволені в include-файлі, і копіює дані тільки з них.
Без exclude='*' довелось би або додавати виключення в файл global.exclude, або в include через “-“.
Виконання та опції rsync
Власне запуск самого rsync виглядає так – спочатку --dry-run, потім “running real backup” – те саме, тільки без --dry-run:
...
RSYNC_DELETE_OPTS=""
if [ "$delete_value" = "yes" ]; then
RSYNC_DELETE_OPTS="--delete-delay"
fi
echo "Rsync command: rsync -avh $RSYNC_DELETE_OPTS --prune-empty-dirs --itemize-changes --progress --exclude-from=$EXCLUDES_DIR/$exclude_file --include-from=$INCLUDES_DIR/$include_file --exclude='*' $user@$hostname:/ $destination" | tee -a "$LOGFILE"
echo "" | tee -a "$LOGFILE"
# Run rsync with dry-run first
echo "Running dry-run..." | tee -a "$LOGFILE"
rsync -avh \
--dry-run \
$RSYNC_DELETE_OPTS \
--prune-empty-dirs \
--itemize-changes \
--progress \
--exclude-from="$EXCLUDES_DIR/$exclude_file" \
--include-from="$INCLUDES_DIR/$include_file" \
--exclude='*' \
"$user@$hostname:/" "$destination" >> "$LOGFILE" 2>&1
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "=== Dry-run FAILED with exit code $EXIT_CODE, skipping real backup ===" | tee -a "$LOGFILE"
send_alert "NAS Backup: Dry-run failed" "❌ Rsync dry-run failed on $HOSTNAME
Host: $hostname
Exit code: $EXIT_CODE
Log: $LOGFILE"
BACKUP_FAILED=$((BACKUP_FAILED + 1))
echo "" | tee -a "$LOGFILE"
continue
fi
echo "Dry-run successful, running real backup..." | tee -a "$LOGFILE"
# Run REAL rsync
rsync -avh \
$RSYNC_DELETE_OPTS \
--prune-empty-dirs \
--itemize-changes \
--progress \
--exclude-from="$EXCLUDES_DIR/$exclude_file" \
--include-from="$INCLUDES_DIR/$include_file" \
--exclude='*' \
"$user@$hostname:/" "$destination" >> "$LOGFILE" 2>&1
EXIT_CODE=$?
...
З корисних опцій тут:
-a(archive): зберігає права, власника, сімлінки, timestamps-v(verbose): виводить в лог інформацію що саме виконується-h(human): відображати розмір як 1G замість байт--delete-delay: видаляти дані по завершенню передачі даних, а не в процесі--prune-empty-dirs: якщо на source каталог пустий – не копіювати його--itemize-changes: детальна інформація в лог що саме змінилось в файлі, які перезаписуються/видаляються--progress: показує прогрес передачі кожного файлу
Порядок передачі опцій –include-from та –exclude-from
І окремо про exclude та include.
Має значення в якому порядку параметри передаються до rsync:
- першим йде
--exclude-from– абиrsyncперед запуском копіювання вже “знав” що треба пропускати - далі через
--include-fromпередаємо список каталогів та файлів, які дозволено прочитати та скопіювати - і останнім з
--exclude='*'виключаємо бекапу все, що явно не задано в--include-from
Формат файлів –include-from та –exclude-from
Exclude-файл поки що один глобальний:
###################### ### Exclude Global ### ###################### # Syncthing **/.stversions/ **/.git/ **/logs/ **/log/ # Vim temp files **/*.swp **/*.swo **/*.swx **/.*.sw? **/node_modules/ # Python **/.venv/ **/venv/ **/__pycache__/ ...
Тут через “**” вказуємо “без різниці, де саме цей файл чи каталог буде знайдено“, тобто виключаємо і /root/some-dir/.git/ – і /home/setevoy/some-dir/.git/.
Приклад одного з include-файлів – тут трохи цікавіше:
############ ### ROOT ### ############ /home/ /home/setevoy/ ### Books ### /home/setevoy/Books/ /home/setevoy/Books/** ### Backups ### /home/setevoy/Backups/ /home/setevoy/Backups/** ### Downloads ### /home/setevoy/Downloads/ /home/setevoy/Downloads/Books/ /home/setevoy/Downloads/Books/** ...
Так як rsync запускається з --exclude='*' – то в include йому треба явно дозволити “зайти” в корневий каталог.
Тобто, при виконані rsync -avh [email protected]:/ – rsync зайде в корінь, “/“, потім – маючи /home/ в include-from – зможе “заглянути” в /home/, а далі вже завітати до /home/setevoy/.
І далі аналогічно дозволяємо доступ в /home/setevoy/Books/, де з “**” вказуємо “взяти тут все, що знайдеш” – окрім того, що було задано в exclude-file.
При цьому дані з, наприклад, каталогу /home/setevoy/Bob/ – rsync пропустить, бо не має явного дозволу їх читати і копіювати.
Step 7: створення ZFS snapshots
Після того як rsync для хоста завершився без помилок – запускається наступний if/else:
...
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo ""
echo "=== Backup from $hostname completed successfully ===" | tee -a "$LOGFILE"
BACKUP_SUCCESS=$((BACKUP_SUCCESS + 1))
# Create ZFS snapshot
SNAPSHOT_NAME="nas-backup-$(date +%Y-%m-%d-%H-%M-%S)"
# Get dataset name from destination path
DATASET=$(zfs list -H -o name "$destination" 2>/dev/null | head -1)
if [ -z "$DATASET" ]; then
echo "ERROR: Could not determine ZFS dataset for $destination" | tee -a "$LOGFILE"
send_alert "NAS Backup: Snapshot failed" "❌ Could not determine ZFS dataset on $HOSTNAME
...
else
echo ""
echo "Creating ZFS snapshot: $DATASET@$SNAPSHOT_NAME" | tee -a "$LOGFILE"
zfs snapshot "$DATASET@$SNAPSHOT_NAME" >> "$LOGFILE" 2>&1
if [ $? -eq 0 ]; then
echo "ZFS snapshot created successfully" | tee -a "$LOGFILE"
else
echo "ERROR: Failed to create ZFS snapshot" | tee -a "$LOGFILE"
send_alert "NAS Backup: Snapshot failed" "❌ Failed to create ZFS snapshot on $HOSTNAME
...
fi
fi
..
В BACKUP_SUCCESS=$((BACKUP_SUCCESS + 1)) просто інкрементиться значення, яке використовується виключно для фінального повідомлення через ntfy.sh.
Далі формуємо ім’я снапшоту, і в змінну $DATASET записуємо ім’я датасету.
Для цього беремо параметр $destination, який в hosts.conf заданий як повний шлях – /nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/, а потім із zfs list отримуємо mountpoint:
root@setevoy-nas:/opt/nas_backup # zfs list -H -o name /nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/ nas/systems
А потім викликаємо zfs snapshot для, власне, створення снапшоту.
Step 8: видалення старих ZFS snapshots
Тут теж потенційно небезпечна операція, бо викликається zfs destroy – яка може дропнути повний ZFS dataset:
...
CUTOFF_DATE=$(date -v-${SNAPSHOT_RETENTION_DAYS}d +%Y-%m-%d 2>/dev/null || date -d "${SNAPSHOT_RETENTION_DAYS} days ago" +%Y-%m-%d)
zfs list -H -t snapshot -o name | grep '@nas-backup-' | while read snapshot; do
SNAP_DATE=$(echo "$snapshot" | sed 's/.*@nas-backup-\([0-9-]*\)-.*/\1/')
if [ "$SNAP_DATE" \< "$CUTOFF_DATE" ]; then
echo "Deleting old snapshot: $snapshot" | tee -a "$LOGFILE"
zfs destroy "$snapshot" >> "$LOGFILE" 2>&1
fi
done
...
Як працює:
- в змінну
$CUTOFF_DATEвносимо дату “сьогодні мінус 30 днів” – бо$SNAPSHOT_RETENTION_DAYSзаданий в 30. - із
zfs list -H -t snapshot -o nameвідображаємо список всіх наявних снапшотів і вибираємо тільки ті, які робились цим скриптом –grep '@nas-backup-' - потім в циклі для кожного снапшоту із
zfs list -t snapshotотримуємо дату, коли цей снапшот був створений, записуємо в змінну$SNAP_DATE - порівнюємо
$SNAP_DATEта$CUTOFF_DATE - і, якщо
$SNAP_DATEстарша за$CUTOFF_DATE– то виконуємоzfs destroy
Step 9: запуск rclone
Тут в цілому підхід аналогічний – читаємо кожну строку з конфігу:
... while IFS='|' read -r dataset remote; do ...
В $dataset записуємо ім’я ZFS dataset, в $remote – ім’я rclone remote.
Ще раз приклад конфігу:
# used for rclone sync only ############## ### Syntax ### ############## # set as: # dataset|rclone_remote # - no leading and closing slashes on the 'dataset' # - no closing ":" on the rclone_remote # use commands: # - rclone listremotes # - rclone listremotes nas-google-drive-crypted-test # - rclone config show nas-google-drive-crypted-test: ############# ### Media ### ############# # Google nas/media|nas-google-drive-media # Backblaze nas/media|nas-backblaze-crypted-media ...
Тобто – беремо dataset nas/media – і копіюємо його зміст до nas-google-drive-media, а потім його ж – але до nas-backblaze-crypted-media.
Приклад rclone remote для Backblaze:
root@setevoy-nas:/opt/nas_backup # rclone config show nas-backblaze-crypted-media [nas-backblaze-crypted-media] type = crypt remote = nas-backblaze-root-media:setevoy-nas-media filename_encryption = off directory_name_encryption = false password = *** ENCRYPTED ***
Весь цикл виглядає так:
...
RCLONE_CONF="/opt/nas_backup/config/rclone-remotes.conf"
if [ ! -f "$RCLONE_CONF" ]; then
echo "WARNING: rclone config not found at $RCLONE_CONF, skipping cloud sync" | tee -a "$LOGFILE"
else
TS=$(date +%F-%H-%M)
while IFS='|' read -r dataset remote; do
# Skip comments and empty lines
case "$dataset" in
\#*|'') continue ;;
esac
echo "Syncing dataset $dataset to $remote" | tee -a "$LOGFILE"
# Get mount point for dataset
MOUNT_POINT=$(zfs get -H -o value mountpoint "$dataset" 2>/dev/null)
if [ -z "$MOUNT_POINT" ] || [ "$MOUNT_POINT" = "-" ]; then
echo "ERROR: Could not get mount point for dataset $dataset" | tee -a "$LOGFILE"
RCLONE_FAILED=$((RCLONE_FAILED + 1))
continue
fi
...
rclone sync "$MOUNT_POINT/" "${remote}:data" \
--backup-dir "${remote}:_archive/$TS" \
--progress \
--stats=30s \
--log-level INFO >> "$LOGFILE" 2>&1
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo "Rclone sync for $dataset completed successfully" | tee -a "$LOGFILE"
RCLONE_SUCCESS=$((RCLONE_SUCCESS + 1))
else
echo "ERROR: Rclone sync for $dataset failed with exit code $EXIT_CODE" | tee -a "$LOGFILE"
send_alert "NAS Backup: Rclone sync failed" "❌ Rclone sync failed on $HOSTNAME
...
RCLONE_FAILED=$((RCLONE_FAILED + 1))
fi
echo "" | tee -a "$LOGFILE"
done < "$RCLONE_CONF"
...
Сам rclone sync виконує саме синхронізацію: якщо на NAS файл або каталог був видалений – то він видалиться і на rclone remote.
Тому, для більш спокійного сну, rclone запускається з --backup-dir, куди копіює дані, які під час виконання sync видаляються або змінюються.
Як це виглядає на remote:
Ну і, власне, на цьому все. Останнім виконується відправка повідомлення про те, як пройшов бекап:
... # Send summary if [ $BACKUP_FAILED -eq 0 ] && [ $RCLONE_FAILED -eq 0 ]; then send_alert "NAS Backup: Completed successfully" "✅ All backups completed successfully on $HOSTNAME Rsync successful: $BACKUP_SUCCESS Rsync failed: $BACKUP_FAILED Rclone successful: $RCLONE_SUCCESS Rclone failed: $RCLONE_FAILED Log: $LOGFILE" "white_check_mark,backup" else send_alert "NAS Backup: Completed with errors" "⚠️ Backups completed with errors on $HOSTNAME Rsync successful: $BACKUP_SUCCESS Rsync failed: $BACKUP_FAILED Rclone successful: $RCLONE_SUCCESS Rclone failed: $RCLONE_FAILED Log: $LOGFILE" fi
Запускається скрипт з crontab:
root@setevoy-nas:~ # crontab -l ... 0 3 * * * /opt/nas_backup/backup.sh
Приклад результату виконання
Як це все щастя виглядає в лог-файлі та повідомлення ntfy.sh.
Початок – робота валідатора:
Завершення – виконання rclone sync:
Що можна покращити
Скрипт(и), звісно, не ідеальні, і можна було б зробити ще:
- запуск
rsyncтаrcloneдля загальної картини можна винести окремим скриптами, як це зроблено дляvalidate-config.shтаvmbackup-backup.sh - зараз весь цикл виконання виконується без можливості вказати “зроби мені тільки web” або “зроби мені тільки rclone” – можна було б додати
getoptчиgetopts, парсити аргументи, з якими запускається скрипт та вибирати, що саме виконувати - додати в аргументи можливість окремого запуску
rsyncчисто з--dry-run - для
rcloneзараз не використовується--ignore-from– можна було б додати - ну і “вішенка на торті” – писати метрики в VictoriaMetrics про те, скільки байт передано, скільки місця на диску було витрачено або звільнено – щось таке
Все.
Поки працює, як є – вже кілька тижнів, поки що без проблем.
![]()




