MAATRIX / Блог / Ключ --delete в rsync стёр архив за одну команду

Ключ --delete в rsync стёр архив за одну команду

MAATRIX

Одна команда rsync с флагом --delete, один перепутанный аргумент местами или одна точка монтирования, которая не поднялась после перезагрузки, — и архив, копившийся годами, исчезает за то время, пока утилита обходит дерево файлов. Это не баг и не редкий сбой: --delete работает ровно так, как документировано, и именно в этом главная опасность. Разберём механизм, из-за которого документированная функциональность превращается в инструмент саморазрушения, и конкретные меры, которые ставят между вами и катастрофой хотя бы одну проверку.

Синхронизация — это не только копирование, но и приведение в соответствие

По умолчанию rsync — инструмент аддитивный: он копирует новые и изменённые файлы из источника в приёмник, но ничего не трогает на приёмнике, если там лежит что-то, чего нет в источнике. Это безопасное поведение по умолчанию, и именно поэтому первые запуски rsync редко пугают — команда может отработать криво, но она не удаляет.

Флаг --delete меняет саму семантику операции. Вместо «скопировать то, что есть в источнике» вы просите «сделать приёмник точной копией источника» — а точная копия означает не только наличие всех файлов из источника, но и отсутствие всего, чего в источнике нет. Man-страница rsync формулирует это прямо: --delete удаляет на приёмнике файлы, которых не существует в источнике. Утилита не спрашивает, почему их там нет — забыли скопировать, переименовали, или источник временно недоступен. Единственный факт, который она проверяет: есть ли файл в дереве источника прямо сейчас. Нет — значит, на приёмнике его тоже быть не должно, и rsync добросовестно это исправляет.

Это разумное поведение для настоящей задачи синхронизации — зеркала веб-сайта, реплики конфигов, актуального среза рабочей директории. Проблема начинается там, где rsync --delete используют как замену бэкапа, а источник в этот момент — не тот, каким его считает оператор.

Перепутанное направление: source и destination поменялись местами

Синтаксис rsync ИСТОЧНИК НАЗНАЧЕНИЕ симметричен по написанию и асимметричен по последствиям. Опечатка, скопированная не та строка из истории команд, случайно поменянные местами переменные в скрипте — и команда, которая должна была подтянуть свежие файлы с боевого сервера на архивный, выполняется в обратную сторону.

Представим типичный сценарий: администратор каждую неделю обновляет холодную копию архива командой вида

rsync -avh --delete /mnt/archive-live/ backup-node:/srv/archive-mirror/

Источник — рабочий архив на текущем сервере, приёмник — зеркало на архивном узле. Но однажды команда запускается уже на архивном узле, и по привычке (или из старого файла с алиасами) аргументы идут в обратном порядке:

rsync -avh --delete backup-node:/srv/archive-mirror/ /mnt/archive-live/

С точки зрения rsync ничего подозрительного не происходит: он честно сравнивает два дерева и синхронизирует одно с другим. Просто теперь «источником истины» стало устаревшее или частично очищенное зеркало, а «приёмником» — рабочий архив. Если на зеркале уже удалили часть старых проектов, чтобы освободить место, ровно эти же проекты через несколько минут исчезнут и с боевого архива: --delete увидит, что на зеркале их нет, и решит, что их не должно быть и на приёмнике.

Ситуация усугубляется тем, что rsync при таком запуске отработает быстро и без единой ошибки — с точки зрения протокола передачи всё прошло штатно. Проверять, «та ли директория оказалась источником», — не задача rsync, это задача оператора и обвязки вокруг команды.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Пустой или неполный источник — тот же результат без единой опечатки в командной строке

Второй, более коварный сценарий не требует вообще никакой ошибки в самой команде. Команда написана правильно, направление верное, синтаксис безупречен — но в момент запуска директория-источник оказывается пустой или неполной по независящей от rsync причине.

Классический пример — точка монтирования. Источник для бэкапа часто лежит не на локальном диске, а на смонтированном разделе: NFS-шара, отдельный блочный том, сетевой диск. Если по какой-то причине том не примонтирован — перезагрузка сервера произошла до того, как поднялся сетевой интерфейс, опечатка или неверный порядок записей в /etc/fstab (разбор похожей ситуации — в статье про автомонтирование и ошибку в fstab), недоступен NFS-сервер, — точка монтирования не пропадает. Она остаётся на месте как обычная директория файловой системы, только пустая. Для rsync это неотличимо от ситуации, когда в источнике действительно ничего нет: он видит директорию, обходит её, находит ноль файлов и добросовестно делает вывод, что на приёмнике тоже должно быть ноль файлов.

С похожей механикой связаны и другие причины «временно пустого» источника: сетевое хранилище ответило на подключение, но ещё не отдало листинг файлов; процесс, который должен был выгрузить данные перед синхронизацией, ещё не отработал; права доступа временно ограничены после смены пользователя или контейнера, и rsync читает директорию как пустую из-за отказа в доступе к содержимому.

Итог один и тот же в обоих случаях: --delete не различает «источник закономерно пуст» и «источник должен быть непустым, но временно недоступен». Он видит только текущее состояние дерева файлов и синхронизирует приёмник под него. Это и есть корень катастрофических потерь: разница между «синхронизация» и «удаление всего» — это не баг rsync, а буквальное описание того, что делает флаг --delete, применённый к неверно интерпретированному состоянию источника.

--dry-run: обязательный шаг перед любым --delete на боевых данных

У rsync есть встроенный режим предварительного просмотра, и его использование перед боевым запуском с --delete должно быть таким же рефлексом, как проверка синтаксиса перед DROP TABLE. Флаг --dry-run (короткая форма -n) выполняет весь расчёт изменений — сравнение дерева источника и приёмника, построение списка файлов на копирование и на удаление — но не производит ни одной реальной операции записи или удаления.

rsync -avhn --delete /mnt/archive-live/ backup-node:/srv/archive-mirror/

Ключевая деталь — комбинировать --dry-run с флагом --itemize-changes (короткая форма -i). Он выводит построчный список изменений с префиксами: *deleting для файлов, которые будут удалены, >f+++++++++ для новых файлов, >f.st...... для изменённых. Это единственный способ увидеть не просто «сколько файлов изменится», а конкретно какие файлы попадут под удаление — до того, как это произойдёт по-настоящему:

rsync -avhn -i --delete /mnt/archive-live/ backup-node:/srv/archive-mirror/ | grep '^\*deleting'

Если в выводе внезапно оказались тысячи строк *deleting там, где вы ожидали увидеть пустой список или пару устаревших временных файлов, — это сигнал остановиться, а не подтверждать запуск на автомате. --dry-run полезен ровно настолько, насколько его реально смотрят: встроенный в скрипт без проверки вывода, он превращается в бесполезную строчку, а не в защиту.

--max-delete и другие предохранители от массового удаления

Просмотр вывода вручную работает для разовых запусков, но для регулярной автоматической синхронизации нужна защита, которая сработает и без человека перед экраном. У rsync есть для этого документированный флаг --max-delete=ЧИСЛО: он ограничивает количество файлов и директорий, которые rsync разрешит удалить за один запуск. Если фактическое число удалений превышает порог, rsync прерывает удаление (по умолчанию — прекращая операции удаления, но продолжая копирование остальных изменений) и возвращает ненулевой код завершения с сообщением об этом в логе.

rsync -avh --delete --max-delete=200 /mnt/archive-live/ backup-node:/srv/archive-mirror/

Порог стоит подбирать не «с запасом на всякий случай», а исходя из реальной картины: сколько файлов обычно устаревает и подчищается за одну синхронизацию в штатном режиме. Условный пример: если в норме исчезает единицы-десятки файлов в неделю (переименования, удалённые черновики), --max-delete=200 — запас, который не сработает на обычной неделе, но остановит ситуацию, где источник неожиданно опустел на тысячи файлов.

Дополнительный слой защиты — не удалять физически, а откладывать. Флаг --backup в паре с --backup-dir=ПУТЬ заставляет rsync не стирать файлы, попавшие под --delete, а переносить их в отдельную директорию с сохранением структуры путей. Полезно тут же включить и логирование — --log-file, чтобы через день или неделю можно было понять, что именно и почему было удалено, а не гадать по факту пропажи файлов:

rsync -avh --delete --max-delete=200 \
  --backup --backup-dir=/srv/deleted/$(date +%F) \
  --log-file=/var/log/rsync-archive-sync.log \
  /mnt/archive-live/ backup-node:/srv/archive-mirror/

Это не отменяет риск целиком (при по-настоящему пустом источнике в «удалённые» переедет весь архив), но превращает необратимую операцию в обратимую: файлы физически никуда не пропадают, их можно вернуть на место, пока директория с бэкапом удалений не вычищена отдельным регламентом ротации. Разница между «стёрли навсегда» и «переложили в другую папку, разберёмся утром» — это разница между инцидентом на пять минут и инцидентом на неделю восстановления.

Скрипт-обвязка: проверки перед запуском синхронизации с --delete

Флаги самого rsync ограничивают ущерб, но не устраняют первопричину — то, что источник может оказаться пустым или не тем деревом. Эту первопричину закрывают проверки до вызова rsync, а не внутри него. Минимальный рабочий каркас для скрипта, который запускает --delete по расписанию:

#!/usr/bin/env bash
set -euo pipefail

SRC="/mnt/archive-live/"
DST="backup-node:/srv/archive-mirror/"
MOUNTPOINT="/mnt/archive-live"
MIN_FILES=1000

# 1. Источник обязан быть точкой монтирования, а не пустой директорией на корневом разделе
if ! mountpoint -q "$MOUNTPOINT"; then
    echo "ERROR: $MOUNTPOINT не примонтирован, синхронизация отменена" >&2
    exit 1
fi

# 2. Источник обязан содержать разумный минимум файлов
FILE_COUNT=$(find "$SRC" -type f | wc -l)
if [ "$FILE_COUNT" -lt "$MIN_FILES" ]; then
    echo "ERROR: в источнике только $FILE_COUNT файлов (ожидалось от $MIN_FILES), синхронизация отменена" >&2
    exit 1
fi

# 3. Сама синхронизация — только после того, как первые две проверки пройдены
rsync -avh --delete --max-delete=200 \
    --backup --backup-dir=/srv/deleted/"$(date +%F)" \
    --log-file=/var/log/rsync-archive-sync.log \
    "$SRC" "$DST"

Проверка через mountpoint -q не даёт скрипту принять несмонтированную, но существующую директорию за настоящий источник данных: команда возвращает ненулевой код, если путь не является активной точкой монтирования, и скрипт с set -e останавливается раньше, чем rsync получит управление. Проверка количества файлов через find | wc -l — грубый, но рабочий предохранитель от «источник вроде примонтирован, но там пусто по другой причине». На очень больших деревьях такой подсчёт может занимать заметное время — эта грань производительности rsync разобрана отдельно в статье про потолок rsync на миллионах файлов. Но именно такие проверки превращают «rsync что-то сделал и, наверное, правильно» в «rsync запустился только после того, как источник подтвердил, что он настоящий».

Тестирование на некритичных данных перед боевым архивом

Прежде чем встраивать команду с --delete в крон или в регламент, её стоит один раз прогнать не на архиве, а на его точной, но неважной копии. Это дешёвая проверка, которая ловит ошибки в направлении аргументов, в путях и в логике проверок ещё до того, как они коснутся реальных данных.

Практическая последовательность:

  • Создайте на источнике и приёмнике тестовые директории с той же вложенностью путей, что и в бою, но с заведомо неважным содержимым (несколько десятков файлов-пустышек, повторяющих структуру каталогов).
  • Прогоните финальную команду целиком — с теми же флагами, включая --delete, --max-delete и обвязку со скриптом проверок — на этих тестовых директориях по-настоящему, не в режиме --dry-run.
  • Намеренно смоделируйте аварию: отмонтируйте тестовую точку монтирования (umount) и убедитесь, что скрипт с проверкой mountpoint -q останавливается и не запускает rsync. Затем уменьшите число файлов в тестовом источнике ниже порога MIN_FILES и убедитесь, что срабатывает вторая проверка.
  • Только после того как оба предохранителя доказали, что реально останавливают выполнение, а не просто присутствуют в коде для вида, переносите команду на боевой архив.
  • Держите тестовые директории и после ввода в эксплуатацию — они пригодятся при следующем изменении скрипта, чтобы проверять правки не сразу на архиве.

Любое изменение в команде синхронизации — новый путь, новый флаг, новая обвязка — сначала проходит через --dry-run на боевых путях и только потом снимается флаг предпросмотра. Совмещение «новая логика» и «сразу без dry-run на реальных данных» — частая причина инцидентов, не связанных напрямую с самим --delete, а с изменением, внесённым рядом с ним.

Отдельная защита, которая работает уже после инцидента, а не вместо предотвращения, — независимая копия архива, до которой команда с --delete физически не дотягивается (другой сервер, другие учётные данные, идеально — другая площадка). Логика такого разделения источников риска подробно разобрана в статье про правило 3-2-1 для бэкапов: если один из экземпляров архива стал жертвой перепутанной команды, второй, к которому эта команда доступа не имела, остаётся цел.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Если убрать --delete из команды, риск потери данных пропадает полностью?

Риск удаления архива на приёмнике — да. Но пропадает и синхронизация «под зеркало»: приёмник будет копить устаревшие файлы бесконечно. Для многих задач бэкапа это даже безопаснее — лишний устаревший файл проще найти и убрать вручную, чем восстановить стёртый архив.

--max-delete полностью защищает от катастрофы?

Нет, он лишь ограничивает масштаб одного инцидента числом удаляемых объектов, но не проверяет, обоснованно ли удаление. Если порог выставлен слишком высоко, он не спасёт — это предохранитель на случай, если остальные проверки не сработали, а не замена им.

Чем --dry-run отличается от запуска без --delete?

Запуск без --delete реально копирует новые и изменённые файлы, то есть частично меняет приёмник. --dry-run не выполняет вообще никаких операций записи или удаления — только показывает, что было бы сделано при боевом запуске с текущим набором флагов, включая --delete.

Есть ли у rsync встроенная «корзина» для удалённых файлов?

Отдельной корзины нет, но --backup вместе с --backup-dir даёт похожий эффект: файлы, которые должны были удалиться, переносятся в указанную директорию вместо стирания. Это не автоматическое поведение — оба флага нужно указывать явно.

Стоит ли вообще использовать --delete для бэкапов, а не только для зеркал?

Для бэкапа с историей версий обычно уместнее инструменты со снапшотами — restic, BorgBackup и подобные, где старые данные остаются доступны как отдельная точка восстановления. --delete в rsync хорош там, где приёмник должен быть точной копией текущего состояния источника, а не архивом истории.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →