Как устроен build-кеш Go и как ускорить сборку в CI
Эфемерный CI-агент каждый раз стартует с пустым диском. Это удобно: окружение предсказуемое, мусор от прошлых сборок не
накапливается, агенты легко масштабировать. Но есть неприятный побочный эффект: вместе с мусором пропадает и $GOCACHE.
На ноутбуке повторный go build ./... часто почти бесплатный, потому что Go переиспользует уже скомпилированные пакеты.
На холодном CI-агенте локальный кеш пустой, поэтому Go снова компилирует весь проект и зависимости. Для больших Go-проектов
это быстро превращается в минуты ожидания на каждом пайплайне.
В этой статье разберем, как устроен build-кеш Go, как работает GOCACHEPROG, и как я добавил в
gobuildcache поддержку любого S3-совместимого хранилища, а не только
AWS S3. В конце будут замеры на сборке Terraform-провайдера в SourceCraft с кешем в Yandex Object Storage.
Как устроен build-кеш Go
Кеш Go - content-addressed, но с важной двухуровневой косвенностью. Есть два вида ключей:
- action ID - хеш входов билд-действия: компиляции пакета, линковки, запуска теста;
- output ID - хеш байтов результата этого действия.
Кеш хранит отображение action ID -> output ID + метаданные и отдельно output ID -> сами байты. Благодаря этому разные
действия, которые дали одинаковый результат, могут разделить один blob.
Для компиляции пакета в action ID попадают версия и идентичность тулчейна, GOOS, GOARCH, релевантные
GOEXPERIMENT, флаги сборки, build-теги, content-хеши исходников пакета и, рекурсивно, action ID импортируемых
пакетов.
Рекурсия здесь важна. Если меняется пакет глубоко в дереве зависимостей, это изменение всплывает наверх и инвалидирует все, что от него зависит. Это же свойство делает кеш безопасным для шаринга между машинами: ключ учитывает входы действия, а не просто имя пакета.
На диске стандартный кеш лежит в $GOCACHE, обычно это ~/.cache/go-build. Внутри два типа файлов:
<hex>-a- action-запись сaction ID,output ID, размером и временем;<hex>-d- данные результата.
Тестовый кеш go test использует ту же инфраструктуру.
Для обычной рабочей машины этого достаточно. Для эфемерного CI-агента - нет: каталог $GOCACHE появляется пустым и
умирает вместе с агентом. Значит, кеш нужно вынести наружу, в хранилище, которое переживает конкретную машину.
Go build в контейнере
Примером холодной сборки может быть go build в контейнере. Если контейнер каждый раз стартует без сохраненного
$GOCACHE, Go компилирует все заново. Повторный запуск в таком окружении занимает примерно столько же времени, сколько
первый: локальная оптимизация, к которой мы привыкли на ноутбуке, просто не успевает накопиться.
В Docker/BuildKit это частично решается через cache mount. Кеш не попадает в слой образа, но сохраняется на стороне
builder'а и переиспользуется между следующими docker build на той же машине:
# syntax=docker/dockerfile:1.4
FROM golang:1.26 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN \
go mod download
COPY . .
RUN
\
go build ./...
Здесь первая строка задает Dockerfile frontend для BuildKit. Она не включает BuildKit сама по себе, а фиксирует версию
синтаксиса, в которой доступен RUN --mount. target=/go/pkg/mod сохраняет module cache: скачанные зависимости не нужно
получать заново на каждой сборке. target=/root/.cache/go-build сохраняет сам $GOCACHE, то есть результаты компиляции
пакетов. После первого прогона следующий build на том же builder'е сможет взять зависимости и скомпилированные пакеты из
cache mount, а не начинать с нуля.
У этого подхода есть важная граница: cache mount привязан к конкретному Docker builder'у. Если CI поднимает новый
эфемерный агент с пустым диском, вместе с ним снова исчезает и этот cache mount. Чтобы кеш переживал не только контейнер,
но и саму машину, нужен backend вне агента. В Go для этого есть специальный механизм - GOCACHEPROG.
GOCACHEPROG
GOCACHEPROG - это переменная окружения, в которой указывается программа-helper. go запускает ее как дочерний процесс
и использует как backend кеша.
В старых версиях это было экспериментом за GOEXPERIMENT=cacheprog, но начиная с Go 1.24 отдельный флаг эксперимента
уже не нужен: достаточно выставить GOCACHEPROG.
Общение между go и helper'ом идет построчным JSON по stdin/stdout. Один процесс helper'а обслуживает весь вызов
go build или go test.
Важно: stdout helper'а - это часть протокола. Логи туда писать нельзя, иначе go попробует распарсить их как JSON и
сломает сборку. Для логов остается stderr, он прокинут в stderr родительского процесса.
У протокола три команды: get, put, close. Все сообщения привязаны к ID: go отправляет запрос с уникальным
положительным ID, helper возвращает ответ с тем же ID. Нулевой ID зарезервирован для стартового handshake.
type Request struct {
ID int64
Command Cmd
ActionID []byte
OutputID []byte
Body io.Reader
BodySize int64
}
type Response struct {
ID int64
Err string
KnownCommands []Cmd
Miss bool
OutputID []byte
Size int64
Time *time.Time
DiskPath string
}
Если смотреть на протокол как на API, получается такой контракт:
| Команда | Кто отправляет | Что означает | Что должен вернуть helper |
|---|---|---|---|
| handshake | helper | Стартовое сообщение с ID == 0 | KnownCommands, например ["get","put","close"] |
get | go | Найти результат по ActionID | При промахе Miss: true; при попадании OutputID, Size, Time, DiskPath |
put | go | Сохранить результат для ActionID | DiskPath к локальному файлу с телом результата |
close | go | Сборка закончилась, можно закрываться | Ответ с тем же ID, после этого helper может чистить временные файлы |
ActionID и OutputID в Go-коде - []byte, а в JSON они сериализуются как base64-строки. Поэтому руками читать
протокольный dump не очень удобно, но формат при этом остается обычным newline-delimited JSON.
Жизненный цикл запроса
На get helper проверяет, есть ли запись для ActionID. Если нет - отвечает Miss: true, и go выполняет действие
сам: компилирует пакет, линкует бинарь или запускает тест. После этого go отправляет put, чтобы helper сохранил
результат.
Если запись найдена, helper должен вернуть не только метаданные, но и локальный путь к файлу. Для remote cache это
означает дополнительный шаг: скачать blob из Object Storage, положить его на диск агента и только потом ответить go.
Здесь есть один неочевидный момент: даже на put helper отвечает DiskPath. go уже передал тело результата helper'у,
но тулчейн все равно ожидает, что у него будет путь к локальному файлу. Поэтому нормальная реализация на put сначала
пишет тело на диск, а потом уже синхронно или асинхронно отправляет его в remote backend.
Детали, о которые легко споткнуться
Хендшейк. При старте helper сразу пишет в stdout ответ с ID == 0 и заполненным KnownCommands. Так go проверяет,
что перед ним действительно cache helper, и понимает, какие команды ему можно слать. Если helper не объявил put, кеш
будет read-only.
DiskPath обязателен. Даже если кеш лежит в Object Storage, ответ на успешный get должен содержать абсолютный путь
к локальному файлу с байтами результата. Тулчейн читает результат с файловой системы, поэтому remote cache обязан сначала
материализовать blob на локальный диск, а потом вернуть путь. На put тоже нужно вернуть локальный DiskPath.
Тело put идет отдельной строкой. Если BodySize > 0, после JSON-запроса идет отдельный JSON string с base64-телом.
Так большие значения можно обрабатывать отдельно от метаданных запроса.
Запросы пайплайнятся. В полете одновременно может быть много get и put. Ответы приходят в любом порядке и
сопоставляются по ID, поэтому helper должен быть конкурентным.
Из-за этого нельзя писать helper как простой цикл "прочитал запрос, полностью обработал, прочитал следующий". Он будет работать, но легко станет bottleneck'ом: один медленный download из Object Storage заблокирует остальные запросы. Практичная реализация читает stdin последовательно, а обработку команд раскладывает по goroutine или worker pool.
Ошибки идут в Err. Если backend временно недоступен или helper не смог записать локальный файл, это не stderr-лог,
а протокольный ответ с заполненным Err. Иначе go не сможет корректно связать ошибку с конкретным ID.
Файлы должны дожить до close. DiskPath, который вернул helper, должен оставаться валидным до конца процесса
helper'а. Нельзя скачать объект во временный файл, ответить go, сразу удалить файл и надеяться, что тулчейн уже успел
его прочитать.
Пример обмена для промаха и последующей записи:
gobuildcache и S3
Готовый helper gobuildcache уже умеет работать с GOCACHEPROG.
У него есть несколько backend'ов, один из них - S3.
Проблема была в том, что S3-backend фактически умел ходить только в AWS S3. Endpoint выводился из региона через AWS SDK, а явно задать адрес было нельзя. Если CI живет не в AWS, а кеш хочется держать рядом с раннерами, это блокер. Нельзя просто подключить Yandex Object Storage, MinIO, Ceph/RADOS Gateway, Cloudflare R2 или другое S3-совместимое хранилище.
Я сделал PR в gobuildcache, который не добавляет новый backend,
а разблокирует существующий S3-backend для S3-compatible storage.
Что изменилось:
- в
S3Configпоявилось опциональное полеEndpoint; - это поле проброшено в
o.BaseEndpointAWS SDK v2; - endpoint читается из
GOBUILDCACHE_S3_ENDPOINTилиS3_ENDPOINT; - если endpoint не задан, поведение для AWS остается прежним;
- path-style addressing остается отдельной явной настройкой через
AWS_S3_USE_PATH_STYLE.
Последний пункт важен. Многие S3-совместимые хранилища требуют path-style, но сам факт кастомного endpoint'а не должен молча менять схему адресации. Поэтому это отдельный тумблер.
Для Yandex Object Storage настройка выглядит так:
export GOCACHEPROG=gobuildcache
export GOBUILDCACHE_BACKEND_TYPE=s3
export GOBUILDCACHE_S3_BUCKET=$BUCKET_NAME
export GOBUILDCACHE_S3_ENDPOINT=https://storage.yandexcloud.net
export GOBUILDCACHE_AWS_S3_USE_PATH_STYLE=true
export GOBUILDCACHE_AWS_REGION=ru-central1
export GOBUILDCACHE_AWS_ACCESS_KEY_ID=$ACCESS_KEY
export GOBUILDCACHE_AWS_SECRET_ACCESS_KEY=$SECRET_KEY
go build ./...
Сжатие: LZ4 и zstd
gobuildcache сжимает blob'ы перед записью в backend и разжимает их на чтении. По умолчанию используется LZ4. Кодек можно
выбрать через --compression или env COMPRESSION: none, lz4, zstd.
В отдельной ветке я добавил zstd и сравнил его с LZ4 на том же кеше. Идея простая: zstd обычно жмет лучше, но требует больше CPU. Если bottleneck - сеть до Object Storage, zstd может выиграть. Если bottleneck - CPU агента, он может проиграть.
А еще кодек на чтении определяется по magic bytes фрейма, а не по текущему конфигу helper'а. Поэтому
в одном бакете могут лежать blob'ы, сжатые разными кодеками. Переключение lz4 на zstd не инвалидирует старый кеш.
Замеры в SourceCraft
Я проверял это на сборке Terraform-провайдера для Yandex Cloud в SourceCraft. Это не микробенчмарк, а нормальный большой Go-проект с глубоким деревом зависимостей.
Длительность ниже - это длительность build* cube steps от старта до финиша. То есть туда попадает не только чистое
время компилятора, но и реальная цена шага в CI.
| Вариант | Без vendor | Vendor |
|---|---|---|
build | 5m16s | 4m52s |
build-cached (LZ4) | 2m57s | 2m30s |
build-cached-zstd | 2m52s | 2m23s |
Для того чтобы исключить влияние скачивания зависимостей, я в новом коммите завендорил все внешние модули и
прогонял сборку с -mod=vendor. Vendoring сам по себе помог: он снял примерно 25-30s со всех вариантов.
На cached-сборках это заметнее, потому что после выноса build-кеша наружу dependency download и разрешение
модулей становятся большей долей оставшегося времени.
На vendored-прогонах remote cache сокращает шаг примерно с 4m52s до 2m30s. Это около 1.9x ускорения. zstd в
этом конкретном прогоне оказался еще немного быстрее: 2m23s.
Что показала статистика helper'а
Для LZ4 и zstd я смотрел статистику gobuildcache. В обоих случаях локальный $GOCACHE на агенте был холодным, поэтому
локальных попаданий не было, а все hits пришли из backend'а.
| Метрика | LZ4 | zstd |
|---|---|---|
| GET operations | 4121 | 4121 |
| Backend hits | 2971 | 2971 |
| Hit rate | 72.1% | 72.1% |
| Backend bytes read | 1.14 GB | 770.46 MB |
| Decompressed GET size | 2.69 GB | 2.69 GB |
| Total backend bytes transferred | 1.14 GB | 771.17 MB |
Hit rate одинаковый, потому что это один и тот же набор action ID. Разница только в том, сколько байт пришлось скачать из
Object Storage для тех же самых распакованных 2.69 GB.
zstd скачал 770.46 MB вместо 1.14 GB, то есть примерно на треть меньше. За это он заплатил CPU: p50 распаковки вырос
с 0.47ms до 3.78ms, p95 - с 23.81ms до 44.26ms. На этом стенде меньший сетевой трафик перевесил, поэтому
wall-clock получился чуть лучше.
PUT-статистика в этом прогоне почти не показательна: 1149 из 1150 PUT оказались дубликатами, и реально в backend ушло
около мегабайта. Поэтому для честного сравнения сжатия на запись нужно отдельно прогонять холодный remote cache. Но для
основного CI-сценария важнее чтение: эфемерный агент приходит с пустым локальным кешем и должен быстро скачать уже
прогретые артефакты.
Детали статистики
Вот так выглядит статистика LZ4. Холодный кеш:
Cache statistics:
GET operations: 2748 (hits: 225, misses: 2523, hit rate: 8.2%)
Local cache hits: 0 (0.0% of GETs)
Backend cache hits: 225 (8.2% of GETs)
Duplicate GETs: 0 (0.0% of GETs)
Deduplicated GETs (singleflight): 0 (0.0% of GETs)
Backend bytes read: 302.02 KB
PUT operations: 5269
Duplicate PUTs: 2522 (47.9% of PUTs)
Deduplicated PUTs (singleflight): 0 (0.0% of PUTs)
Backend bytes written: 1.14 GB
Total operations: 8017
Unique action IDs: 5495
Total backend bytes transferred: 1.14 GB
Compression statistics (codec: lz4):
Compression (PUT): 2.69 GB -> 1.14 GB (42.5%, saved 1.55 GB)
Decompression (GET): 302.02 KB -> 725.58 KB (240.2% expansion)
Latency quantiles (ms):
put_local_cache_write (n=5269): min=0.09ms p50=0.18ms p90=1.09ms p95=3.10ms p99=19.89ms max=1408.37ms
put_compression (n=3895): min=0.00ms p50=0.04ms p90=9.30ms p95=31.50ms p99=108.86ms max=273.18ms
get_decompression (n=225): min=0.01ms p50=0.01ms p90=0.02ms p95=0.05ms p99=0.43ms max=4.71ms
get_backend (n=2748): min=9.68ms p50=11.13ms p90=20.70ms p95=44.26ms p99=80.65ms max=1901.13ms
put_local_cache_check (n=5269): min=0.01ms p50=0.02ms p90=0.04ms p95=0.05ms p99=0.12ms max=18.36ms
put_backend (n=5269): min=0.00ms p50=0.03ms p90=6.89ms p95=21.54ms p99=115.60ms max=333.67ms
put_overall (n=5269): min=0.16ms p50=0.38ms p90=9.30ms p95=31.50ms p99=186.82ms max=1495.47ms
get_local_cache_write (n=225): min=0.12ms p50=0.16ms p90=0.20ms p95=0.22ms p99=0.25ms max=0.28ms
get_local_cache_check (n=2748): min=0.01ms p50=0.02ms p90=0.04ms p95=0.05ms p99=0.10ms max=284.33ms
get_overall (n=2748): min=9.68ms p50=11.36ms p90=21.54ms p95=45.15ms p99=85.64ms max=1901.13ms
Как читать этот отчёт.
Блок GET operations - чтения из кеша. hits нашлись, misses нет, и их пришлось компилировать самому go, hit rate
- доля попаданий. В холодном прогоне он низкий (
8.2%): backend ещё почти пустой, поэтому почти всё промахивается и собирается заново.Local cache hits: 0- локальный$GOCACHEна агенте тоже пустой, так что те немногие попадания, что есть, прилетели из backend (Backend cache hits: 225).Backend bytes readпоэтому крошечный (302.02 KB) - читать почти нечего.
Блок PUT operations - записи, и в холодном прогоне основная работа именно здесь: 5269 записей и 1.14 GB, реально
ушедших в backend. Duplicate PUTs (47.9%) - попытки записать контент, который уже лежит в хранилище; повторно его
helper не заливает.
Compression statistics - эффективность кодека, и в холодном прогоне она видна именно на записи: Compression (PUT)
ужал 2.69 GB до 1.14 GB (42.5% от исходного, экономия 1.55 GB). Decompression (GET) тут почти ни о чём -
распаковывать было нечего.
Latency quantiles - задержки по фазам в миллисекундах, формат min / p50 / p90 / p95 / p99 / max. p99=108.86ms
читается как «99% операций уложились в 108 мс, а 1% был медленнее». В холодном прогоне дороже всего запись:
put_compression и put_backend дают заметный хвост (p99 108ms и 115ms), потому что helper жмёт результаты и
заливает blob'ы в Object Storage. Чтение (get_backend) пока дешёвое - брать из backend почти нечего.
А вот тот же LZ4 на тёплом кеше: backend уже прогрет предыдущей сборкой, и роли меняются местами.
statistics:
GET operations: 4121 (hits: 2971, misses: 1150, hit rate: 72.1%)
Local cache hits: 0 (0.0% of GETs)
Backend cache hits: 2971 (72.1% of GETs)
Duplicate GETs: 0 (0.0% of GETs)
Deduplicated GETs (singleflight): 0 (0.0% of GETs)
Backend bytes read: 1.14 GB
PUT operations: 1150
Duplicate PUTs: 1149 (99.9% of PUTs)
Deduplicated PUTs (singleflight): 0 (0.0% of PUTs)
Backend bytes written: 995.58 KB
Total operations: 5271
Unique action IDs: 4122
Total backend bytes transferred: 1.14 GB
Compression statistics (codec: lz4):
Compression (PUT): 2.58 MB -> 995.58 KB (37.7%, saved 1.61 MB)
Decompression (GET): 1.14 GB -> 2.69 GB (235.5% expansion)
Latency quantiles (ms):
get_local_cache_check (n=4121): min=0.01ms p50=0.02ms p90=0.04ms p95=0.04ms p99=0.07ms max=1380.49ms
get_overall (n=4121): min=4.10ms p50=7.17ms p90=74.45ms p95=183.12ms p99=1620.02ms max=5598.41ms
put_local_cache_check (n=1150): min=0.01ms p50=0.01ms p90=0.02ms p95=0.02ms p99=0.04ms max=0.10ms
put_compression (n=1149): min=0.01ms p50=0.02ms p90=0.05ms p95=0.06ms p99=0.10ms max=4.18ms
put_overall (n=1150): min=0.18ms p50=0.27ms p90=0.35ms p95=0.38ms p99=0.95ms max=4.35ms
get_backend (n=4121): min=4.01ms p50=6.89ms p90=64.72ms p95=89.13ms p99=210.64ms max=1176.37ms
put_local_cache_write (n=1150): min=0.11ms p50=0.18ms p90=0.23ms p95=0.24ms p99=0.32ms max=3.29ms
put_backend (n=1150): min=0.01ms p50=0.03ms p90=0.06ms p95=0.08ms p99=0.12ms max=4.18ms
get_decompression (n=1598): min=0.01ms p50=0.47ms p90=15.33ms p95=23.81ms p99=48.91ms max=194.44ms
get_local_cache_write (n=2971): min=0.13ms p50=0.25ms p90=2.80ms p95=14.73ms p99=1620.02ms max=5272.37ms
Что изменилось после прогрева.
Набор action ID почти тот же, но роли поменялись местами. hit rate подскочил с 8.2% до 72.1%: теперь большинство
действий находит готовый результат в backend, а оставшиеся промахи компилируются заново.
Главное смещение - с записи на чтение. В холодном прогоне helper залил 1.14 GB в backend и почти ничего не читал; в
тёплом - наоборот, скачал 1.14 GB и записал меньше мегабайта. PUT operations упали с 5269 до 1150, а
Duplicate PUTs выросли до 99.9%: писать почти нечего, всё уже лежит в хранилище.
Сместился и хвост латентности. В холодном прогоне он жил на стороне записи (put_compression, put_backend); в тёплом
самым дорогим стал get_local_cache_write (p99=1620ms, max=5272ms) - материализация скачанного blob'а на диск
агента. Именно она, а не сеть или распаковка, тянет get_overall на хвосте (p99 1620ms при медиане 7.17ms). Сам
backend (get_backend) и распаковка (get_decompression) на этом фоне быстрые и стабильные.
Теперь сравним с zstd. Это тоже тёплый кеш, поэтому все попадания пришли из backend.
Cache statistics:
GET operations: 4121 (hits: 2971, misses: 1150, hit rate: 72.1%)
Local cache hits: 0 (0.0% of GETs)
Backend cache hits: 2971 (72.1% of GETs)
Duplicate GETs: 0 (0.0% of GETs)
Deduplicated GETs (singleflight): 0 (0.0% of GETs)
Backend bytes read: 770.46 MB
PUT operations: 1150
Duplicate PUTs: 1149 (99.9% of PUTs)
Deduplicated PUTs (singleflight): 0 (0.0% of PUTs)
Backend bytes written: 726.53 KB
Total operations: 5271
Unique action IDs: 4122
Total backend bytes transferred: 771.17 MB
Compression statistics (codec: zstd):
Compression (PUT): 2.58 MB -> 726.53 KB (27.5%, saved 1.87 MB)
Decompression (GET): 770.46 MB -> 2.69 GB (357.6% expansion)
Latency quantiles (ms):
put_backend (n=1150): min=0.01ms p50=1.84ms p90=3.22ms p95=3.78ms p99=5.42ms max=10.28ms
get_backend (n=4121): min=3.86ms p50=6.36ms p90=52.99ms p95=75.95ms p99=135.65ms max=804.46ms
put_local_cache_write (n=1150): min=0.13ms p50=0.21ms p90=0.39ms p95=0.52ms p99=1.09ms max=1.84ms
put_compression (n=1149): min=0.26ms p50=1.84ms p90=3.22ms p95=3.78ms p99=5.42ms max=10.28ms
put_overall (n=1150): min=0.46ms p50=2.25ms p90=3.71ms p95=4.26ms p99=5.87ms max=3072.41ms
get_decompression (n=1598): min=0.03ms p50=3.78ms p90=27.94ms p95=44.26ms p99=79.05ms max=320.58ms
get_local_cache_write (n=2971): min=0.14ms p50=0.28ms p90=2.75ms p95=15.64ms p99=2018.69ms max=6837.96ms
get_local_cache_check (n=4121): min=0.01ms p50=0.02ms p90=0.04ms p95=0.05ms p99=0.13ms max=257.27ms
get_overall (n=4121): min=4.10ms p50=6.75ms p90=66.03ms p95=156.04ms p99=1495.47ms max=6976.10ms
put_local_cache_check (n=1150): min=0.01ms p50=0.01ms p90=0.03ms p95=0.04ms p99=0.17ms max=2.34ms
Выводы из сравнения с lz4.
Набор action ID тот же, поэтому hit rate, число операций и распакованный размер (2.69 GB) совпадают. Меняется
только цена компрессии.
- Меньше трафика. zstd ужал put до
27.5%против37.7%у lz4, а на чтении это превратилось в770.46 MBскачанного backend-трафика вместо1.14 GB- примерно на треть меньше при тех же самых2.69 GBпосле распаковки (357.6%expansion против235.5%). - Дороже по CPU. За меньший трафик zstd платит процессором: медиана распаковки выросла с
0.47msдо3.78ms, p95 - с23.81msдо44.26ms. На записиput_compressionp50 поднялся с0.02msдо1.84ms. Для эфемерного CI-агента это допустимо: распаковок всего1598, и даже на p95 это десятки миллисекунд. - Backend стал чуть быстрее. Меньше байт по сети - меньше время похода в хранилище:
get_backendулучшился по p99 с210.64msдо135.65ms. Итоговыйget_overallтоже немного лучше на хвосте (p991620ms→1495ms). - Главный хвост остался прежним. В обоих прогонах
get_overallупирается не в кодек, а вget_local_cache_write(zstd:p99=2018ms,max=6837ms). Это запись материализованного blob на диск агента, и она от выбора компрессии не зависит. То есть дальше оптимизировать стоит локальный диск-слой, а не кодек.
Вывод: на этом стенде, где сеть до Object Storage дороже CPU, zstd выгоднее - меньше трафика, чуть лучше латентность чтения, а накладные расходы на распаковку теряются на фоне дисковых хвостов.
Влияние окружения на hit rate
Вы могли обратить внимание, что hit rate всего лишь 72.1%, а не 100%. Меня это тоже насторажило. Чтобы проверить,
в чем проблема, я включил GODEBUG=gocachehash=1 и посмотрел, что именно попадает в хеши.
Картина оказалась не случайной. В логе было 1373 разных compile actions: 224 для стандартной библиотеки и 1149 для
не-stdlib пакетов - самого проекта и vendored-зависимостей. Число промахов на тёплой сборке почти идеально совпадало с
этими 1149 non-stdlib действиями. То есть backend стабильно отдавал стандартную библиотеку и общий набор объектов, но
результаты компиляции проекта и vendor каждый раз отсутствовали и пересобирались заново.
Я проверил это на двух последовательных CI-прогонах одного и того же коммита с тем же S3-prefix. Результат не улучшался:
Local cache hits оставался 0, Backend cache hits держался на 72.1%, из backend читалось те же 1.14 GB, а запись
после тёплого прогона добавляла меньше мегабайта. Это важное отличие от просто неправильного prefix'а: если бы сборка
читала не тот бакет или не тот путь, backend hits были бы около нуля. Здесь чтение работало, но нужные compile outputs не
накапливались в читаемом наборе объектов.
Я отдельно проверил две подозрительные версии про gobuildcache. Первая: duplicate PUT мог якобы пропускать запись
action-index. Но в S3-backend у gobuildcache нет отдельного action-index и отдельного blob-объекта: ключ строится по
actionID, а bytes результата лежат в этом же объекте, с outputID в metadata. Поэтому состояния "blob есть, а action
record не записался" там просто нет. Счётчик Duplicate PUTs - это статистика внутри процесса, а не причина пропуска
записи в S3.
Вторая версия: async-записи не успевают доехать до Object Storage до завершения go build. Она тоже не подтвердилась:
AsyncBackendWriter.Close() ждёт все in-flight PUT перед ответом на close. Кроме того, тот же binary, тот же image и тот
же репозиторий на свежем MinIO и на свежем Yandex Object Storage давали 100% попаданий на втором прогоне: после первого
прогона в backend оставалось 5494 объекта, и второй прогон доставал их из S3. То есть на локально воспроизведённой
сборке добиться 100% hit rate получилось.
Но это не исключает другой класс проблем в CI: разные пути checkout'а. Если каждый прогон распаковывает репозиторий в
новую директорию, например с ID задачи в пути, абсолютный путь рабочей директории без -trimpath может попасть во входы
compile action. Тогда actionID для пакетов проекта и vendor будет меняться от запуска к запуску, а стандартная библиотека
продолжит попадать в кеш. По симптомам это очень похоже на мои цифры: stdlib hits есть, а non-stdlib compile actions
каждый раз промахиваются.
Проверяется это сравнением GODEBUG=gocachehash=1 между двумя CI-прогонами: если в хешах или compile inputs видны разные
пути рабочей директории, причина найдена. Практическое лекарство - фиксированный checkout path или сборка с -trimpath,
чтобы абсолютные пути не становились частью cache key.
Оставшийся вывод приземлённый: потолок 72.1% в моих SourceCraft-прогонах был не свойством Go-кеша и не багом кодека, а
проблемой окружения CI или конфигурации хранилища.
Когда remote cache не поможет
Remote cache не магия. Он помогает, когда достать результат из хранилища дешевле, чем пересобрать пакет.
Если проект маленький, а пакеты компилируются быстрее, чем занимает сетевой round-trip до Object Storage, удаленный get
может только замедлить сборку. Поэтому полезен локальный диск-слой перед remote: сначала проверяем локальный кеш агента,
и только на промах идем в сеть.
Латентность тоже критична. Кеш должен лежать рядом с раннерами. Кросс-региональный Object Storage легко съест весь выигрыш, особенно на большом количестве мелких объектов.
И отдельно про корректность. Go-кеш рассчитан на шаринг между машинами, потому что action ID включает входы действия.
Но окружение все равно должно быть воспроизводимым: одинаковая версия Go, одинаковые флаги сборки, аккуратность с cgo,
одинаковые системные заголовки и компилятор C, если они участвуют в сборке. Для CI почти всегда стоит включать -trimpath,
чтобы абсолютные пути рабочей директории не ломали попадания между агентами.
Если попаданий мало, можно включить GODEBUG=gocachehash=1 и посмотреть, что именно попадает в хеши. Обычно проблема
быстро находится: разные пути, разные флаги, разный тулчейн или cgo-окружение.
Цифры выше - это один реальный прогон, а не статистически достоверное исследование. Я не гонял десятки итераций, не считал p50/p95 по шагам CI и не контролировал соседей по железу. Поэтому относитесь к этим значениям как к иллюстрации порядка величины.
На другом проекте, другом регионе Object Storage и другом классе раннеров результат будет отличаться. Мерить нужно на своем пайплайне.
Итоги
Для эфемерных CI-агентов локальный $GOCACHE почти бесполезен: агент умирает, и кеш умирает вместе с ним. GOCACHEPROG
позволяет вынести build-кеш наружу, а gobuildcache дает готовый helper с S3-backend'ом.
После PR с поддержкой custom S3 endpoint этот backend можно использовать не только с AWS S3, но и с Yandex Object Storage или любым другим S3-совместимым хранилищем. Либо вы уже сейчас можете использовать мой форк.
В моем прогоне на Terraform-провайдере шаг сборки в SourceCraft сократился примерно с 4m52s до 2m30s, а с zstd -
до 2m23s. Это не универсальная константа, но хороший практический результат для большого Go-проекта на холодных
CI-агентах.