Администратор выбирает в интерфейсе или скриптом старые теги, удаляет их, запускает df -h и видит, что диск освободился на жалкие проценты. Список релизов стал короче на три десятка строк, а каталог с данными весит почти столько же. Кажется, что registry делает вид, будто что-то удалил.

На самом деле он ничего не скрывает. Просто название релиза и физические данные связаны не так, как список файлов в папке и сами файлы. Чтобы чистить диск и не остаться без образа, который сейчас работает в проде или понадобится при откате, нужно сначала разобраться, что именно в registry на что ссылается. Дальше речь пойдёт о приватном registry на базе Distribution (проект, который многие знают по образу registry:2). Если вы только собираетесь его поднимать, сначала загляните в материал про установку и настройку приватного Docker Registry на VPS: здесь мы начинаем с уже работающего сервиса, у которого заканчивается место.

Два релиза одного приложения: кто на кого ссылается

Возьмём условное приложение shop/api и две его версии: 1.4 и 1.5. Числа ниже выдуманы для наглядности, у вас они будут другими.

Образ в registry состоит из нескольких видов объектов:

  • тег — человекочитаемое имя вроде 1.4 или latest; по сути указатель;
  • манифест — небольшой документ, который перечисляет, из каких частей состоит образ; у него есть собственный дайджест вида sha256:...;
  • конфигурация образа — отдельный blob с описанием запуска и историей сборки;
  • слои — blobs с содержимым файловой системы.

Версии 1.4 и 1.5 собраны из одного базового образа и с одинаковыми зависимостями, поэтому разница между ними — только последний слой с кодом приложения. Картина получается такая:

Условный состав двух релизов
ОбъектРелиз 1.4Релиз 1.5Условный размер
Слой базового образадада, тот же80 МБ
Слой зависимостейдада, тот же120 МБ
Слой с кодомсвойсвой15 МБ
Конфигурация образасвоясвоянесколько КБ
Манифестсвойсвойнесколько КБ

Теперь удалим тег 1.4. Что освободится в лучшем случае? Только собственный слой с кодом на 15 МБ, плюс крошечные манифест и конфигурация, и то лишь после того, как они перестанут быть нужны. Два тяжёлых слоя по-прежнему нужны версии 1.5, значит, их трогать нельзя. Если же у вас сто сборок приложения и в каждой меняется только код, то удаление девяноста старых тегов освободит примерно девяносто небольших слоёв, а не девяносто полноценных образов. Вот почему диск не «худеет» пропорционально списку удалённых имён.

Есть и вторая причина. Тег удаляется быстро, а вот сам манифест может остаться в хранилище. В Distribution удаление обычно означает удаление манифеста по дайджесту через API реестра, и для этого удаление должно быть разрешено в конфигурации (раздел storage, параметр delete). Проверьте это в своём конфиге, а не полагайтесь на привычку: в других реестрах кнопка «удалить тег» может делать другое. Пока манифест лежит в хранилище, он продолжает ссылаться на свои слои.

Что на самом деле делает сборщик мусора

Теперь о том, как именно данные исчезают с диска. По документации Distribution, сборка мусора работает по принципу mark and sweep: сначала registry отмечает blobs, на которые ссылаются манифесты, а затем удаляет всё остальное. Из этого простого правила вытекает несколько практических следствий.

Во-первых, GC не знает про возраст, важность и «последние N релизов». Он смотрит только на ссылки. Если манифест существует, его слои считаются нужными, даже если образ вы не запускали два года. Решение, какие манифесты оставить, принимаете вы — или скрипт, который вы написали и проверили. GC лишь подбирает то, что после вашего решения осталось без хозяев.

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

В-третьих, режим работы. В той же документации прямо сказано: на время GC registry должен быть остановлен или работать в режиме только для чтения, иначе есть риск, что только что загруженные слои будут удалены. Представьте: CI заливает новый образ, слои уже записаны, а манифест ещё нет. На этот момент слои ни на что не ссылаются и выглядят как мусор. Сборщик, запущенный в этот момент, их удалит, а потом придёт манифест, указывающий на пустоту. Публикация формально прошла, а скачать образ нельзя. Поэтому запуск GC параллельно с push — худшее, что можно сделать с registry.

И наконец, ещё одно предупреждение, которое вытекает из темы статьи. Режим сборки мусора одного продукта нельзя переносить на любой OCI-совместимый registry. Harbor, GitLab Container Registry, облачные реестры и другие реализации имеют собственные политики хранения и собственные условия очистки: где-то они работают онлайн, где-то требуют отдельного режима, где-то есть встроенные правила по тегам. Всё, что написано дальше, относится к Distribution. Если у вас другой продукт, откройте его собственную документацию и не применяйте эти шаги вслепую.

Политика хранения и освобождённые блоки — разные вещи

Удобно разделить задачу на два слоя, которые часто смешивают.

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

Освобождение блоков отвечает на вопрос «когда диск реально станет свободнее». Это техническая операция — сборка мусора — с остановкой записи и отдельной проверкой.

Если политика есть, а GC не запускался, диск не освободился. Если GC запускается регулярно, а политики нет, диск тоже не освободится: ведь манифесты всё ещё на месте, и сборщик честно сохраняет всё, на что они ссылаются. Только когда работают оба слоя, получается ожидаемый эффект.

Подводный камень второго слоя — теги, которые никому не нужны, но которые никто не удаляет: промежуточные сборки веток, тег latest, переписанный сто раз, образы pr-1234 от закрытых запросов. Каждый такой тег удерживает свой манифест, пока его не удалили. Поэтому полезно заранее договориться о схеме именования: релизные теги выглядят одним образом (1.5.0), временные — другим (pr-1234, branch-feature-x), и скрипт очистки не тронет первые, пока вы сами этого не решите.

Какие образы нельзя трогать, даже если они старые

Самая опасная формулировка политики — «всё старше 30 дней удалить». Образ, которому полгода, вполне может работать в проде, потому что приложение стабильно и не менялось. Поэтому перед любым удалением составляется список защищаемых образов. В него входят три группы.

  • Запущенное сейчас. Всё, что крутится на серверах окружений: прод, стейдж, демо-стенд для клиента. Смотрите не теги, а дайджесты, потому что тег можно было перезаписать после запуска.
  • Точки отката. Как минимум предыдущий релиз каждого сервиса. Если откат по вашему процессу делается на две версии назад, держите две.
  • Базовые и вспомогательные образы. Если вы публикуете в registry собственные базовые образы или образы инструментов для CI, от них зависят сборки всех проектов. Их удаление ломает не работающий сервис, а будущие сборки.

Как получить дайджесты запущенного? На хосте, где запущен контейнер, можно спросить у Docker, из какого образа он создан. Например:

docker ps --format '{{.Names}} {{.Image}}'
docker inspect --format '{{index .RepoDigests 0}}' registry.example.com/shop/api:1.5

Первая команда покажет имена контейнеров и образы, вторая — дайджест образа, который хранится локально. Если образ был стянут по тегу и затем тег в registry переписали, локальный дайджест и текущий дайджест тега в registry различаются, и это важное наблюдение: защищать нужно именно тот дайджест, который реально запущен.

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

Отдельно стоит сказать про образы, которые собираются для нескольких архитектур. У такого тега под капотом индексный манифест, ссылающийся на манифесты для каждой платформы. Удаляя вручную по дайджесту, легко убрать один из вложенных манифестов и оставить индекс, который станет указывать на отсутствующий образ. Если вы публикуете мультиплатформенные образы, проверяйте каждую платформу при пробной загрузке, о которой речь пойдёт ниже.

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

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

АРЕНДОВАТЬ СЕРВЕР

Где место на диске и что оно значит для VPS

Приватный registry — тот сервис, у которого главный ресурс не процессор, а диск. Слои копятся, а читаются и пишутся они при каждом pull и push. Поэтому при выборе сервера под registry смотрите прежде всего на объём NVMe и на то, сколько места у вас остаётся под данные за вычетом системы и логов. На VPS MAATRIX доступны NVMe-диски, AMD EPYC и root-доступ, а тарифы различаются по объёму диска: от 40 ГБ у Ba до 320 ГБ у Djed. Это просто размер диска, а не обещание, сколько образов поместится: реальная ёмкость зависит от того, как у вас устроены образы и как часто вы их публикуете. Root-доступ нужен именно для описанных ниже действий: остановить контейнер registry, запустить сборку мусора, посмотреть на размер каталога данных.

Практическая рекомендация, которая следует из самой механики GC: держите запас по диску. Сборка мусора не требует много дополнительного места, но если registry заполнен под завязку, у вас нет пространства для снимка каталога перед чисткой, и вы остаётесь без страховки в самый ответственный момент. Кроме того, раздел с данными registry лучше не смешивать с тем, что может внезапно разрастись (логи, сборочные кеши на том же сервере).

Безопасная последовательность очистки

Теперь соберём всё в порядок действий. Он рассчитан на Distribution с хранилищем в локальной файловой системе VPS. Пути и имена контейнеров подставьте свои.

  1. Измерьте исходное состояние. Запишите, сколько занимает каталог данных и сколько свободно на разделе: du -sh /var/lib/registry и df -h. Без этих цифр потом нечего будет сравнивать.
  2. Составьте список защищаемых дайджестов. См. выше: запущенное, откаты, базовые образы.
  3. Определите, какие манифесты удаляются. Это решение политики хранения. Удаляйте манифесты по дайджесту через API реестра (если в конфигурации разрешено удаление) и ведите журнал: что удалено, когда, по какому правилу. Журнал спасёт, если потом выяснится, что чего-то не хватает.
  4. Сделайте копию каталога данных. Если у вас есть место, скопируйте данные или создайте снимок средствами, которые вы настроили сами. Не рассчитывайте на автоматические резервные копии: их наличие зависит от вашей настройки, а не от того, что вы арендовали VPS.
  5. Остановите запись. Согласно документации, на время GC registry должен быть остановлен или переведён в режим только для чтения. Самый простой путь — остановить контейнер и предупредить команды, что публикация образов недоступна. Вариант с read-only делается через раздел maintenance в конфигурации Distribution; уточните точный синтаксис в документации вашей версии.
  6. Запустите пробный проход. В документации описан dry run: он показывает кандидатов на удаление, но данных не трогает. Типичный вызов выглядит так (путь к бинарю и конфигу зависит от того, как вы запускаете registry):
    registry garbage-collect --dry-run /etc/distribution/config.yml
  7. Прочтите вывод. Сравните список кандидатов со списком защищаемого. Если в нём мелькает слой, который вы считали живым, остановитесь: скорее всего, какой-то манифест уже удалён, а вы об этом не знали. Исправляйте политику, а не запускайте GC.
  8. Запустите реальную сборку той же командой без --dry-run. Она выполняет sweep: удаляет blobs, на которые не осталось ссылок.
  9. Верните registry в рабочий режим — запустите контейнер или снимите read-only — и только потом сообщайте командам, что push снова доступен.
  10. Снова измерьте размер каталога и свободное место.

Обратите внимание на порядок: проверка идёт до удаления, а не после. Dry run нужен именно для того, чтобы увидеть последствия, пока их можно исправить. Реальный GC необратим в том смысле, что удалённые слои вернуть можно только из копии или повторной сборкой образов из исходников.

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

Проверка с чистого исполнителя

Сборка мусора прошла без ошибок, диск освободился. Радоваться рано: пока мы не скачали образ так, как это делает настоящий потребитель, мы не знаем, всё ли на месте. Главная ловушка — локальные кеши. На вашем рабочем компьютере и на старом runner слои давно лежат в локальном хранилище Docker, и docker pull может сообщить, что образ актуален, даже если в registry недостающих слоёв уже нет. Docker скачивает только те слои, которых у него нет, так что кешированные он просто не запрашивает.

Поэтому проверка делается там, где кеша нет. Это может быть только что созданный VPS, подготовленный runner без истории образов или хотя бы свежая установка Docker. Новый небольшой сервер удобен ещё и тем, что его можно после проверки удалить: вам нужен он на несколько минут, а не на месяцы. Что на нём выполнить:

  1. Авторизоваться в registry тем же способом, которым авторизуется CI: docker login registry.example.com.
  2. Скачать образ по дайджесту, а не по тегу: docker pull registry.example.com/shop/api@sha256:.... Дайджест берётся из вашего списка защищаемых. Так вы убеждаетесь, что получаете именно тот манифест, который защищали, а не то, что сейчас указывает тег.
  3. Скачать образ предыдущего релиза — точку отката — тем же способом.
  4. Для мультиплатформенных образов повторить загрузку с указанием нужной платформы: docker pull --platform linux/arm64 ..., если такая у вас есть.
  5. Запустить контейнер и убедиться, что приложение стартует: недостающий слой иногда проявляется только при распаковке или запуске.

Если pull завершился ошибкой вроде «blob unknown» или «manifest unknown», значит, в хранилище нет слоя или манифеста, на который что-то ссылается. Тут и пригодятся копия каталога, сделанная перед чисткой, и журнал удалений: по ним видно, что исчезло и почему. Лучше узнать об этом сейчас, на тестовом хосте, чем во время аварийного отката в прод.

Полезно один раз превратить эту проверку в скрипт: список дайджестов из вашего файла, цикл по ним, docker pull на чистой машине, код возврата в отчёт. Тогда после каждой чистки проверка занимает минуту, а не зависит от внимательности человека.

Как не вернуться к той же проблеме через месяц

Одноразовая чистка мало что решает: через месяц диск опять наполнится. Чтобы не повторять ручной подвиг, полезно закрепить несколько привычек.

  • Разделите теги на долгоживущие (релизы) и временные (ветки, pull request). Временные удаляйте по расписанию скриптом, релизные — только осознанно.
  • Публикуйте образы так, чтобы общие слои оставались общими: стабильные инструкции в Dockerfile раньше, часто меняющиеся — позже. Тогда новый релиз добавляет в хранилище только небольшой слой, а старые удаляются дёшево.
  • Планируйте окно обслуживания. Раз во время GC запись должна быть остановлена, договоритесь с командами о времени, когда публикация образов недоступна, и сообщайте о нём заранее.
  • Следите за заполнением диска и поднимайте тревогу не на 95%, а заметно раньше: чтобы был запас для копии данных перед чисткой.

Если один registry обслуживает несколько проектов, имеет смысл не только следить за общим объёмом, но и раз в какое-то время смотреть, какой репозиторий занимает больше всего. Часто оказывается, что один сервис с ежедневными сборками и несжатыми артефактами занимает больше места, чем все остальные вместе, и именно ему нужна более жёсткая политика.

Когда свободное место настоящее

Вернёмся к началу. После удаления тегов диск не стал свободнее, потому что имена релизов оказались лишь верхушкой: под ними лежали манифесты, а под манифестами — слои, часть которых делили между собой соседние версии. Чтобы место действительно освободилось, понадобилось три разных действия: решить, какие образы нужны (политика), удалить лишние манифесты и пересчитать ссылки сборкой мусора на остановленном или read-only registry, а затем проверить результат глазами потребителя.

Теперь у вас на руках числа до и после: каталог данных стал меньше, а на разделе появилось свободное место, которое можно измерить, а не предположить. А на свежем runner, без единого слоя в кеше, команда docker pull по дайджесту нужного релиза спокойно скачивает образ слой за слоем и запускает его. Предыдущий релиз для отката тоже на месте. Диск стал свободнее, а образ, который нужен, никуда не делся.