Skip to main content
flotsam — cover

Избранное

flotsam

Чистка Docker-диска, которая знает, от какого проекта остался каждый объект — индекс «проект → каталог», честная арифметика слоёв и dry run по умолчанию.

  • Go 1.25
  • Docker SDK v28
  • SQLite (modernc.org)
  • Cobra
  • Vue 3 + Vite
  • GitHub Actions
  • ghcr.io multi-arch

Проблема

docker system df с готовностью сообщит, что ушло 84 ГБ. Но он не отвечает на единственный вопрос, который важен перед удалением:

Вот этот том — от какого он проекта, и можно ли его снести, не потеряв данные?

Как только контейнеры проекта удалены, его тома становятся сиротами. На них всё ещё висит лейбл com.docker.compose.project, но нигде не записано, где проект лежал на диске — и Docker эту связь уже не восстановит. Выбор вырождается в docker system prune -a (снести всё и надеяться) либо «пусть лежит ещё пару лет».

Flotsam — это то, что всплывает после того, как корабль пошёл ко дну. Ровно это тулза и собирает.

Что она знает такого, чего не знает Docker

flotsam ведёт собственный индекс: проект → каталог, в SQLite (чистый Go, без CGO), наполняемый пассивно на каждом запуске из лейбла com.docker.compose.project.working_dir — то есть пока контейнеры ещё существуют. Спустя месяцы, когда контейнеров давно нет, индекс всё ещё отвечает:

🟢 legacy-crm                                     12.4 GB   ⚠ каталог отсутствует
   /home/v/work/legacy-crm  (последний раз 2025-11-03, источник: index)
   ├─ volume  legacy-crm_pgdata          8.1 GB   postgresql   🟡 содержит данные БД
   ├─ volume  legacy-crm_uploads         3.9 GB   unknown-data 🟢 каталог проекта удалён
   └─ image   legacy-crm-app:latest       340 MB               🟢 каталог проекта удалён

Разрешение имени — цепочка с явным «неизвестно» в конце: живой лейбл → индекс → скан файловой системы → unknown. А read-only проба монтирует том и говорит, что внутри на самом деле: postgres, mysql, mongo, redis или неопознанные данные. Потому что «8 ГБ чего-то» — не основание для решения.

Честная арифметика освобождения

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

Самое интересное было в сопоставлении записей ImageHistory со слоями RootFS.Layers. Очевидное правило — брать записи с Size > 0 — дало на живой машине всего 55 % совпадений. Причина: WORKDIR создаёт настоящий слой нулевого размера. Классификация записи по CreatedBy (RUN/COPY/ADD/WORKDIR дают слой; ENV/CMD/ENTRYPOINT — нет; #(nop) — нет) плюс relaxed-проход для образов от bazel/ko подняли цифру до 99,4 % — 333 образа из 335 совпали точно.

Модель риска

Классификация — 18 упорядоченных правил, побеждает первое сработавшее, а всё, что не совпало ни с чем, по умолчанию получает risky, а не «наверное, можно»:

УровеньЧто значит
🟢safeКаталог проекта удалён, dangling-образ, пустой том, давно остановленный контейнер
🟡probably-safeПроект спит > 30 дней, свежий неиспользуемый образ, свежий build cache
🔴riskyВ томе данные БД, проект активен, происхождение неизвестно, путь неоднозначен
neverЗащищён конфигом, том подключён к контейнеру, образ запущенного контейнера

risky и never никогда не попадают в план, какие бы флаги вы ни передали. Красный объект удаляется только по одному, по полному имени, с --force-risky.

Гарантии безопасности

  1. apply по умолчанию — dry run; реальное удаление требует --no-dry-run.
  2. Том с RefCount > 0 не удаляется никогда — даже с --force-risky.
  3. Инвентарь пересобирается прямо перед удалением; всё, что изменилось за это время, пропускается с предупреждением.
  4. Никакого docker system prune -a — только точечные удаления по ID.
  5. Ничего вне Docker не трогается: ни каталоги проектов, ни образ диска ВМ. flotsam host печатает команды для сжатия Docker.raw / ext4.vhdx; запускаете их вы.
  6. Каждая операция дописывается в ~/.local/share/flotsam/history.jsonl.
  7. Каталог считается «отсутствующим», только если виден его родитель — отмонтированное поддерево не будет принято за удалённый проект.

Последнее правило — не теоретическое. На macOS и Windows -v /:/host:ro монтирует корень виртуальной машины Docker Desktop, а не ваш диск: /host/Users существует и пуст. Без правила «виден ли родитель» контейнерная сборка объявила сиротами 8 из 12 живых проектов. Теперь flotsam распознаёт ситуацию, помечает хостовую ФС как непроверяемую, отказывается называть что-либо сиротой и прямо об этом пишет — вместо того чтобы приглашать удалить живые данные.

Поставка

По одному статическому бинарю на платформу, всего шесть (linux/darwin/windows × amd64/arm64), собираются GitHub Actions на теге v*, с checksums.txt в релизе; мультиархитектурный образ 22 МБ на ghcr.io; и Vue 3 SPA, вшитая в бинарь, поэтому go build не требует Node — CI падает, если закоммиченный бандл разошёлся с исходниками. Всё, что умеет CLI, доступно в браузере на 127.0.0.1:7333: по токену, с валидацией Host/Origin и без кук.

Выводы

  • Ценные данные никогда не лежали в Docker: это связь «проект → каталог», и снять её нужно было до того, как она понадобится. Пассивный индекс не стоит ничего на запуске — и в нём вся фича.
  • Бенчмарк из ТЗ — это гипотеза. Описанная эвристика сопоставления слоёв на реальных образах работала на 55 %; честной цифру «сколько на самом деле освободится» сделала только классификация по CreatedBy.
  • Когда инструмент чего-то проверить не может — например, не видит хостовую ФС — правильный вывод это отказ, а не догадка. Инструментам удаления оптимизм не положен.