Резервная копия VPS — это не «где-то лежит архив», а процедура: что копируем, куда, как ротируем и как убеждаемся, что копия разворачивается. Ниже три рабочие схемы — снапшоты на rsync с жёсткими ссылками, дедуплицирующий BorgBackup и restic с бэкендом в объектном хранилище. Скрипты даны целиком, вместе с кодами возврата, исключениями и off-site-частью, о которой обычно забывают.
Что считать бэкапом
Ориентир — правило 3-2-1-1-0:
3 копии данных, включая рабочую;
2 разных носителя или типа хранилища;
1 копия off-site, физически в другом месте;
1 копия неизменяемая: append-only, offline или с запретом удаления на стороне хранилища;
0 ошибок при контрольном восстановлении — то есть восстановление регулярно проверяется, а не предполагается.
Типичная ошибка — держать «бэкап» на втором разделе того же VPS. Одно неудачное
rm -rf, один шифровальщик с правами root, одна авария на стороне гипервизора — и пропадают сразу и данные, и копия. Во всех примерах ниже приёмник — отдельная машинаbackup-host, а не соседний каталог.
rsync: снапшоты на жёстких ссылках
Самая простая схема: каждый запуск создаёт отдельный каталог-снапшот, а неизменившиеся файлы не копируются заново, а становятся жёсткими ссылками на файлы предыдущего снапшота. Дедупликация здесь пофайловая: изменился один байт в файле — файл копируется целиком. Транспорт — обычный SSH, на приёмнике нужны только rsync и sshd, никакого агента. В Ubuntu 24.04 LTS это rsync 3.2.7.
Скрипт удобнее запускать на приёмнике и тянуть данные с VPS (pull): скомпрометированный сервер тогда не имеет доступа к хранилищу бэкапов, а --link-dest, симлинк latest и ротация остаются локальными операциями. Ключ на стороне VPS ограничьте до чтения — для этого в комплекте с rsync идёт обёртка rrsync.
Список исключений
Исключения выносим в файл, а не пишем в командную строку через --exclude={...}: раскрытие фигурных скобок есть в bash, но не в dash. При запуске sh /usr/local/sbin/backup.sh rsync получит весь список одним аргументом, ни один шаблон не сработает, и в бэкап молча уедут /proc и /sys. Файл /etc/backup/excludes.txt:
/proc/*
/sys/*
/dev/*
/run/*
/tmp/*
/mnt/*
/media/*
/lost+found
/swapfile
/var/cache/*
/var/lib/mysql/*
/var/lib/postgresql/*
/var/lib/docker/*Почему именно это:
/var/lib/mysql,/var/lib/postgresql— копия файлов работающей СУБД неконсистентна. Базы выгружаются отдельно, об этом ниже;/swapfile— если он есть, это гигабайты полностью изменившихся данных при каждом запуске, и жёсткие ссылки тут ничем не помогут;/var/lib/docker— сотни тысяч файлов в overlay2, кратное замедление прохода ради данных, которые пересобираются из образа;если хранилище бэкапов примонтировано к тому же серверу, добавьте в список и его точку монтирования, иначе бэкап начнёт копировать сам себя.
Правило простое: исключение должно указывать внутрь того, что вы реально копируете. Строчка /var/log/journal/* в списке, где /var/log вообще не бэкапится, не исключает ничего — она лишь создаёт ложную уверенность.
Скрипт снапшота
#!/usr/bin/env bash
set -euo pipefail
SRC='root@web-01:/' # тянем с VPS, скрипт живёт на приёмнике
DEST=/srv/backup/web-01 # отдельная машина или отдельный носитель
STAMP=$(date +%F_%H%M)
mkdir -p "$DEST/$STAMP"
rc=0
rsync -aAXH -x --numeric-ids \
--exclude-from=/etc/backup/excludes.txt \
--link-dest="$DEST/latest" \
"$SRC" "$DEST/$STAMP/" || rc=$?
# 0 — успех, 24 — исходные файлы исчезли во время копирования (норма для живого /)
case "$rc" in
0|24) ;;
*) echo "rsync failed with $rc" >&2; exit "$rc" ;;
esac
ln -sfn "$DEST/$STAMP" "$DEST/latest"Ключевая деталь — обработка кода возврата. При копировании живой файловой системы rsync штатно возвращает 24 («some files vanished before they could be transferred»): пока шёл проход, PHP-сессия истекла, временный файл удалён. Это норма, а не сбой. Под set -e скрипт на этой строке умирает, ln -sfn не выполняется, и симлинк latest продолжает указывать на позавчерашний снапшот. Дальше --link-dest сравнивает с устаревшей копией, место расходуется впустую, а systemd каждый день рапортует failed — и на алерты перестают смотреть. Код 23 (часть файлов не удалось прочитать) ведёт себя так же.
Флаг -x (--one-file-system) обязателен для SRC=/: без него любая примонтированная в неучтённую точку файловая система — сетевой диск, дополнительный volume, bind-mount — утянется в бэкап целиком. Перед настройкой посмотрите, что вообще смонтировано: findmnt -o TARGET,SOURCE,FSTYPE, и при необходимости добавьте нужные ФС отдельными запусками. Флага --delete здесь нет намеренно: каталог назначения каждый раз новый и пустой, удалять в нём нечего, а в примерах он создаёт впечатление, что старое чистится само. За удаление отвечает только скрипт ротации.
Ротация
Первое, что приходит в голову, — find -mtime +30 -exec rm -rf. Так делать нельзя. rsync -a сохраняет времена и на корневом каталоге передачи, поэтому /srv/backup/web-01/2026-08-25_0320 получает mtime исходного /, а не время создания бэкапа. Дальше два одинаково плохих исхода: если mtime / старше 30 дней (обычное дело на стабильном сервере), первый же запуск ротации сносит все снапшоты разом; если / менялся вчера — не удаляется ничего и диск забивается. Плюс find может удалить каталог, на который указывает latest, и следующий бэкап молча станет полным.
Надёжный вариант — ротация по имени каталога, которое и есть штамп времени:
cd /srv/backup/web-01
KEEP=30
LATEST=$(readlink -f latest || true)
ls -1d 20*_* 2>/dev/null | sort | head -n "-$KEEP" | while read -r d; do
[ "$(readlink -f "$d")" = "$LATEST" ] && continue
rm -rf -- "$d"
done
du -sh /srv/backup/web-01Где схема упирается в потолок
нет шифрования на стороне хранения: кто получил доступ к приёмнику, читает файлы как есть;
нет сжатия на диске (
-zсжимает только транспорт);дедупликация пофайловая: перезаписанный образ или дописанный лог копируется целиком;
десятки снапшотов — это миллионы инодов,
rm -rfстарого снапшота иduначинают занимать заметное время;приёмник обязан быть ФС с жёсткими ссылками и сохранением владельцев — SMB-шара или FAT-диск не подойдут.
BorgBackup: дедупликация и шифрование
Borg режет данные на блоки переменной длины, хранит каждый уникальный блок один раз, шифрует и сжимает репозиторий. Взамен требует установленный borg на обеих сторонах: клиент по SSH запускает borg serve на приёмнике. В Ubuntu 24.04 LTS пакет borgbackup — ветка 1.2, все команды ниже даны для неё.
Репозиторий, ключ и парольная фраза
export BORG_REPO='ssh://backup@backup-host:22/srv/borg/web-01'
borg init --encryption=repokey-blake2
umask 077
head -c 32 /dev/urandom | base64 > /root/.borg-passphrase
chmod 600 /root/.borg-passphraseПро права нужно помнить отдельно: umask по умолчанию отдаст файлу 0644, и парольная фраза станет читаемой любому пользователю системы. То же касается restic:
chmod 600 /root/.borg-passphrase /root/.restic-passПри режиме repokey ключ хранится внутри репозитория, поэтому единственный секрет — парольная фраза. Её бумажную копию делают так:
borg key export --paper :: /root/borg-paper-key.txt
# распечатать, убрать в сейф и удалить с сервера
shred -u /root/borg-paper-key.txtПока эти файлы лежат на сервере, они обязаны быть в исключениях borg create. Иначе и парольная фраза, и бумажный ключ молча уезжают внутрь того же репозитория: хранить пароль от репозитория внутри репозитория бессмысленно, а ключ в архиве — лишняя точка утечки, потому что тот, кто получил репозиторий и пароль, получает заодно и ключ. (На журналируемых ФС и SSD shred не даёт абсолютной гарантии затирания, но убирает файл из области видимости и следующих бэкапов.)
Скрипт бэкапа
#!/usr/bin/env bash
set -euo pipefail
export BORG_REPO='ssh://backup@backup-host:22/srv/borg/web-01'
export BORG_PASSCOMMAND='cat /root/.borg-passphrase'
rc=0
borg create --stats --compression zstd,6 --exclude-caches \
--exclude '/root/.borg-passphrase' \
--exclude '/root/borg-paper-key.txt' \
::'{hostname}-{now:%Y-%m-%dT%H:%M:%S}' \
/etc /home /root /opt /srv /usr/local /var/www /var/backups || rc=$?
[ "$rc" -le 1 ] || { echo "borg create failed: $rc" >&2; exit "$rc"; }
borg prune --list --glob-archives '{hostname}-*' \
--keep-daily=7 --keep-weekly=4 --keep-monthly=6
borg compactКоды возврата у borg 1.2: 0 — успех, 1 — предупреждение, 2 — ошибка. Предупреждение выдаётся, например, на «file changed while we backed it up» или на исчезнувший файл, то есть почти на каждом запуске на живом сервере. Архив при этом создан и валиден. Но под set -euo pipefail скрипт на этой строке обрывается: prune не отработает — архивы копятся вечно, compact не отработает — освобождённое место не вернётся на диск, а юнит будет ежедневно падать в failed. Поэтому rc <= 1 считаем успехом.
Секунды в имени архива — не педантизм. С точностью до минуты два запуска в одну минуту (ручной перезапуск после сбоя) дают Archive ... already exists и падение.
Список источников стоит проверять глазами. Если в нём нет /opt, /srv, /usr/local, /var/spool/cron, то на обычном VPS вы не бэкапите ровно то, где лежат прикладные данные. И наоборот: --exclude '/var/cache/*' при источниках /etc /home /root не исключает ничего.
Append-only на приёмнике
Самая ценная часть схемы и самая пропускаемая. Если клиент ходит в репозиторий обычным ключом, то root на скомпрометированном VPS удаляет бэкапы той же командой, которой их создаёт. Лечится ограниченным ключом на приёмнике — строка в ~backup/.ssh/authorized_keys:
command="borg serve --append-only --restrict-to-repository /srv/borg/web-01",restrict ssh-ed25519 AAAA... web-01-backupЭтим ключом клиент может только дописывать. Обратная сторона: prune и compact с клиента больше не сработают, уплотнение придётся делать отдельно. Два варианта:
на приёмнике, локально, от пользователя
backup:borg prune ... /srv/borg/web-01иborg compact /srv/borg/web-01. Учтите, чтоpruneтребует парольную фразу репозитория — значит, она должна оказаться на приёмнике, и это осознанный размен;вторым, непубличным ключом без
--append-only, который лежит не на боевом сервере и используется вручную в окно обслуживания.
Для restic аналог — rest-server --append-only либо S3 Object Lock и bucket policy с Deny на s3:DeleteObject для ключа, которым ходит сервер.
Оговорка про версии
Команды даны для ветки 1.2. В 1.4 --glob-archives переименован в --match-archives (старое имя работает как алиас с предупреждением). В borg 2.x формат репозитория несовместим и часть команд переименована — borg init стал borg repo-create, миграция выполняется через borg transfer. Перед копипастом проверьте borg --version.
restic: один бинарник и объектное хранилище
Репозиторий и бэкенды
restic — один статический бинарник на Go, который пишет в локальный каталог, по SFTP, в собственный REST-сервер, в S3 и MinIO, Backblaze B2, Azure Blob, Google Cloud Storage, OpenStack Swift или через rclone (WebDAV — только через rclone). Главное отличие от borg: на стороне хранилища ничего ставить не нужно, кроме случая с REST-сервером — а именно он и даёт append-only. В Ubuntu 24.04 LTS доступен пакет ветки 0.16; более свежую версию проще положить одним бинарником в /usr/local/bin.
umask 077
head -c 32 /dev/urandom | base64 > /root/.restic-pass
chmod 600 /root/.restic-pass
export RESTIC_REPOSITORY='s3:https://s3.example.net/backup-web-01'
export RESTIC_PASSWORD_FILE=/root/.restic-pass
export AWS_ACCESS_KEY_ID='...'
export AWS_SECRET_ACCESS_KEY='...'
restic init --repository-version 2Версия репозитория 2 нужна для сжатия; в свежих сборках она и так по умолчанию, но явный флаг снимает вопрос.
Бэкап и политика удержания
#!/usr/bin/env bash
set -euo pipefail
export RESTIC_REPOSITORY='s3:https://s3.example.net/backup-web-01'
export RESTIC_PASSWORD_FILE=/root/.restic-pass
restic backup --tag daily --exclude-caches \
--exclude-file=/etc/backup/restic-excludes.txt \
/etc /home /root /opt /srv /usr/local /var/www /var/backups
restic forget --tag daily \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --pruneФайл /etc/backup/restic-excludes.txt — только пути внутри списка источников, иначе получатся мёртвые правила:
/root/.restic-pass
/root/.cache
/home/*/.cache
/srv/backupПеред первым боевым запуском обязательно прогоните удержание вхолостую:
restic forget --tag daily \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --dry-runforget --prune удаляет снапшоты необратимо — ровно как borg prune, и оговорка тут нужна такая же. Второй нюанс: forget по умолчанию группирует снапшоты по host,paths (--group-by). Если список бэкапируемых путей когда-то менялся, политика применится к каждой группе отдельно, и старых снапшотов останется больше, чем вы ожидали.
Сколько экономит дедупликация: пример расчёта
Это арифметика, а не замер. Возьмём 40 ГБ данных, из которых меняется около 2% в сутки, и 30 ежедневных копий.
Без дедупликации: 30 × 40 ГБ = 1,2 ТБ.
С блочной дедупликацией: первый снапшот 40 ГБ, каждый следующий добавляет примерно 0,8 ГБ, итого 40 + 29 × 0,8 ≈ 64 ГБ.
Реальный коэффициент зависит от характера изменений: лог, который дописывается в конец, дедуплицируется прекрасно, а перепакованный архив, образ виртуалки или дамп БД — почти никак. Считать нужно на своих данных: borg create --stats и restic stats покажут фактические цифры после нескольких прогонов.
Базы данных копируют отдельно
MySQL
umask 077
TMP=$(mktemp /var/backups/app_production.sql.XXXXXX)
mysqldump --single-transaction --quick --routines --events \
app_production > "$TMP"
mv -f "$TMP" /var/backups/app_production.sqlТри причины не писать просто mysqldump ... > /var/backups/app_production.sql. Во-первых, редирект обнуляет файл до запуска mysqldump: упавший дамп оставляет обрезанный или пустой файл, который затем штатно уезжает в бэкап и вытесняет рабочую копию. Во-вторых, без --events молча теряются события планировщика (--triggers, в отличие от них, включён по умолчанию). В-третьих, без umask и mktemp дамп ложится с правами 0644 в каталог с правами 0755 — вся база читается любым пользователем системы.
Ещё одна оговорка, которую часто пропускают: --single-transaction даёт консистентный снимок только для InnoDB и ломается, если параллельно выполняется DDL (ALTER, DROP, TRUNCATE). Для MyISAM нужен --lock-all-tables со всеми вытекающими блокировками.
PostgreSQL
umask 077
TMP=$(mktemp /var/backups/app_production.dump.XXXXXX)
sudo -u postgres pg_dump -Fc app_production > "$TMP"
mv -f "$TMP" /var/backups/app_production.dumpРоли, пароли и табличные пространства в pg_dump не попадают — их выгружают отдельно через pg_dumpall --globals-only, тем же приёмом с mktemp и mv.
Проверка дампа
Дамп, который ни разу не разворачивали, — это файл, а не бэкап. Разворачивайте его в одноразовом контейнере:
docker run --rm -d --name restore-check \
-e MYSQL_ROOT_PASSWORD=secret mysql:8
docker exec -i restore-check mysql -uroot -psecret \
-e 'CREATE DATABASE restore_check;'
docker exec -i restore-check mysql -uroot -psecret restore_check \
< /var/backups/app_production.sql
docker exec -i restore-check mysql -uroot -psecret restore_check \
-e 'SHOW TABLES;'
docker rm -f restore-checkНе проверяйте восстановление на продовом инстансе. Строка вида
mysql restore_check < dump.sqlс опечаткой в имени базы заливает дамп поверх боевых данных без единого подтверждения. Если проверка всё же делается на том же сервере, базаrestore_checkдолжна удаляться сразу после проверки — иначе она останется висеть и попадёт в следующий бэкап.
DROP DATABASE restore_check;Расписание
systemd timer
# /etc/systemd/system/borg-backup.service
[Unit]
Description=BorgBackup nightly run
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/borg-backup.sh
Nice=19
IOSchedulingClass=idle
IOWeight=10# /etc/systemd/system/borg-backup.timer
[Unit]
Description=Run BorgBackup nightly
[Timer]
OnCalendar=*-*-* 03:20:00
RandomizedDelaySec=15m
Persistent=true
[Install]
WantedBy=timers.targetsystemctl daemon-reload
systemctl enable --now borg-backup.timer
systemctl list-timers borg-backup.timer
journalctl -u borg-backup.service -n 50 --no-pagerPersistent=true догоняет пропущенный запуск после простоя машины, RandomizedDelaySec разводит по времени бэкапы соседних серверов, бьющих в одно хранилище. Wants=/After=network-online.target нужен для удалённого репозитория: без него бэкап при загрузке стартует раньше сети.
Про приоритет ввода-вывода стоит знать неприятное: IOSchedulingClass=idle — валидная директива, но ionice-классы реализует только планировщик BFQ. На современных ядрах у виртуальных дисков по умолчанию стоит mq-deadline или none, и класс idle там просто игнорируется — приоритет тихо не применяется. IOWeight= задаётся через io-контроллер cgroup v2 и от ionice-классов не зависит, поэтому имеет смысл указать обе директивы. Если же нужен не приоритет, а жёсткий потолок, используйте IOWriteBandwidthMax=/dev/vda 20M. Таймаут запуска для Type=oneshot по умолчанию отключён, так что многочасовой бэкап юнит не убьёт.
cron
Если systemd не вариант, cron тоже подойдёт, но строку нельзя копировать вслепую — формат зависит от того, куда вы её кладёте:
# root crontab: crontab -e
# В /etc/cron.d/ и /etc/crontab нужен ещё столбец пользователя:
# 20 3 * * * root /usr/local/sbin/borg-backup.sh >> /var/log/borg-backup.log 2>&1
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
20 3 * * * /usr/local/sbin/borg-backup.sh >> /var/log/borg-backup.log 2>&1Строка PATH здесь не для красоты: cron отдаёт скрипту PATH=/usr/bin:/bin, и restic из /usr/local/bin в такой среде падает с command not found. И сразу заведите ротацию лога, иначе он растёт бесконечно — /etc/logrotate.d/borg-backup:
/var/log/borg-backup.log {
weekly
rotate 8
compress
missingok
notifempty
create 0640 root adm
}Проверка восстановления
BorgBackup
mkdir -p /tmp/restore-test && cd /tmp/restore-test
pwd
borg extract --list ::'web-01-2026-08-25T03:20:11' etc/nginxПути внутри архива указываются без ведущего слеша — это правильно и так и надо. А вот чего у borg extract в ветке 1.2 нет, так это опции --target: команда всегда распаковывает в текущий каталог. На фоне restic restore --target это неочевидно, и читатель, скопировавший одну строку borg extract, запросто распакует архив там, где стоит. Из / это перезапись боевого /etc/nginx. Порядок такой: сначала cd, потом pwd для проверки, и только потом extract. Посмотреть содержимое без записи на диск можно через borg extract --dry-run --list или borg mount.
Сравнивать восстановленное только по содержимому мало: diff -r не видит владельцев и права, а именно на них ломается запуск сервисов после восстановления.
diff -r /etc/nginx /tmp/restore-test/etc/nginx
diff <(cd /etc/nginx && find . -printf '%p %m %u:%g\n' | sort) \
<(cd /tmp/restore-test/etc/nginx && find . -printf '%p %m %u:%g\n' | sort)restic
restic restore latest --host "$(hostname)" --path /etc \
--target /tmp/restore-test --include /etc/nginxlatest без фильтров — это последний снапшот во всём репозитории. Если в один бакет пишут несколько серверов, а это типовой сценарий для S3, восстановится чужой /etc/nginx, и diff покажет расхождение, которое вы примете за повреждённый бэкап. Поэтому --host и --path обязательны.
Целостность репозитория
borg check :: # структура и метаданные, регулярно
borg check --verify-data :: # полное чтение и расшифровка, раз в квартал
restic check # структура
restic check --read-data-subset=5% # выборочная проверка данныхborg check --verify-data читает и расшифровывает весь репозиторий: на 100 ГБ это часы CPU и полный проход по диску. В ежедневном скрипте ему не место — там достаточно borg check :: без флага. У restic та же логика: полная проверка данных дорогая, поэтому регулярно читают выборку.
И два парных заклинания на случай, когда задание убили по таймауту и репозиторий остался заблокирован — иначе следующий запуск упрётся в Failed to create/acquire the lock:
borg break-lock ::
restic unlockСравнение
| Критерий | rsync + --link-dest | BorgBackup 1.2 | restic 0.16+ |
|---|---|---|---|
| Дедупликация | пофайловая, жёсткие ссылки | блочная, блок переменной длины | блочная, блок переменной длины |
| Шифрование хранилища | нет | да, репозиторий целиком | да, репозиторий целиком |
| Сжатие | нет | zstd, lz4, zlib, lzma | zstd, формат репозитория v2 |
| Что нужно на приёмнике | rsync и sshd | borg той же ветки | ничего для S3/SFTP, REST-сервер по желанию |
| Off-site | SSH-цель | ssh:// и borg serve | S3, B2, Azure, GCS, Swift, SFTP, REST, rclone |
| Append-only | нет | borg serve --append-only | rest-server --append-only, S3 Object Lock |
| Достать один файл | обычный cp из каталога | borg extract, borg mount | restic restore --include, restic mount |
| Чем платим | место: изменённый файл копируется целиком | borg на обеих сторонах, compact вручную | память и число запросов к хранилищу при prune |
Что выбрать
rsync — когда нужен мгновенный доступ к файлам без всякого инструмента, приёмник свой и место не в дефиците. Это же лучший вариант для «второй копии рядом», которая дополняет, а не заменяет off-site.
BorgBackup — когда важны экономия места и шифрование, а приёмник вы контролируете и можете поставить туда borg. Append-only через
borg serveздесь настраивается одной строкой вauthorized_keys.restic — когда бэкап уезжает в объектное хранилище, серверов много, а ставить агент на приёмник нельзя или не хочется. Расплата — трафик и запросы к хранилищу на операциях
prune.
Инструмент вторичен. Любую из трёх схем нужно довести до одинакового состояния: копия вне сервера, неизменяемое хранилище, ротация, которая не сносит лишнего, обработанные коды возврата — и календарное напоминание раз в месяц развернуть случайный снапшот. Схема, которую ни разу не восстанавливали, бэкапом не является.







