MAATRIX / Блог / Предел размера репозитория Git: когда клон перестаёт помещаться в память

Предел размера репозитория Git: когда клон перестаёт помещаться в память

MAATRIX

Git спроектирован так, чтобы каждый клон содержал всю историю проекта — это его сила и его же слабость. Пока репозиторий — это код, история в несколько тысяч коммитов весит мегабайты и никого не беспокоит. Но стоит начать коммитить бинарники (картинки, видео, модели, архивы сборок) или просто копить историю годами, и .git незаметно разрастается до гигабайт. Дальше начинается: клонирование съедает память раннера, git gc считает подолгу, а каждый новый коммит с бинарником делает следующий клон ещё немного тяжелее — и назад пути нет, потому что история immutable по умолчанию. Разберём, где именно упирается Git и что с этим делать до того, как клонирование репозитория станет отдельной строкой в бюджете CI.

Почему растёт .git и куда девается память при клонировании

Git хранит не diff-ы между версиями файлов в привычном смысле, а объекты трёх типов: blob (содержимое файла на конкретный момент), tree (структура каталога) и commit. Каждое изменение файла — если это не разрешено delta-компрессией при упаковке — потенциально новый blob, весящий столько же, сколько сам файл. Текстовые файлы сжимаются и упаковываются в pack-файлы эффективно: Git умеет находить похожие версии и хранить только разницу. А вот бинарники — картинки, PDF, ZIP, веса моделей, скомпилированные бинарники — сжимаются плохо и почти никогда не дельта-кодируются друг относительно друга, потому что даже маленькое изменение бинарного файла обычно меняет его целиком на уровне байтов. В итоге каждая новая версия картинки в 5 МБ, закоммиченная десять раз за время жизни проекта, — это условно 50 МБ истории, которые останутся в репозитории навсегда, даже если сейчас в рабочей копии лежит только последняя версия.

При клонировании клиент Git должен получить все объекты истории (git clone без флагов тянет полный .git), распаковать pack-файлы и по умолчанию checkout-нуть рабочую копию. Память расходуется в нескольких местах: передача и распаковка pack-файлов, индексация объектов (git index-pack), построение дерева для checkout. На репозитории в несколько гигабайт с длинной историей это может потребовать заметно больше памяти, чем кажется интуитивно — индексация держит в памяти структуры для всех получаемых объектов сразу, а не пишет их на диск потоково. На CI-раннере с ограниченным лимитом памяти это нередко заканчивается OOM-killer, который убивает джобу без внятной ошибки — просто «Killed» в логе. Похожая механика скрытых лимитов в контейнере разобрана в статье про ограничение числа процессов и pids_max.

Где проблема проявляется на практике

Симптомы почти всегда всплывают не в тот момент, когда репозиторий стал большим, а спустя месяцы, когда с этим сталкивается кто-то новый:

  • Новый сотрудник или CI-раннер делают первый клон. На домашнем интернете клон многогигабайтной истории может идти долго и требовать заметную свободную память на распаковку — на слабом ноутбуке или в контейнере с лимитом это либо долго, либо падает.
  • CI-джоба клонирует репозиторий заново на каждом запуске. Если пайплайн не кэширует .git между запусками (или это одноразовый эфемерный раннер), полный клон повторяется десятки раз в день — время и трафик умножаются на количество запусков.
  • git gc и git repack занимают ощутимое время и I/O. На большом репозитории сборка мусора (git gc --auto срабатывает сама при накоплении лишних объектов) создаёт заметную нагрузку на диск — на медленном или перегруженном диске это дополнительно вытесняет другие процессы.
  • git status и git diff подтормаживают. Это отдельная, менее очевидная проблема: чаще она связана не с объёмом истории, а с числом файлов в рабочем дереве — тот же эффект, что в разборе про миллион мелких файлов на диске: Git обходит файлы рабочей копии и сверяет их с индексом, и это упирается в число операций с inode, а не в объём данных.
  • Диск на сервере с зеркалом или self-hosted Git постепенно заполняется. Если вы держите собственное зеркало (например, для фрилансерских проектов — практика из статьи про свой Git на сервере при потере доступа к чужому репозиторию), рост .git на сервере со временем становится вопросом планирования диска, а не разовой проблемой.

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

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

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

Shallow clone: доступ к коду без полной истории

Самый быстрый способ снять боль в CI и для разовых операций — не тянуть историю целиком:

# только последний коммит
git clone --depth 1 https://example.com/repo.git

# последние 50 коммитов
git clone --depth 50 https://example.com/repo.git

# сузить историю уже существующего клона
git fetch --depth 1

Shallow clone получает только указанное число последних коммитов и связанные с ними объекты — вместо всей истории. Для CI, где нужен только текущий код для сборки или деплоя, это обычно правильный выбор: клонирование быстрее, память под индексацию требуется меньше.

Ограничения важны и их стоит проговорить честно:

  • Команды, которым нужна история (git log дальше глубины клона, git blame на старых строках, git bisect по всей истории) работать не будут или дадут усечённый результат.
  • Некоторые операции с ветками и тегами в shallow-репозитории ведут себя иначе — например, не все серверы одинаково хорошо поддерживают push из shallow-клона без дополнительных флагов.
  • Если CI-джобе для анализа нужен предыдущий коммит (сравнение с предыдущим состоянием, changed-files-детект), --depth 1 может не дать нужный контекст — тогда берут --depth 2 или чуть больше, либо --shallow-since=<дата>.

Для локальной разработки shallow clone обычно не подходит — разработчику нужна история для git blame, git log, отката. Это инструмент прежде всего для эфемерных сред: CI-раннеров, деплой-скриптов, разовых сборок в Docker-образе.

Git LFS: бинарники не должны жить в основной истории

Если проблема — именно бинарные файлы (дизайн-макеты, датасеты, веса моделей, архивы сборок), правильное направление — не тащить их в обычные blob-объекты Git вообще. Git LFS (Large File Storage) заменяет содержимое файла в истории на текстовый указатель, а сам файл хранит отдельно, в объектном хранилище:

# установка расширения (один раз на машину)
git lfs install

# указать, какие файлы отслеживать через LFS
git lfs track "*.psd"
git lfs track "*.mp4"
git lfs track "assets/models/**"

# .gitattributes создаётся/дополняется автоматически — закоммитьте его
git add .gitattributes
git commit -m "Track binary assets via Git LFS"

После этого в самом репозитории .git вместо содержимого файла лежит указатель на несколько десятков байт вида:

version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab...
size 12345678

Реальные версии файлов лежат в отдельном LFS-хранилище (на GitHub/GitLab это встроенный сервис, на self-hosted Git — можно поднять свой, например через Gitea или GitLab CE). Клонирование основной истории становится лёгким почти всегда, а тяжёлые файлы подкачиваются по требованию — либо все сразу при git lfs pull, либо выборочно.

Нюансы, которые стоит знать до внедрения, а не после:

  • LFS нужно включать до того, как бинарники попали в обычную историю. Перевод уже закоммиченных файлов в LFS постфактум — это переписывание истории (git lfs migrate import), с тем же набором проблем, что и любой rewrite: старые хэши коммитов меняются, все клоны нужно пересоздавать.
  • LFS-хранилище на self-hosted Git — отдельный объём на диске сервера, который тоже нужно закладывать заранее. Здесь применима та же логика запаса, что и для дисков вообще — см. сколько дискового пространства закладывать с запасом.
  • Некоторые провайдеры выставляют отдельные лимиты и квоты именно на LFS-трафик и хранение — это стоит проверить заранее.
  • LFS не панацея для файлов, которые меняются построчно (текстовые форматы) — там обычная дельта-компрессия Git и так работает хорошо, LFS ей не нужен и даже мешает diff-инструментам.

git gc и репак: обслуживание, а не разовая операция

Git по умолчанию хранит новые объекты как отдельные несжатые файлы (loose objects) и периодически упаковывает их в компактные pack-файлы с дельта-компрессией. git gc --auto запускается автоматически при накоплении большого числа loose-объектов, но на активном репозитории с частыми коммитами и на self-hosted сервере с несколькими репозиториями стоит понимать, что там происходит, и иногда запускать вручную:

# посмотреть текущий размер объектной базы
git count-objects -vH

# обычная сборка мусора — безопасна, ничего не удаляет из достижимой истории
git gc

# более агрессивная упаковка — пересобирает pack-файлы заново
git gc --aggressive

# ручной репак с настройкой глубины дельта-цепочек
git repack -a -d --depth=250 --window=250

git count-objects -vH — первая команда, которую стоит запустить, если непонятно, откуда растёт .git: она покажет число loose-объектов и суммарный размер pack-файлов в человекочитаемом виде.

Практические нюансы:

  • git gc --aggressive заметно дороже по CPU и времени, чем обычный git gc — на большом репозитории с длинной историей это стоит планировать как фоновую задачу, а не запускать в момент пиковой нагрузки.
  • Если сервер выполняет git gc для нескольких репозиториев одновременно (например, self-hosted Gitea/GitLab с cron-заданием на обслуживание), стоит развести их по времени — параллельный репак нескольких больших репозиториев разом даёт заметную нагрузку на диск и CPU.
  • git gc не удаляет коммиты из истории — он только упаковывает и оптимизирует хранение достижимых объектов. Недостижимые объекты (после reset --hard, удалённых веток) удаляются лишь спустя gc.pruneExpire (по умолчанию около двух недель) — это защита от потери данных, а не баг.
  • На self-hosted Git-сервере обслуживание репозиториев напрямую конкурирует с обычной нагрузкой на диск — сервер стоит подбирать с запасом по I/O, а не только по объёму хранилища.

Когда пора чистить историю или переписывать её

Иногда обслуживания (gc, LFS) недостаточно — потому что тяжёлые объекты уже в истории и продолжают клонироваться каждый раз. Тогда встаёт вопрос переписывания истории. Это крайняя мера, и к ней стоит относиться серьёзно:

# современный инструмент вместо устаревшего git filter-branch
# (git-filter-repo нужно установить отдельно — это внешняя утилита)
git filter-repo --path path/to/heavy-folder --invert-paths

# удалить конкретный файл из всей истории
git filter-repo --path old-video.mp4 --invert-paths

git filter-repo (рекомендуемая замена устаревшему и опасному git filter-branch) переписывает историю, физически удаляя объекты из всех коммитов, где они упоминались. После этого:

  • Все хэши коммитов меняются — история несовместима со старыми клонами, всем нужен свежий клон, а не pull.
  • Открытые pull request-ы и ссылки на конкретные коммиты (в тикетах, в документации) ломаются.
  • Старые объекты физически удаляются только после git gc с истёкшим pruneExpire — само переписывание место не освобождает мгновенно.
  • Если репозиторий уже где-то запушен, потребуется форс-push и синхронизация со всеми, кто с ним работает — это операция с широким радиусом последствий, а не локальная чистка.

Прежде чем переписывать историю, стоит трезво спросить: правда ли экономия того стоит. Если тяжёлые объекты — просто исторический балласт, который никого не беспокоит на практике, дешевле оставить как есть и не давать истории расти дальше (LFS для новых файлов, ограничение размера коммита pre-receive хуком на сервере).

Когда пора разделять монорепозиторий

Отдельный сценарий — не бинарники, а сам масштаб кода: монорепозиторий, в котором годами копится код десятков сервисов, команд и модулей. Проблема здесь не столько в весе .git, сколько в том, что организационная нагрузка Git (индекс, checkout, git status) считается по всему дереву сразу, даже если разработчику или CI-джобе нужна только часть.

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

  • Полный клон занимает заметное время даже с быстрым интернетом, и это стало типичной жалобой новых разработчиков, а не разовым неудобством.
  • CI собирает и тестирует весь репозиторий на каждый PR, потому что дешевле пересобрать всё, чем разбираться, что изменилось (частично это лечится sparse-checkout и path-based триггерами, а не разделением репозитория).
  • Разные команды регулярно блокируют друг друга: конфликт слияния в одном модуле тормозит релиз не связанного с ним сервиса.
  • Права доступа нужны на уровне подкаталогов, а Git такого разграничения нативно не даёт — приходится городить обходные пути на уровне CI или внешних инструментов.

Промежуточный шаг перед полным разделением — git sparse-checkout: клонирует полную историю, но материализует в рабочей копии только нужные каталоги:

git clone --filter=blob:none --sparse https://example.com/monorepo.git
cd monorepo
git sparse-checkout set service-a shared-libs

Флаг --filter=blob:none (partial clone) вместе со sparse-checkout особенно эффективен: тянет метаданные истории для всех путей, но содержимое файлов — только для включённых в checkout каталогов, и только по мере обращения. Это промежуточный вариант между shallow clone и полным разделением репозитория — сохраняет единую историю, но резко снижает объём, реально попадающий на диск разработчика.

Если и sparse-checkout не снимает проблему (команды продолжают мешать друг другу, права разграничить всё равно нельзя), тогда встаёт вопрос физического разделения на отдельные репозитории по границам сервисов или команд — с собственным CI-пайплайном и собственным жизненным циклом релизов у каждого. Это дороже с точки зрения инфраструктуры (свой CI, свои переменные окружения, свои деплой-скрипты на каждый репозиторий), но снимает главную боль монорепозитория — общий блокирующий рабочий процесс.

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

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

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

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

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

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

Уменьшает ли git gc размер репозитория, если история и так чистая?

Немного — за счёт более плотной дельта-компрессии при пересборке pack-файлов. Но тяжёлые уникальные бинарники gc не сожмёт сильнее, чем позволяет их собственная сжимаемость — это не замена LFS или очистки истории.

Можно ли включить Git LFS на существующем репозитории без переписывания истории?

Для новых файлов — да, добавьте паттерны в git lfs track и коммитьте дальше как обычно. Но уже закоммиченные бинарники останутся в истории как обычные blob-объекты, пока вы явно не мигрируете их через git lfs migrate import, а это уже переписывание истории со всеми последствиями.

Что случится, если клонировать с --depth 1 и потом сделать полноценный git log?

Вы увидите только полученный коммит — Git честно скажет, что остальная история недоступна. Её можно доподгрузить командой git fetch --unshallow, но это по сути превращает shallow-клон обратно в полный, с соответствующими затратами трафика и памяти.

На сколько вырастает .git, если раз в неделю коммитить видео на 200 МБ без LFS?

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

Нужен ли отдельный сервер под self-hosted Git, если репозитории большие?

Не всегда отдельный, но с запасом по диску и I/O — да. Обслуживание (git gc, репак, LFS-хранилище) создаёт заметную нагрузку на диск, и если рядом крутится продакшн-база или другие чувствительные к задержкам сервисы, конкуренция за I/O становится проблемой раньше, чем нехватка места.

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

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

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