RE:NODE

restic forget и prune: политики хранения снимков на S3

Автор Команда RE:NODE
17 мин чтения


Политика хранения в restic - это две команды с разными задачами и очень разной ценой. restic forget дешёвая: она читает вашу политику, решает, какие записи о снимках удалить, и удаляет их - это несколько маленьких объектов. restic prune дорогая: она выясняет, какие данные больше не нужны ни одному оставшемуся снимку, затем удаляет целые pack-файлы и перезаписывает частично используемые. На S3 это значит скачивание и загрузку реальных данных при эксклюзивной блокировке репозитория. Для большинства серверов разумная схема такая: forget после каждого бэкапа, prune раз в неделю с --max-unused на значении по умолчанию (5%) или выше, а обе команды запускаются таймерами systemd с --retry-lock, чтобы они ждали друг друга, а не падали. Остальная часть статьи - детали, на которых держится эта схема: что на самом деле считают флаги --keep-*, почему снимки переживают политику, которая, как вам казалось, должна их удалить, сколько стоит prune и что делать, когда репозиторий сообщает о блокировке.

Это глубокое погружение в одну тему. Создание репозитория, бэкап файлов и баз данных, проверка и восстановление описаны в статье restic backups to S3, а сколько места займёт история - в статье S3 storage sizing and the 3-2-1 rule.

Что на самом деле считает forget#

Флаги --keep-* легко прочитать и так же легко понять неправильно. Правило за всеми ними одно: restic сортирует снимки от новых к старым, делит время на интервалы заданного размера (час, день, неделя, месяц, год) и оставляет самый новый снимок в каждом из n последних интервалов, в которых есть снимки.

Из этого следуют три вывода, и каждый кого-нибудь удивляет.

  • Считаются интервалы со снимками, а не календарные единицы. --keep-daily 7 оставляет по одному снимку за каждый из последних семи дней, когда выполнялся бэкап. Если сервер был выключен две недели, семь сохранённых дней растянутся на три недели назад. Так задумано: если задание бэкапа тихо перестало работать, оставшаяся история не начнёт постепенно удаляться.
  • Учитывается только самый новый снимок в интервале. При бэкапах каждые шесть часов и --keep-daily 7 вы сохраняете последний снимок каждого дня, а не первый, а остальные три за день удаляются, если их не оставит другое правило.
  • Правила объединяются. Снимок остаётся, если его оставляет хотя бы одно правило. Самый новый снимок обычно является и дневным, и недельным, и месячным одновременно, поэтому --keep-daily 7 --keep-weekly 4 --keep-monthly 6 оставит около пятнадцати снимков, а не семнадцать.
ФлагИнтервалПримечание
--keep-last nНет - просто n самых новыхВремя не учитывается вовсе
--keep-hourly nЧас по часамПолезно только при частых бэкапах
--keep-daily nКалендарный деньРабочая лошадка
--keep-weekly nКалендарная неделяНеделя начинается с понедельника
--keep-monthly nКалендарный месяц
--keep-yearly nКалендарный год
--keep-tag tНе интервалОставляет каждый снимок с тегом t

Интервалы строятся по собственной метке времени снимка, в местном часовом поясе машины, на которой запущен forget. Сервер в UTC, который делает бэкап в 23:30, и ноутбук в UTC+4, читающий тот же репозиторий, проведут границы дня в разных местах. По той же причине запускайте forget с той машины, которая делает бэкапы, а не со своей рабочей станции.

keep-within и его варианты

--keep-within 30d оставляет все снимки новее заданного срока. В сроке можно сочетать годы, месяцы, дни и часы: 1y6m, 14d, 2d12h. Подвох в точке отсчёта: срок отсчитывается от самого нового снимка в группе, а не от текущего момента. Если бэкапы остановились три месяца назад, --keep-within 30d всё равно сохранит тридцать дней перед последним бэкапом - то же защитное поведение, что и у счётчиков интервалов.

В restic 0.13 появились --keep-within-hourly, --keep-within-daily, --keep-within-weekly, --keep-within-monthly и --keep-within-yearly. Они объединяют обе идеи: по одному снимку на интервал для каждого интервала внутри срока. Запись --keep-within-daily 7d --keep-within-weekly 1m --keep-within-monthly 1y читается естественнее, чем подсчёт интервалов, и лучше ведёт себя при смене расписания бэкапов, потому что сроку всё равно, сколько запусков произошло внутри него.

Выбор политики#

Правильной политики не существует, есть только компромисс между глубиной истории и объёмом хранилища. Решают два вопроса. Как долго проблема может оставаться незамеченной? Повреждённый файл мира или удалённую таблицу часто находят через несколько дней, поэтому дневная история короче недели - это оптимизм. И как далеко назад вы действительно стали бы восстанавливаться? Игровой сервер почти никто не восстанавливает из состояния одиннадцатимесячной давности, но кому-то может понадобиться база за прошлый квартал для бухгалтерского вопроса.

СерверПолитикаПримерно хранит
Небольшой игровой сервер--keep-daily 7 --keep-weekly 4Неделю по дням, месяц по неделям
Сайт или приложение--keep-daily 14 --keep-weekly 8 --keep-monthly 6Шесть месяцев
Бизнес-база данных--keep-hourly 24 --keep-daily 14 --keep-monthly 12Сутки по часам, год по месяцам
Перед обновлениемrestic tag --add keep-foreverПока вы не уберёте тег

Стоимость хранения при более длинной политике меньше, чем ожидают, потому что каждый дополнительный снимок добавляет только те блоки, которые изменились по сравнению с соседним. Месячный снимок, который полгода остаётся уникальным, стоит ровно столько, сколько изменилось за тот месяц, а не целую копию. Арифметика разобрана в статье о размерах хранилища; если коротко, переход от 7 дневных снимков к 7 дневным плюс 6 месячным редко удваивает репозиторий и часто добавляет около трети.

Зато с длиной политики растёт время. Каждый снимок - это метаданные, которые должны прочитать forget, prune и check, а политика --keep-hourly 720 на нагруженном сервере замедляет каждый запуск обслуживания ради очень малой пользы. Прореживайте историю по мере старения: по часам - сутки, по дням - недели, дальше по месяцам.

Закрепление снимков тегами

--keep-tag - это запасной выход. Пометьте снимок тегом, и ни одно правило по времени его не удалит:

bash
$ restic tag --add keep-forever --tag nightly a81d0b37$ restic forget --keep-daily 7 --keep-weekly 4 --keep-tag keep-forever --prune

restic tag также принимает --remove и --set. Установка тега переписывает запись о снимке, поэтому снимок получает новый ID, а старый исчезает из restic snapshots. Используйте это перед крупным обновлением игры, миграцией базы данных или любым другим действием, которое вы, возможно, захотите отменить через месяц, и снимайте тег, когда будете уверены, что откат не понадобится.

Помните, что --keep-tag должен присутствовать при каждом запуске forget. Разовая команда без него, набранная впопыхах, удалит закреплённые снимки как любые другие.

Группы: почему некоторые снимки не исчезают#

forget применяет политику не ко всему репозиторию сразу. Сначала он делит снимки на группы и применяет политику к каждой группе отдельно. По умолчанию используется --group-by host,paths: снимки с одним именем хоста и одним списком сохраняемых путей образуют одну группу.

В большинстве случаев это правильно - дампы баз данных и файловые бэкапы одного сервера хранят свои семь дней каждый, - но именно это вызывает самую частую жалобу на хранение: снимки, которые никогда не истекают. Каждая из этих ситуаций создаёт новую группу, а старая группа хранит свои последние снимки вечно, потому что в своей группе они всегда самые новые:

  • Вы изменили пути бэкапа. Если добавить /etc к заданию, которое копировало /srv, возникает новая группа. Старая группа только с /srv бессрочно хранит свои последние 7 дневных, 4 недельных и 6 месячных снимков.
  • Изменилось имя хоста. Контейнеры получают новое случайное имя хоста при каждой пересборке, а переустановленная машина может не получить прежнее имя. Каждый бэкап с нового имени хоста - это новая группа. Исправьте это при создании бэкапа с помощью restic backup --host web-01, который записывает фиксированное имя независимо от настоящего.
  • Пути записаны по-разному. restic хранит абсолютные пути, поэтому /srv/app и /srv/app/ нормализуются в одно и то же, а путь через символическую ссылку и её цель - нет.

Это видно в выводе пробного запуска, который печатает по одному блоку на группу:

code
Applying Policy: keep 7 daily, 4 weekly, 6 monthly snapshotssnapshots for (host [web-01], paths [/srv]):keep 6 snapshots:...snapshots for (host [web-01], paths [/etc /srv]):keep 14 snapshots:...

Группа, которую вы больше не бэкапите, появляется там с полностью сохранённой историей. Уберите её намеренно: выберите её и примените короткую политику:

bash
$ restic forget --host web-01 --path /srv --keep-last 1 --dry-run

Альтернатива - изменить группировку. --group-by host считает все пути одного хоста единой историей: она переживает смену путей, но сливает снимки файлов и баз данных в одну временную шкалу - с --keep-daily 7 из двух снимков за день уцелеет только самый новый. --group-by host,tags - самый чистый выбор, когда у каждого задания свой тег (files, mysql, postgres): тогда историю определяет тег, а пути можно свободно менять. --group-by '' отключает группировку совсем, что почти никогда не нужно в репозитории с более чем одним заданием.

--host, --path и --tag в forget - это фильтры, а не группировка: они ограничивают, какие снимки команда вообще рассматривает. restic forget --tag mysql --keep-daily 30 применяет тридцатидневную политику к снимкам баз данных и не трогает остальные, так что один репозиторий может нести разные политики для разных заданий.

Безопасный запуск forget#

Читайте пробный запуск в первый раз и каждый раз, когда меняете политику:

bash
$ restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --dry-run

В выводе для каждой группы перечислены снимки, которые будут оставлены, с причиной (daily snapshot, weekly snapshot, last snapshot и так далее), и снимки, которые будут удалены. Если в списке на удаление оказалось то, что вы рассчитывали оставить, это почти всегда проблема группировки или границы интервала в другом часовом поясе, и обе видны в столбце причин.

restic 0.16 и новее отказываются выполнять политику, которая удалила бы все снимки группы; для этого нужен --unsafe-allow-remove-all. forget совсем без политики ничего не удаляет. Обе защиты нужны на случай скрипта, который собирает команду из пустой переменной.

forget также принимает ID снимков напрямую - restic forget a81d0b37 c5e9f210 - это аккуратный способ удалить заведомо плохой снимок, например сделанный в момент, когда дамп базы данных падал, чтобы он не занял месячный слот.

Что делает prune и какие у него параметры#

forget удаляет только записи о снимках. Данные остаются, пока не запустится prune. Prune работает по этапам:

  1. Он читает все оставшиеся снимки и строит набор блоков, которые всё ещё используются.
  2. Он классифицирует каждый pack-файл: используется полностью, не используется вовсе или используется частично.
  3. Полностью неиспользуемые packs удаляются сразу - дёшево, один запрос на удаление каждый.
  4. Частично используемые packs перепаковываются: скачиваются, их живые блоки записываются в новые packs, новые packs загружаются, а старые удаляются.
  5. Индекс перезаписывается в соответствии с результатом.

На этапе 4 уходят время и трафик, и именно его контролируют параметры:

ПараметрПо умолчаниюДействие
--max-unused5%Сколько неиспользуемого места терпеть до перепаковки
--max-repack-sizeбез ограниченийПредел объёма данных, перепаковываемых за один запуск, например 2G
--repack-cacheable-onlyвыкл.Перепаковывать только packs деревьев и индекса
--repack-uncompressedвыкл.Переписать старые несжатые данные в сжатом виде
--dry-runвыкл.Сообщить, что произойдёт, ничего не меняя

Важнее всего --max-unused. Он принимает процент, абсолютный размер вроде 10G или unlimited. При значении по умолчанию 5% prune перепаковывает частично используемые packs, пока оставшиеся потери не станут меньше 5% репозитория. При unlimited он никогда не перепаковывает: удаляет полностью неиспользуемые packs и останавливается. Это обмен: часть места пропадает впустую - pack, в котором используется один блок из пятидесяти, остаётся целиком, - зато prune почти ничего не скачивает.

--max-repack-size вместо этого растягивает работу во времени. Еженедельный prune с --max-repack-size 2G за запуск никогда не перемещает больше примерно двух гигабайт, поэтому большой накопившийся объём после смены политики разбирается за несколько недель, а не за одну ночь, которая заходит на время следующего бэкапа.

--repack-uncompressed имеет значение только для репозиториев, созданных до restic 0.14 и обновлённых до версии формата 2 командой restic migrate upgrade_repo_v2. Старые данные остаются несжатыми, пока их не перепакуют, и этот флаг делает это постепенно. В новом репозитории он ничего не делает.

Перед изменением этих параметров всегда смотрите пробный запуск с -v. Он печатает, сколько будет удалено, сколько перепаковано и сколько неиспользуемого места останется, - то есть именно ту величину, которой вы торгуете.

Цена prune на S3#

На локальном диске prune - это просто ввод-вывод. На S3 каждый шаг - это запрос, а у провайдеров, которые за них берут деньги, затраты делятся на четыре вида:

  • Исходящий трафик (egress). Перепаковка скачивает pack-файлы. Если провайдер берёт плату за исходящий трафик, это самая большая статья расходов, и ею управляет --max-unused.
  • Запросы. Листинг бакета, чтение индексов, удаление packs. Каждый запрос дёшев, но в репозитории на несколько сотен гигабайт десятки тысяч объектов.
  • Загрузка. Новые packs после перепаковки. Входящий трафик обычно бесплатен, но на медленном канале это занимает время.
  • Досрочное удаление. У классов хранения для редкого доступа есть минимальные сроки хранения - 30 дней у Amazon STANDARD_IA, 90 у Glacier Instant Retrieval, - и удаление pack раньше срока оплачивается так, будто он остался.

Последний пункт - причина дважды подумать перед -o s3.storage-class=STANDARD_IA. Packs в репозитории restic постоянно удаляются при работе prune, поэтому более дешёвый класс хранения с учётом платы за досрочное удаление и извлечение может обойтись дороже. Архивные классы, где перед чтением объекта нужен запрос на восстановление, например Glacier Flexible Retrieval и Deep Archive, для репозитория restic не подходят вообще, потому что restic рассчитывает прочитать любой pack по требованию.

Практические настройки по ситуациям:

СитуацияНастройка pruneПочему
Egress оплачивается--max-unused unlimited раз в неделюУдаляет, но ничего не скачивает
Хранилище с фиксированной ценой5% по умолчанию раз в неделюДержит бакет в порядке
Большой накопившийся объём--max-repack-size 2GРастягивает работу
Медленный исходящий каналPrune раз в месяцМеньше долгих блокировок

Если трафик не тарифицируется по гигабайтам, цена prune - это время и эксклюзивная блокировка, а не деньги, и значения по умолчанию подходят. Выберите настройку один раз, исходя из того, как тарифицирует провайдер, и впишите её в задание по расписанию, а не вспоминайте каждый раз.

Ошибки блокировки и устаревшие блокировки#

restic использует файлы блокировки, хранящиеся как объекты в locks/ внутри репозитория, чтобы два процесса не могли повредить друг друга. backup, restore и snapshots берут общую блокировку; несколько таких команд могут работать одновременно. forget и prune берут эксклюзивную блокировку и требуют, чтобы репозиторий принадлежал только им. Пока процесс работает, он обновляет свою блокировку каждые несколько минут.

Когда эксклюзивная операция натыкается на другую блокировку или бэкап застаёт работающий prune, он останавливается с сообщением примерно такого вида (формулировки немного различаются между версиями):

code
unable to create lock in backend: repository is already locked by PID 41234on web-01 by root (UID 0, GID 0)lock was created at 2026-10-12 04:30:02 (6h12m10s ago)storage ID 8c1d2e4fthe `unlock` command can be used to remove stale locks

Прежде чем что-то делать, посмотрите на возраст и хост. Блокировка возрастом в несколько минут принадлежит процессу, который ещё работает: подождите. Блокировка возрастом в часы с того же хоста, для которой ps не показывает процесс restic с таким PID, устарела - её оставил запуск, который был убит, остановлен из-за нехватки памяти или прерван перезагрузкой.

  • `restic unlock` удаляет только устаревшие блокировки: те, процесса которых на этом хосте уже нет или которые не обновлялись более получаса. Её можно безопасно запускать в любое время.
  • `restic unlock --remove-all` удаляет все блокировки, включая действующие. Запускайте её, только если уверены, что нигде нет ни одного процесса restic, использующего репозиторий: снять блокировку работающего prune и запустить бэкап - верный способ повредить репозиторий.
  • `--retry-lock 30m` (restic 0.16 и новее) у любой команды заставляет её ждать блокировку до указанного времени вместо немедленного отказа. Это настоящее решение для заданий по расписанию, которые иногда перекрываются.

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

Если prune жалуется не на блокировку, а на отсутствующие packs или несогласованный индекс, остановитесь и запустите restic check. restic repair index (до 0.16 называлась rebuild-index) перестраивает индекс по реально существующим packs и обычно является первым шагом, после которого prune снова запустится.

Расписание через таймеры systemd#

Для этой задачи таймер systemd лучше cron: пропущенные запуски можно наверстать после перезагрузки, два запуска одного юнита никогда не пересекаются, вывод попадает в журнал с метками времени, а при сбое можно запустить другой юнит. Вам нужна машина, где вы управляете systemd, - собственный VPS или выделенный сервер, - а общая схема описана в статье systemd services for your apps.

EnvironmentFile в systemd ожидает простые строки KEY=value, поэтому отведите ему отдельный файл, а не используйте shell-файл, полный строк export:

/etc/restic/web-01.env
AWS_ACCESS_KEY_ID=RNAKEXAMPLE123AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEYAWS_DEFAULT_REGION=us-east-1RESTIC_REPOSITORY=s3:https://s3.example.com/my-bucket/restic/web-01RESTIC_PASSWORD_FILE=/etc/restic/web-01.passwordRESTIC_CACHE_DIR=/var/cache/restic

Служба бэкапа выполняет бэкап и дешёвый forget:

/etc/systemd/system/restic-backup.service
[Unit]Description=restic backup and forgetWants=network-online.targetAfter=network-online.targetOnFailure=restic-alert@%n.service[Service]Type=oneshotEnvironmentFile=/etc/restic/web-01.envNice=10IOSchedulingClass=idleExecStart=/usr/bin/restic backup /srv /etc --host web-01 --tag files \    --one-file-system --retry-lock 30mExecStart=/usr/bin/restic forget --host web-01 --tag files \    --keep-daily 7 --keep-weekly 4 --keep-monthly 6 \    --keep-tag keep-forever --retry-lock 30m
/etc/systemd/system/restic-backup.timer
[Unit]Description=Nightly restic backup[Timer]OnCalendar=*-*-* 04:30:00RandomizedDelaySec=15mPersistent=true[Install]WantedBy=timers.target

У службы oneshot может быть несколько строк ExecStart=; они выполняются по порядку, и служба падает на первой же неудачной, поэтому неудавшийся бэкап пропускает forget. У prune своя служба и еженедельный таймер, чтобы медленный prune никогда не задерживал бэкап:

/etc/systemd/system/restic-prune.service
[Unit]Description=restic prune and checkOnFailure=restic-alert@%n.service[Service]Type=oneshotEnvironmentFile=/etc/restic/web-01.envNice=15IOSchedulingClass=idleExecStart=/usr/bin/restic prune --max-unused 10% --retry-lock 2hExecStart=/usr/bin/restic check --read-data-subset=5% --retry-lock 2h

Его таймер такой же, как у бэкапа, но с OnCalendar=Sun *-*-* 06:00:00. Затем:

bash
$ systemctl daemon-reload$ systemctl enable --now restic-backup.timer restic-prune.timer$ systemctl list-timers 'restic-*'$ systemctl start restic-backup.service      # run once now, to test$ journalctl -u restic-backup.service -n 50

Persistent=true запускает пропущенное задание один раз при загрузке, если машина была выключена в назначенное время. RandomizedDelaySec разносит во времени несколько серверов, бэкапящихся в одно хранилище, чтобы они не стартовали в одну секунду. OnFailure= запускает шаблонный юнит, который вы пишете сами, - короткий скрипт, отправляющий сообщение в webhook или на почту, - и передаёт ему имя упавшего юнита. Эта часть не опциональна: таймер, который каждую ночь тихо падает, выглядит точно так же, как работающий, и именно к этому всё время возвращается статья monitoring that tells you something.

Если вы предпочитаете cron, те же команды помещаются в скрипт-обёртку с --retry-lock, а синтаксис расписания описан в статье cron expressions explained. Оберните скрипт в flock, чтобы два запуска никогда не пересекались, - systemd даёт это бесплатно.

Устранение неполадок#

Репозиторий остался таким же большим после forget. Вы запустили forget без --prune и так и не запустили prune. Либо prune работал с --max-unused unlimited, и место занято частично используемыми packs. Пробный запуск с -v показывает, сколько места не используется.

Старые снимки не истекают. Устаревшая группа - из-за изменившихся путей или имени хоста. Найдите лишний блок в forget --dry-run и уберите его фильтром --host/--path, а затем задавайте --host при каждом бэкапе.

forget удалил снимки, которые должен был оставить. При этом запуске не было --keep-tag, либо --group-by слил два задания, либо граница дня оказалась в другом часовом поясе. Столбец причин в пробном запуске объясняет каждый оставленный снимок; всё, чего там нет, удалено по замыслу.

Бэкапы падают каждое воскресное утро. Они сталкиваются с эксклюзивной блокировкой prune. Добавьте --retry-lock к бэкапу или перенесите prune на час, когда больше ничего не работает.

Prune работает часами и много скачивает. Крупное изменение политики или первый prune за много месяцев. Ограничьте его через --max-repack-size или повышайте --max-unused, пока накопившийся объём не будет разобран.

Каталог кэша продолжает расти. В /var/cache/restic лежат метаданные каждого репозитория, который открывала эта машина. restic cache --cleanup удаляет кэши репозиториев, которые давно не использовались.

Какую бы политику вы ни выбрали, хотя бы один раз восстановите что-нибудь из самого старого снимка, который она сохраняет. Это единственное доказательство того, что политика оставляет то, что вы думаете, а в статье testing a restore before you need it показано, как сделать это рутиной.

FAQ#

Нужно ли запускать prune после каждого бэкапа?

Нет. forget после каждого бэкапа дёшев и поддерживает список снимков в порядке. prune стоит времени, трафика и эксклюзивной блокировки, а место, которое он освобождает между двумя соседними ночами, ничтожно. Для большинства серверов достаточно раза в неделю; раз в месяц нормально при медленном канале или оплачиваемом egress.

Чем --keep-last отличается от --keep-daily?

--keep-last 7 оставляет семь самых новых снимков, когда бы они ни были сделаны - при почасовых бэкапах это семь часов истории. --keep-daily 7 оставляет самый новый снимок каждого из последних семи дней, в которые они есть, то есть семь дней истории при любой частоте бэкапов.

Безопасно ли запускать forget, пока идёт бэкап?

restic этого не допустит. Бэкап держит общую блокировку, а forget нужна эксклюзивная, поэтому forget либо завершится ошибкой блокировки, либо, с --retry-lock, дождётся окончания бэкапа. То же относится к prune.

Можно ли задать разное хранение для разных заданий в одном репозитории?

Да. Помечайте каждое задание тегом при бэкапе и запускайте по одному forget на тег с собственной политикой, например --tag mysql --keep-daily 30 и --tag files --keep-daily 7. Фильтр --tag заставляет каждый запуск затрагивать только свои снимки.

Экономит ли деньги более дешёвый класс хранения S3 с restic?

Редко. Классы для редкого доступа берут плату за извлечение и за удаление объектов до минимального возраста, а prune постоянно удаляет и переписывает packs. Архивные классы, где перед чтением нужно восстановление, с restic вообще не работают. Стандартный класс хранения - правильный выбор по умолчанию.

Как сохранить один снимок навсегда?

Пометьте его тегом - restic tag --add keep-forever <id> - и добавляйте --keep-tag keep-forever в каждую команду forget. Установка тега меняет ID снимка, поэтому после неё найдите его заново через restic snapshots.