Скрытая ловушка CI/CD: как автоматизация релизов Android съела годовой запас хранилища GitHub за 10 дней
Рабочий пайплайн сборки Android-приложений вдруг перестал выпускать релизы — и причина оказалась не в коде, а в том, что GitHub Actions незаметно исчерпал бесплатную квоту хранилища.
Пятьсот мегабайт — вот и весь бесплатный запас хранилища GitHub Actions на организацию. Рабочий пайплайн потратил его за десять дней, и настоящим решением оказался не скрипт очистки, а отказ от облачной инфраструктуры в пользу собственного сервера.
Это третья часть серии из пяти материалов, в которых разбирается путь от идеи автоматизировать публикацию Android-приложений в Google Play до реально работающего CI/CD-пайплайна. В первых двух частях автор серии — разработчик, пишущий на dev.to под ником cynthizo — описал, как собрать подписание, версионирование и управление релизными треками. Пайплайн заработал и стабилизировался. Казалось, можно забыть.
А потом одна из сборок упала с ошибкой, которая не имела никакого отношения к коду приложения.
Проблема: хранилище кончилось
При попытке загрузить артефакт сборки GitHub Actions вернул:
Error: Failed to CreateArtifact: Artifact storage quota has been hit.
Unable to upload any new artifacts. Usage is recalculated every 6-12 hours.
У бесплатного плана GitHub для организаций есть жёсткое ограничение на хранилище артефактов и кешей Actions: 500 мегабайт суммарно, на всю организацию, навсегда — не на репозиторий, а на весь аккаунт. Не «500 МБ в месяц», а «500 МБ, точка». Это число — ключевое для всей истории, и именно оно делает её поучительной для любого, кто автоматизирует мобильные сборки в облаке.
Чтобы понять, что именно съело квоту, пришлось вручную обойти все артефакты и кеши через GitHub API:
gh api repos/<org>/<repo>/actions/artifacts --paginate \
-q '.artifacts[].size_in_bytes' | \
awk '{sum+=$1; n++} END {printf "count=%d total_MB=%.1f\n", n, sum/1024/1024}'
gh api repos/<org>/<repo>/actions/caches --paginate \
-q '.actions_caches[] | [.key, .size_in_bytes] | @tsv'
На одних артефактах — подписанных AAB-файлах релизов (~70 МБ каждый) и внутренних тестовых APK (20–40 МБ) — «висело» почти два гигабайта за пару недель. Но настоящий рекордсмен оказался другим.
Десять копий одного и того же кеша
Gradle-зависимости Android-проекта — это сотни мегабайт библиотек. Их логично закешировать между сборками, чтобы не тянуть заново. В пайплайне для этого был соответствующий шаг. Проблема в том, что GitHub Actions по умолчанию привязывает кеш к ветке. Кеш, сохранённый на одной ветке, не доступен из другой — он просто не подтягивается.
Десять активных веток — десять полных, почти идентичных копий одного и того же набора зависимостей. Каждая занимает сотни мегабайт. Ни одна не удаляется автоматически, потому что по каждой ветке регулярно идут коммиты и сборки, которые «обновляют» соответствующий кеш. В сумме — доминирующая доля всей квоты, и при этом ни в одном сообщении об ошибке об этом не сказано. Найти проблему можно было только вручную, перечислив содержимое хранилища.
Кеш, который никогда не работал
Вторая находка усугубляет картину. Ключ кеша в пайплайне выглядел так:
key: ${{ runner.os }}-gradle-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/build.gradle') }}
Выглядит разумно — ключ зависит от хеша конфигурационных файлов, меняется при обновлении зависимостей. Проблема: оба файла находятся в директории android/, которая попадает в .gitignore и генерируется позже, на этапе предсборки. В момент, когда шаг кеширования выполняется, этих файлов просто не существует на диске.
Функция hashFiles() в GitHub Actions при отсутствии указанных файлов возвращает пустую строку. Всегда. Это значит, что:
- ключ кеша никогда не менялся в зависимости от реального состояния зависимостей;
- каждый запуск был фактическим промахом кеша (cache miss), замаскированным под кеширование;
- зависимости скачивались заново при каждой сборке;
- и хуже того — каждая ветка сохраняла «свой» кеш под одним и тем же статическим ключом вместо того, чтобы корректно переиспользовать общий.
Две ошибки, один корень: система кеширования, которая выглядела работающей, но не выполняла ни одной из своих задач.
Артефакты, которые никто не собирался хранить
Подписанные AAB-файлы — тяжёлые бинарники, которые и составляли основную массу артефактов — хранились с 30-дневным сроком жизни. Эта цифра не была осознанным решением: она стояла по умолчанию в GitHub Actions, и никто её не пересмотрел.
При этом AAB-файл публикуется в Google Play в той же самой задаче, через секунды после загрузки в GitHub. Копия в Actions хранилась чисто «на всякий случай» — чтобы можно было скачать точный файл, который ушёл в релиз, без перезапуска сборки. За всё время никто ни разу не обратился к трёхнедельной копии. Тридцать дней хранения тяжёлого бинарника, который нужен только для отладки, — чистая растрата квоты.
Старый артефакт — не значит удалённый
Есть ещё один нюанс. Незадолго до этого случая в пайплайне нашли и исправили баг: внутренние тестовые сборки раньше загружали APK одновременно в облачное хранилище и в GitHub Actions, причём копия в Actions никем не использовалась. Утечку закрыли.
Но вот что важно: исправление утечки не удалило уже накопленные дубликаты. Артефакты, созданные до фикса, продолжали занимать место, потому что никто не вернулся и не почистил бэклог. Это общая ловушка: закрыть источник проблемы — не значит устранить её последствия. Особенно в системах с автоматическим накоплением данных, где «срок жизни» — единственный механизм очистки.
Чистка помогает, но не решает проблему
Удаление накопленных артефактов и сокращение срока хранения — это правильные меры. Они дают передышку. Но они не устраняют корневую причину: пока пайплайн работает на инфраструктуре GitHub, он подчиняется квотам GitHub — и по минутам сборки, и по хранилищу, разделяя их на всю организацию. Это тот же тип ограничения, от которого автор серии ушёл ранее с EAS Build (Expo Application Services), где бесплатная очередь сборок тоже задыхалась под нагрузкой. Проблема просто сместилась на уровень выше.
Реальным решением стал переход на self-hosted runner — собственный сервер или виртуальная машина, где и сборка, и хранение артефактов контролируются командой напрямую, без привязки к чужим биллинговым планам. Это принципиально более серьёзный шаг, чем скрипт очистки, и он пришёл со своими сюрпризами: образ self-hosted runner'а не содержал утилиты /usr/bin/time, которая на стандартных образах GitHub стоит из коробки и использовалась для сбора метрик длительности сборки и пикового потребления памяти. Мелочь, но показательная: «свой сервер» означает, что все допущения, которые облачный хостинг делал за вас молча, теперь лежат на вас.
Вместо итога: архитектурный урок
Эта история — не про GitHub Actions и не про Gradle. Она про общую проблему автоматизации мобильных релизов: невидимые накопительные издержки инфраструктуры.
Бесплатные и условно-бесплатные тире CI/CD-сервисов щедры на старте. Хватает на прототип, на первые несколько месяцев. Но мобильные сборки — тяжёлые: один AAB весит десятки мегабайт, кеш зависимостей — сотни, а логов и промежуточных артефактов за короткий срок набираются гигабайты. При этом ограничения редко бывают там, где их ждёшь: не в минутах сборки, а в хранилище; не в количестве запусков, а в веточной изоляции кешей.
Три вывода, которые можно вынести:
-
Кешируйте осознанно. Проверяйте, что ключ кеша действительно зависит от релевантных файлов. Если
hashFiles()ссылается на пути, которых нет на момент выполнения шага, — кеш мёртв, и вы платите за хранение пустышки. -
Аудитируйте хранилище, а не только логи. GitHub Actions не показывает дублирующиеся кеши в сводке ошибок. Чтобы найти их, нужно вручную обойти API — или принять на веру, что всё в порядке.
-
Считайте квоту как бюджет. 500 МБ на организацию — это не абстрактное число. Один подписанный AAB — 70 МБ. Десять веток с кешем Gradle — и квота исчерпана. Если пайплайн растёт, вопрос не «хватит ли бесплатного тира», а «когда именно он кончится».
Четвёртая часть серии обещает разбор ещё одной скрытой ошибки — в системе нотификаций, которая сообщала об успехе как о неудаче, — и описание первого реального продакшн-релиза после всех переделок.