Типичная схема «бэкапа», которую я застаю на VPS: один каталог, в него каждую ночь rsync -a --delete с продакшена. Места мало, скорость нормальная, откатить вчерашний файл нельзя — его уже перетёрли сегодняшним. Второй край: каждый день полный tar в новый файл. Через две недели диск кончился, а проверять restore никто не пробовал, потому что «архив же пишется, код нулевой».
--link-dest лежит между этими двумя дуростями. Каждый слепок выглядит как полный каталог: зашёл в /backup/site/2026-08-27/ — видишь весь сайт, как в тот день. На диске при этом лежат только изменившиеся файлы. Неизменные — hardlink на inode прошлого слепка. Это не магия файловой системы и не «инкремент в проприетарном формате». Это обычный ext4/xfs и обычный rsync. Именно поэтому я его до сих пор ставлю там, где не нужен Borg/restic со своими репозиториями, а нужен каталог, из которого файл можно просто скопировать.
Ниже — как я это собираю, где оно ломается, и как проверить restore так, чтобы не узнать о битом бэкапе в день аварии.
Что именно делает –link-dest, без сказки про «инкремент»
Rsync сравнивает источник с каталогом назначения. Если файла в назначении ещё нет, он смотрит в каталог из --link-dest. Файл там есть, размер и mtime совпали — в новый слепок кладётся hardlink, байты заново не копируются. Файл изменился — в новый слепок пишется новая копия, старый inode остаётся в старом слепке.
Поэтому:
- каждый дневной каталог самодостаточный:
ls,grep,cpработают как по живому дереву; - место растёт примерно на объём изменений, а не на полный размер каждый день;
- удалили файл на проде — в новом слепке его нет (
--delete), в старом он ещё лежит, пока слепок не выкинете.
Hardlink — это не копия и не симлинк. Два имени, один inode. Пока на inode есть хотя бы одна ссылка, данные живы. Выкинули самый старый слепок — unlink уменьшил счётчик, и только для тех файлов, которые больше ни в одном слепке не встречаются, место реально освободилось.
Важное следствие, из-за которого люди потом материются: правка файла внутри слепка правит все слепки, которые ссылаются на тот же inode. Бэкап — не рабочая копия. Залезли поправить «на всякий случай конфиг в бэкапе» — поправили историю. Restore всегда копированием в другой каталог, никогда правкой на месте.
Раскладка каталогов, с которой потом не стыдно жить
Я не нумерую snapshot.0 … snapshot.7, если нет rsnapshot. Номера крутятся mv, в панике в три ночи легко перепутать, что сейчас «сегодня». Даты читаются без расшифровки:
/backup/host-a/
2026-08-25/
2026-08-26/
2026-08-27/
latest -> 2026-08-27
Отдельный корневой каталог на хост. Не сваливать сайты разных машин в одну кучу: hardlink работает только внутри одной файловой системы, а путать, чей это wp-config.php, в аварии дорого.
Диск под бэкапы — отдельный раздел или отдельный диск. Не /var той же машины, которую снимаете. И не NFS, если рассчитываете на hardlink: на NFS hardlink либо запрещён, либо ведёт себя так, что экономии не получите, а du сойдёт с ума. --link-dest требует, чтобы каталог назначения и каталог link-dest были на одной ФС. Проверка перед первым запуском:
df -P /backup/host-a | awk 'NR==1 || {print $1, $6}'
stat -f -c '%i' /backup/host-a # filesystem id, должен совпасть у всех слепков
Если снимаете несколько источников на один раздел — нормально. Если бэкап-диск собрали из двух mount — rsync молча начнёт копировать файлы вместо hardlink, место кончится через несколько ночей, в логе это будет не ошибкой.
Первый слепок и все следующие
Первый прогон — обычный полный rsync, без --link-dest. Каталога для ссылок ещё нет.
SRC=/var/www/site ROOT=/backup/host-a TODAY=$(date +%F) mkdir -p "$ROOT/$TODAY" rsync -aH --numeric-ids --delete --one-file-system \ "$SRC/" "$ROOT/$TODAY/" ln -sfn "$TODAY" "$ROOT/latest"
Следующие ночи — тот же приём, плюс ссылка на предыдущий слепок. Предыдущий я беру не из latest до обновления симлинка, а явно: последний каталог с датой, который не равен сегодняшнему. Если ночной прогон перезапустили дважды за сутки, второй раз должен писать в тот же $TODAY, а не плодить 2026-08-27-1.
SRC=/var/www/site
ROOT=/backup/host-a
TODAY=$(date +%F)
DEST="$ROOT/$TODAY"
PREV=$(find "$ROOT" -mindepth 1 -maxdepth 1 -type d -name '20*' \
! -path "$DEST" | sort | tail -n1)
mkdir -p "$DEST"
RSYNC_OPTS=(-aH --numeric-ids --delete --one-file-system --partial)
if [ -n "$PREV" ]; then
rsync "${RSYNC_OPTS[@]}" --link-dest="$PREV" "$SRC/" "$DEST/"
else
rsync "${RSYNC_OPTS[@]}" "$SRC/" "$DEST/"
fi
ln -sfn "$TODAY" "$ROOT/latest"
Слэш после $SRC/ обязателен: копируем содержимое, а не каталог «site» внутрь слепка. Без слэша через год получите /backup/host-a/2026-08-27/site/... вперемешку со старыми прогонами, где слэш уже стоял.
По флагам, без которых потом больно:
-a— права, времена, симлинки, устройства. Без этого слепок «как файлы», не «как система».-H— сохранить hardlink в источнике. Это не про--link-dest. Если на проде два имени на один inode, без-Hв бэкапе станут две копии.--numeric-ids— uid/gid цифрами, без подстановки имён. На бэкап-сервере пользовательwww-dataчасто другой uid. Без флага права «починятся» молча и неправильно.--delete— в новом слепке нет того, чего уже нет на источнике. Старые слепки это не трогает.--one-file-system— не уехать в/mnt, bind-mount и чужой NFS, который примонтировали «на пять минут».
ACL и xattr на нормальном сайте часто не нужны. Если нужны — добавляю -AX, и проверяю, что приёмник их умеет. На копии «через промежуточный tar на FAT» они умрут, hardlink тут ни при чём.
Тянуть по SSH с другой машины — тот же набор, источник user@host-a:/var/www/site/. Ключ только для чтения нужных путей, не root с интерактивным шеллом «потому что так проще». Хостнеймы в скриптах и known_hosts — свои, не клиентские FQDN из тикета.
Что rsync считает «тем же файлом»
По умолчанию — размер и mtime. Совпали оба, содержимое не читает. Для дерева из сотен тысяч php/jpg это правильно: иначе каждую ночь будете гонять диск впустую.
Где это врёт:
- бэкап-скрипт или деплой делают
touchпо дереву — mtime новый, rsync решит, что всё изменилось, hardlink не состоится, слепок станет почти полным; - наоборот: файл переписали, mtime оставили (редкость, но бывает у кривых копировщиков) — в слепке останется старое содержимое через hardlink;
- права/владелец поменялись, размер и mtime нет — с обычным
-arsync всё же обновит метаданные. С hardlink это тонкое место: смена владельца на inode видна во всех слепках, которые на него ссылаются. На практике для «сайт + загруженные картинки» почти не всплывает. На домашних каталогах с постояннымchown— всплывает.
--checksum лечит второй случай и наказывает диск. Я включаю его точечно, не «на всякий случай каждый день». Для проверки целого слепка после аварии — да. Для ночного прогона магазина с десятками гигабайт upload — нет.
Ещё одна мина: --inplace. Он пишет в существующий файл. Существующий файл в новом слепке после hardlink — это тот же inode, что вчера. --inplace вместе с --link-dest портит историю. Не включать.
Что не класть в слепок живьём
Rsync снимает файлы. Он не делает консистентный снимок СУБД и не понимает очереди почты.
MySQL/MariaDB: ночью mysqldump (или mariadb-dump) в файл, и в слепок идёт дамп, не /var/lib/mysql. Снимать datadir на работающем сервере — лотерея с битым InnoDB. Если очень нужен сырой datadir — только после FLUSH TABLES WITH READ LOCK / горячего снимка LVM/ZFS, и это уже другая схема, не «просто rsync».
PostgreSQL: pg_dump или pg_basebackup, не rsync по base/ на живой базе.
Сессии PHP, кэш, node_modules, очереди — либо exclude, либо смириться, что слепок на 80% состоит из мусора, который ещё и каждый раз «изменился». Типичный exclude для сайта:
/wp-content/cache/ /wp-content/uploads/cache/ /tmp/ /var/tmp/ *.log
Передаю через --exclude-from=/etc/backup/site.excludes. Не размазывать exclude по трём скриптам: через полгода никто не вспомнит, почему картинки из uploads вдруг не попали в слепок.
Полный бэкап системы — отдельный разговор. /proc, /sys, /dev, /run не снимают. --one-file-system как раз спасает от этого, если корневой слепок берёте с /. Загрузчик, UUID в fstab, криптозаголовки — rsync их «как файлы» скопирует, но загрузиться с такого каталога просто так нельзя. Для «откатить сайт» этого достаточно. Для «восстановить весь сервер» я не выдаю rsync-слепок за образ диска.
Место на диске: почему du врёт, если мерить по одному каталогу
Заходите в слепок — du -sh 2026-08-27 показывает почти полный размер сайта. Так и должно быть: внутри каталога почти все файлы «настоящие» с точки зрения дерева, просто inode общие с соседом. Смотреть надо сумму по корню и сравнение «по отдельности vs вместе»:
du -sh /backup/host-a du -sh /backup/host-a/20* du -sh --apparent-size /backup/host-a/20*
Первая строка — сколько занято уникальными данными. Список по датам — каждый слепок выглядит толстым. --apparent-size ближе к «логическому» объёму дерева. Если уникальный du по корню прыгает на размер всего сайта после очередной ночи — hardlink не сработал. Типичные причины: слепки на разных ФС, первый прогон без --link-dest в уже существующий каталог с другими inode, массовый touch, копирование через промежуточный диск без сохранения inode.
Иноды кончаются раньше байтов, если мелких файлов много. WordPress с кэшем и тучей php это умеет:
df -h /backup df -i /backup
Если IUse% под 80%, а место ещё есть — либо чистить старые слепки, либо не снимать то, что exclude должен был отсечь.
Ротация у меня тупая и предсказуемая: хранить 7 дневных, плюс «первое число месяца», если место позволяет. Удалять целиком каталог даты, не файлы изнутри:
# всё старше 14 дней, кроме 01-го числа
find /backup/host-a -mindepth 1 -maxdepth 1 -type d -name '20*' \
| sort | head -n -14 | while read -r d; do
b=$(basename "$d")
case $b in *-01) continue ;; esac
rm -rf "$d"
done
rm -rf по hardlink-дереву безопасен: уменьшает nlink, чужие слепки не трогает. Не делать chmod -R a-w по слепку «чтобы никто не правил». chmod пишет в inode. Общий inode — права поменяются и во вчерашнем слепке. «Защита от записи» — монтированием раздела ro на чтение, отдельным пользователем без записи, не chmod по живым hardlink.
Проверка restore: код 0 у rsync ничего не доказывает
Ночной прогон вернул 0. Это значит: rsync отработал. Это не значит, что из слепка поднимется сайт. Я отдельно гоняю проверку, хотя бы раз в неделю, на копию в песочницу, не на прод.
Минимум, который реально ловит беду:
SNAP=/backup/host-a/2026-08-27
TEST=/tmp/restore-test.$$
mkdir -p "$TEST"
# копируем, не линкуем: иначе снова общий inode
rsync -aH --numeric-ids "$SNAP/" "$TEST/"
# дерево не пустое, ключевые файлы на месте
test -s "$TEST/wp-config.php"
test -d "$TEST/wp-content/uploads"
# сравнение числа файлов — расхождение сразу видно
echo -n "snap: "; find "$SNAP" -type f | wc -l
echo -n "copy: "; find "$TEST" -type f | wc -l
# несколько случайных файлов — содержимое не нулевое и совпадает
find "$SNAP" -type f | shuf -n 20 | while read -r f; do
rel=${f#"$SNAP"/}
cmp -s "$SNAP/$rel" "$TEST/$rel" || echo "DIFF $rel"
done
Если это дамп базы — мало «файл не пустой». Нужен пробный импорт в отдельный экземпляр и SELECT COUNT(*) по жирной таблице, плюс открыть пару записей глазами. Дамп в 200 байт с ошибкой доступа тоже «файл на месте».
Если бэкап тянется по SSH — раз в неделю снимаю md5/sha256 списка ключевых файлов на источнике и в свежем слепке. Не всего дерева: достаточно конфигов, .env, пары известных загрузок. Расхождение mtime при том же содержимом меня не пугает. Расхождение содержимого — уже инцидент, даже если сайт «вроде открывается».
Что я ещё смотрю после ночного прогона, не дожидаясь недели:
- код выхода rsync и последние строки лога:
rsync error, обрыв по SSH,vanished filesпачкой; - размер корня
du -sh /backup/host-aотносительно вчера — скачок на полный объём сайта; - что симлинк
latestуказывает на сегодняшнюю дату, а не застрял на прошлой неделе; - что в слепке нет пустого дерева из-за опечатки в источнике (rsync прекрасно синхронизирует пустой каталог и с
--deleteвычистит новый слепок подчистую, старые не трогая).
Пустой источник — отдельный класс аварии. Защита простая: если find "$SRC" -type f | wc -l меньше порога, скрипт выходит, не вызывая rsync. Порог свой для каждого дерева, не «больше нуля».
Типичные поломки, которые я уже не ищу по второму разу
Слэш и не тот каталог. Источник без завершающего /, destination уже с предыдущим мусором. Слепок «есть», сайта внутри нет, зато есть вложенная лишняя директория.
Два слепка на разных ФС. --link-dest молча деградирует в полное копирование. Лечится только тем, что df смотрят до, а не после письма «диск кончился».
Запуск от пользователя, который не читает часть дерева. Rsync пропускает файлы с Permission denied и в конце может вернуть код 23. Часть людей глотает 23 как «ну почти ноль». Для бэкапа 23 — это дырка. Либо root на чтение, либо доступ по группе, либо явный список, и код 23 будит, а не пишется в лог «на всякий случай».
Живая база в слепке. Сайт после restore «открывается», заказы за вчерашние полдня разъехались. Dump рядом со слепком файлов, одной датой.
chmod/chown по слепкам. История переписана во всех датах, которые делили inode.
Один и тот же destination каждый раз, а –link-dest в соседнюю копию «для экономии». Если destination не чистый каталог новой даты, а вечно живой /backup/current, вы снова приехали к схеме «есть только сегодня». --link-dest имеет смысл, когда новый слепок — новый каталог.
Cron в локальной полночи и пик записи на сайте. Для файлового дерева это обычно терпимо: максимум файл середины заливки. Для заказов и почты — нет. Файлы ночью, дамп базы в окно минимальной нагрузки, не наоборот.
Когда этого достаточно, а когда уже нет
rsync --link-dest хорошо закрывает: сайт, домашние каталоги, конфиги, каталог загрузок, «хочу зайти и забрать вчерашний wp-content». Прозрачность тут важнее степени сжатия. Я могу отдать человеку путь и сказать: копируй отсюда. Без fuse, без restic mount, без сюрприза «репозиторий не открывается, ключ от другого хоста».
Плохо закрывает: много маленьких постоянно меняющихся файлов (тотальный выигрыш hardlink падает), нужна дедупликация между разными машинами, нужен шифрованный репозиторий на чужой площадке. Туда Borg/restic. Не вместо проверки restore — поверх другой модели хранения.
Обёртки вроде rsnapshot делают ровно это же, плюс ротацию hourly.0. Если ставит человек, который не хочет писать скрипт — пусть ставит. Если уже наступил на chmod и на NFS, лучше свой скрипт на два экрана: в нём видно, куда смотреть, когда слепок внезапно стал полным по диску.
И последнее, без чего схему не считаю рабочей: хотя бы один успешный restore в отдельный каталог, с поднятым экземпляром или хотя бы с проверенным дампом. Пока этого не было, у вас не бэкап, а ночной rsync с красивыми датами на каталогах.
Нужна профессиональная удалённая помощь с сервером, сайтом, компьютером или ноутбуком?
Свяжитесь со мной любым удобным для вас способом, и получите её быстро и не дорого.
Обсудить задачуПомогла статья? Поблагодари автора!
Остались вопросы, или есть что добавить? Добро пожаловать в комментарии.
Угостить автора чашечкой кофе