Перейти к основному содержанию

go-cache-lru: кеш сборки Go с ограничением по размеру

27 октября 2026 · 9 минут чтения

golang · cache · sqlite · opensource

go-cache-lru — реализация GOCACHEPROG, которая держит кеш сборки Go в заданном размере1. Один демон на каталог кеша обслуживает все команды go, а когда кеш перерастает лимит, удаляет записи, к которым дольше всего не обращались. Есть второй уровень хранения: каталог на сетевом диске или S3-совместимое хранилище. Сегодня вышла версия 0.1.02. Ниже — откуда взялась задача, почему первая версия индекса жила в файлах и почему вторая живёт в SQLite.

Проблема

Я работаю с git worktree: каждая задача живёт в своей копии репозитория, параллельно их бывает несколько, и в каждой постоянно идут сборки и тесты. На ноутбуке это быстро съедает диск. 14 октября кеш сборки дорос до 85 ГБ за пять дней. 20 октября на диске в 460 ГБ кеш занимал 66 ГБ, свободного места оставалось 4.7 ГБ, и make test падал на линковке:

ld: write() failed, errno=28 (No space left on device)

go clean -cache вернул 78 ГБ свободного места, но через несколько дней картина повторяется.

Команда go чистит свой кеш только по возрасту: удаляет записи, к которым не обращались пять дней. Ограничения по размеру нет. При активной работе почти всё в кеше моложе пяти дней, поэтому чистка не успевает за ростом.

Попытка с -trimpath

Первым подозреваемым был путь к исходникам. Без -trimpath абсолютный путь к пакету входит в ключ кеша, поэтому каждый worktree собирает свою копию всего. Я замерил это на рабочем репозитории: вторая копия добавляла в кеш 721 МБ, с флагом — 3 МБ. 14 октября GOFLAGS=-trimpath попал в мой конфиг окружения.

Копии между worktree флаг действительно убрал. Но через шесть дней кеш снова занимал 66 ГБ: рост остался, просто медленнее. В ключ входят не только пути. Разные флаги сборки дают разные записи: на том же репозитории холодный кеш после сборки и тестов занимал 1 383 МБ без -race и 2 263 МБ, когда -race включён только в тестах. Каждая новая ветка, каждое изменение зависимостей и каждый прогон с другими флагами добавляют новые записи, а старые живут свои пять дней. -trimpath уменьшает объём, но не ограничивает его.

GOCACHEPROG

С Go 1.24 команде go можно подставить внешнюю программу вместо встроенного кеша: переменная GOCACHEPROG указывает на бинарник, go запускает его и общается с ним по stdin/stdout простым JSON-протоколом3. Программа отвечает на get и put и возвращает путь к файлу на диске, откуда go прочтёт объект.

Готовые реализации уже есть, я посмотрел четыре: tailscale/go-cache-plugin, platacard/cacheprog, richardartoul/gobuildcache и bradfitz/go-tool-cache4. Все они про общий удалённый кеш для CI, где каталог живёт одну задачу. Ни одна не ограничивает локальный диск. В README platacard/cacheprog прямо сказано, что сборки мусора на диске нет, в README gobuildcache — что нет никакой автоматической чистки ни локального, ни удалённого кеша. Мне нужно обратное: долгоживущий локальный кеш с жёстким лимитом. Так появился go-cache-lru.

Как устроено

go build ──stdin/stdout──▶ go-cache-lru ──unix socket──┐
go test  ──stdin/stdout──▶ go-cache-lru ──unix socket──┼──▶ daemon ──▶ local dir
go vet   ──stdin/stdout──▶ go-cache-lru ──unix socket──┘      │      (LRU, pins)
                                                              └────▶ remote (optional)

Каждая команда go запускает свой экземпляр go-cache-lru, но этот процесс только пересылает байты демону через unix-сокет. Первая команда запускает демон, остальные подключаются к нему, в том числе параллельные сборки из разных worktree. Один демон на каталог гарантирует flock на файле блокировки. Блокировка умирает вместе с процессом, поэтому после падения следующий запуск не застревает.

Что делает демон:

  • Держит лимит. Выше --max-size (по умолчанию 30 ГиБ) он удаляет записи, к которым дольше всего не обращались, пока кеш не сожмётся до --target-size (25 ГиБ). Удаление идёт в фоне, пачками по 256 записей.
  • Не удаляет файлы в работе. Протокол требует, чтобы возвращённый файл существовал, пока команда go не закроет сессию. Поэтому каждый выданный объект закреплён до конца сессии, и вытеснение его пропускает.
  • Хранит объект один раз. Файл называется по хешу содержимого, и одинаковые результаты разных действий делят один файл. Перед записью демон проверяет, что тело совпадает с SHA-256 из имени.
  • Понимает go clean -cache. Эта команда GOCACHEPROG не запускает, а просто удаляет подкаталоги GOCACHE. Демон держит в одном из них файл-метку и проверяет его раз в две секунды. Пропала метка — значит, была чистка, и демон приводит индекс в порядок.
  • Завершается аккуратно. По SIGTERM или go-cache-lru stop он перестаёт принимать подключения, ждёт запущенные команды go (по умолчанию минуту), дописывает очередь загрузок и сохраняет статистику.

Удалённое хранилище — второй уровень за локальным диском. Команда go читает объекты только из локальных файлов, поэтому диск всегда основной. При промахе демон спрашивает удалённое хранилище (таймаут 10 секунд) и копирует объект на диск, а после локальной записи загружает объект в фоне. Поддерживаются file:// для каталога, например сетевой шары, и s3:// для AWS S3, Cloudflare R2, MinIO и других S3-совместимых сервисов. Новый драйвер реализует интерфейс из трёх методов (Get, Put, Close) и регистрирует свою схему URL.

Первый индекс: файлы

Объекты в любом случае остаются файлами: команда go читает их по путям, которые вернул демон. Вопрос был в том, где держать индекс — какому действию соответствует какой объект и когда к нему обращались последний раз.

Сначала я выбрал файлы: на каждую запись индекса отдельный маленький файл, а время последнего обращения — его mtime. Доводы были такие. Каждая запись появляется одним атомарным rename, и каталог всегда согласован сам с собой. База данных дала бы два источника правды: go clean -cache удаляет файлы, но не строки, а падение между rename и коммитом оставляет файл без строки или строку без файла. Пишет только демон, так что многопроцессные блокировки SQLite не нужны. И SQLite в Go — это либо cgo, либо заметно более тяжёлый бинарник.

Цена — время запуска: при старте демон читает все записи индекса. Чтобы понять масштаб, я написал тест TestOpenScale, который генерирует синтетический кеш и замеряет открытие. Размер взял по первой реальной статистике: 177.9 МиБ в 875 объектах, около 200 КиБ на объект и две записи индекса на объект. Полный кеш в 30 ГиБ — это примерно 150 000 объектов и 300 000 записей индекса, всего 450 000 файлов.

На Mac с Apple Silicon (12 ядер, APFS) открытие такого кеша с прогретым page cache заняло от 6.6 до 15.4 секунды за шесть прогонов. По профилю CPU 97 % времени уходило в системные вызовы, 84 % — в чтение записей индекса по одной: open, read, close и stat на каждый файл. Разброс шёл за нагрузкой на машину. Демон открывает кеш до того, как начинает слушать сокет, поэтому после перезагрузки или stop первая команда go ждала всё это время. Холодный page cache сразу после загрузки я не замерял: для purge нужен root, и там будет медленнее.

Второй индекс: SQLite

После этого замера индекс переехал в SQLite. Драйвер — modernc.org/sqlite на чистом Go, без cgo. В базе одна строка на действие: идентификатор объекта, размер, время записи и время последнего обращения.

Чтобы кеш не ждал базу, горячий путь не изменился. get и put работают с индексом в памяти под одним мьютексом. Изменения встают в очередь, а фоновая горутина раз в 100 мс пишет их одной транзакцией. При завершении демон дописывает остаток очереди.

Расхождение базы и файлов никуда не делось, его пришлось обрабатывать явно. go clean -cache удаляет файлы, а падение теряет до 100 мс изменений из очереди. Поэтому при каждом старте и после каждой обнаруженной go clean -cache демон сверяет одно с другим. Он перечисляет файлы объектов по именам через ReadDir, не открывая их и не вызывая stat. Строки без объекта удаляются, объекты без строки тоже. Падение стоит не больше последних 100 мс записей в кеш, но согласованность не теряется.

Замер на той же машине, тот же синтетический кеш на 300 000 записей и параллельный бенчмарк пакета кеша (12 горутин, объекты по 4 КиБ, три прогона по 3 секунды):

Индекс в файлах и в SQLite: запуск демона, Put и Get

Индекс в файлахИндекс в SQLite
Открытие, прогретый page cache6.6–15.4 с за 6 прогонов0.55–0.57 с за 9 прогонов
Put251–265 мкс110–117 мкс
Get, попадание4.5–4.9 мкс1.43–1.47 мкс
Память под индексоколо 150 МиБоколо 100 МиБ
Индекс на диске300 000 файловindex.db на 54 МБ
Размер бинарника11.6 МиБ17.4 МиБ

Запуск стал быстрее в 12–28 раз и перестал зависеть от нагрузки на машину. Горячий путь тоже ускорился: put больше не пишет и не переименовывает файл индекса под блокировкой, а get не обновляет mtime файла. Платой стали 5.8 МиБ бинарника и требование Go 1.26 для сборки из исходников — его просит драйвер SQLite. Самой команде go, которая пользуется кешем, достаточно 1.24.

Старый индекс в файлах импортируется в базу при первом запуске. На кеше в 300 000 записей импорт один раз занял 42 секунды. Если перед переходом выполнить go clean -cache, импортировать нечего.

Вся история от кодека протокола до релиза уложилась в один день: 15 коммитов 27 октября, с 16:00 до 17:40. Замер с индексом в файлах я записал в README в 17:01 вместе с объяснением, почему не SQLite. Через 13 минут индекс уже был в SQLite.

Как подключить

Готовые бинарники для macOS и Linux (amd64, arm64) лежат на странице релизов. Можно собрать из исходников:

go install github.com/juev/go-cache-lru/cmd/go-cache-lru@latest

Дальше указать бинарник в GOCACHEPROG. Флаги идут в ту же переменную:

export GOCACHEPROG="$HOME/go/bin/go-cache-lru \
  --dir=$HOME/.cache/go-lru \
  --max-size=30GiB \
  --target-size=25GiB"

То же для fish:

set -gx GOCACHEPROG "$HOME/go/bin/go-cache-lru --dir=$HOME/.cache/go-lru --max-size=30GiB --target-size=25GiB"

GOFLAGS=-trimpath стоит оставить: без него каждый worktree по-прежнему заполняет кеш своими копиями, и до лимита кеш дойдёт быстрее.

Отдельный каталог через --dir лучше, чем GOCACHE. Команда go всё равно открывает GOCACHE для корпуса фаззинга и отметки устаревания тестового кеша, и остатки её собственного кеша там не смешаются с файлами go-cache-lru. Перед переходом эти остатки стоит один раз удалить через go clean -cache.

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

--remote=file:///Volumes/share/go-cache
--remote='s3://my-bucket/go-cache?region=eu-central-1'
--remote='s3://my-bucket/go-cache?endpoint=<account-id>.r2.cloudflarestorage.com&region=auto'

Ключи S3 берутся из AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY, из файла учётных данных или из метаданных инстанса. Удалённые объекты go-cache-lru никогда не удаляет, для этого нужно правило жизненного цикла бакета.

Две оговорки. Демон запоминает лимиты, с которыми запущен, поэтому после их изменения нужен go-cache-lru stop: следующая команда go поднимет демон с новыми значениями. То же после обновления бинарника. И программы, запущенные не из шелла, например IDE из Dock, переменную не видят и пользуются встроенным кешем.

Как следить

Управляющим командам нужен тот же --dir, что и в GOCACHEPROG. Главная из них — stats. Так выглядит мой кеш через час после перехода:

$ go-cache-lru --dir=$HOME/.cache/go-lru stats
daemon          pid 12299, version v0.0.0-20261027142116-62b694c9a6b6 62b694c9a6b6, up 25m49s, 0 active sessions
directory       ~/.cache/go-lru
size            4.4 GiB of 30.0 GiB (target 25.0 GiB), 14.7%
entries         17740 actions, 6020 objects, 0 pinned
counters since  2026-10-27 16:38:51
hits            25955 (73.8%), 5.4 GiB served
misses          9199
puts            22017, 5.1 GiB written
evictions       0 entries, 0 B
cleans          0 explicit, 1 by go clean -cache
sessions        21

Счётчики переживают перезапуск демона. stats -json отдаёт то же для скриптов, stats -reset обнуляет счётчики. Остальные команды:

go-cache-lru --dir=$HOME/.cache/go-lru trim    # сжать до --target-size сейчас
go-cache-lru --dir=$HOME/.cache/go-lru clean   # удалить всё, что не используется
go-cache-lru --dir=$HOME/.cache/go-lru stop    # аккуратно остановить демон
go-cache-lru --dir=$HOME/.cache/go-lru serve   # запустить демон на переднем плане

Журнал демона пишется в <dir>/gocacheprog/daemon.log.

Что не проверено

Вытеснение в реальной работе ещё не срабатывало: в статистике выше 4.4 ГиБ из 30 и ноль вытеснений. Его покрывают тесты, в том числе сквозные с настоящей командой go, но на живом кеше лимит я ещё не набрал. Запуск с холодным page cache не замерен. Windows пока не поддерживается.


  1. juev/go-cache-lru, репозиторий с README, где описаны устройство и замеры. ↩︎

  2. go-cache-lru v0.1.0, бинарники для macOS и Linux. ↩︎

  3. cmd/go/internal/cacheprog, описание протокола GOCACHEPROG. ↩︎

  4. tailscale/go-cache-plugin, platacard/cacheprog, richardartoul/gobuildcache, bradfitz/go-tool-cache; README смотрел в октябре 2026. ↩︎