Резервная копия 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-run

forget --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.target
systemctl daemon-reload
systemctl enable --now borg-backup.timer
systemctl list-timers borg-backup.timer
journalctl -u borg-backup.service -n 50 --no-pager

Persistent=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/nginx

latest без фильтров — это последний снапшот во всём репозитории. Если в один бакет пишут несколько серверов, а это типовой сценарий для 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-destBorgBackup 1.2restic 0.16+
Дедупликацияпофайловая, жёсткие ссылкиблочная, блок переменной длиныблочная, блок переменной длины
Шифрование хранилищанетда, репозиторий целикомда, репозиторий целиком
Сжатиенетzstd, lz4, zlib, lzmazstd, формат репозитория v2
Что нужно на приёмникеrsync и sshdborg той же веткиничего для S3/SFTP, REST-сервер по желанию
Off-siteSSH-цельssh:// и borg serveS3, B2, Azure, GCS, Swift, SFTP, REST, rclone
Append-onlyнетborg serve --append-onlyrest-server --append-only, S3 Object Lock
Достать один файлобычный cp из каталогаborg extract, borg mountrestic restore --include, restic mount
Чем платимместо: изменённый файл копируется целикомborg на обеих сторонах, compact вручнуюпамять и число запросов к хранилищу при prune

Что выбрать

  • rsync — когда нужен мгновенный доступ к файлам без всякого инструмента, приёмник свой и место не в дефиците. Это же лучший вариант для «второй копии рядом», которая дополняет, а не заменяет off-site.

  • BorgBackup — когда важны экономия места и шифрование, а приёмник вы контролируете и можете поставить туда borg. Append-only через borg serve здесь настраивается одной строкой в authorized_keys.

  • restic — когда бэкап уезжает в объектное хранилище, серверов много, а ставить агент на приёмник нельзя или не хочется. Расплата — трафик и запросы к хранилищу на операциях prune.

Инструмент вторичен. Любую из трёх схем нужно довести до одинакового состояния: копия вне сервера, неизменяемое хранилище, ротация, которая не сносит лишнего, обработанные коды возврата — и календарное напоминание раз в месяц развернуть случайный снапшот. Схема, которую ни разу не восстанавливали, бэкапом не является.