MAATRIX / Блог / Диск раннера кончился, а сборки падали с ошибкой линковки

Диск раннера кончился, а сборки падали с ошибкой линковки

MAATRIX

Ночью упала половина сборок в CI, и все — с ошибкой линковки. Никто не трогал тулчейн, никто не менял зависимости, но ld вдруг стал жаловаться на битые объектные файлы. Разбор занял почти сутки, потому что настоящая причина не имела ничего общего с компилятором — диск раннера был забит под ноль, а сам факт нехватки места нигде не появился в логах сборки.

Что сломалось: сборки падали с непонятной ошибкой линковки

Self-хостед раннер GitLab CI (docker executor, VPS с 80 ГБ диска) обслуживал монорепозиторий на C++/Rust. Обычная сборка занимала 6-9 минут, проходила стабильно неделями. В одну из ночей после планового пайплайна с полной пересборкой (без кеша, с нуля — раз в сутки для проверки чистоты сборки) начали сыпаться ошибки вида:

/usr/bin/ld: final link failed: unexpected end of file
collect2: error: ld returned 1 exit status

и рядом, в соседних джобах — другое:

ar: writing operator-file: File truncated
libcore.a: error adding symbols: archive has no index; run ranlib

Ошибки были нестабильными: одна и та же джоба то падала, то проходила при перезапуске. Разные библиотеки, разные таргеты — общего знаменателя на первый взгляд не было. Пайплайны на других ветках, запущенные на других раннерах, шли нормально — проблема была локализована на одном конкретном раннере.

Что видели в логах и на дашбордах

Первым делом подняли логи джоб в GitLab и сравнили с метриками из Prometheus/Grafana по этому раннеру. Картина была противоречивой:

  • В логе сборки — только ошибки линкера, никаких No space left on device, никаких предупреждений от Docker.
  • Метрика node_filesystem_avail_bytes для корневого раздела показывала около 30% свободного места — то есть по дашборду диск не был проблемой.
  • dmesg на самом раннере (проверили отдельно, уже вручную) содержал строки ENOSPC от процессов ld и ar за те же временные метки, что и упавшие джобы.

Это несовпадение — «на дашборде всё в порядке, а ядро жалуется на нехватку места» — стало ключевой зацепкой, но не сразу: метрика диска была настроена по общему /, а Docker overlay2 и рабочие директории gitlab-runner физически лежали на нём же, но алерт на 30% никого не разбудил, потому что порог стоял 90%. О похожей ловушке — когда мониторинг показывает один процент, а места физически нет — есть отдельный разбор: диск заполнился на 100%, что отвалилось первым.

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

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

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

Гипотеза первая: сломался тулчейн после обновления базового образа

Неделей ранее обновили базовый Docker-образ раннера (новая минорная версия дистрибутива, апдейт binutils и openssl). Первая версия — где-то разошлись версии ld/ar с тем, что ожидает сборочная система, и архивы собираются некорректно.

Проверили: откатили образ на предыдущий тег, пересобрали раннер, прогнали проблемные джобы вручную — ошибка повторилась. Сравнили вывод ld --version и ar --version до и после отката — версии совпадали с теми, что были до инцидента, когда всё работало стабильно. Гипотезу закрыли: дело не в тулчейне, версии те же самые, что и в «здоровый» период.

Гипотеза вторая: гонка при параллельной сборке

Вторая версия — состояние гонки. Раннер был настроен с concurrent = 4 в config.toml, и несколько джоб из разных пайплайнов могли параллельно писать временные объектные файлы в пересекающиеся пути, если builds_dir не был размечен уникальным ID джобы. Похожая история — когда два независимых пайплайна физически кладут артефакты в одну и ту же папку раннера и перезаписывают файлы друг друга — встречается отдельно и стоит того, чтобы проверить свою конфигурацию builds_dir/cache_dir на этот счёт.

Проверили конфиг: builds_dir уже включал %CI_CONCURRENT_ID%, пути не пересекались, каждая джоба работала в изолированной поддиректории. Снизили concurrent до 1 и прогнали пачку джоб последовательно — ошибка линковки всё равно проявилась, просто реже (примерно раз на 6-7 запусков вместо раза на 3-4). Если бы дело было в гонке при параллельной записи, при concurrent = 1 проблема должна была исчезнуть полностью. Она не исчезла — гипотезу тоже отбросили, хотя снижение параллелизма и дало ложное ощущение, что стало «получше», и на полдня увело разбор в сторону.

Настоящая причина: диск раннера был забит под ноль в момент сборки

Отбросив тулчейн и гонку, вернулись к несовпадению из логов: ENOSPC в dmesg в момент падений. Зашли на раннер и сразу посмотрели фактическое состояние диска, а не то, что показывала усреднённая метрика:

df -h /
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1        80G   80G     0 100% /

docker system df
TYPE            TOTAL   ACTIVE   SIZE      RECLAIMABLE
Images          214     12       41.2GB    38.9GB (94%)
Build Cache     1841    0        19.4GB    19.4GB (100%)
Containers      3       0        180MB     180MB (100%)
Local Volumes   6       2        2.1GB     0B (0%)

Диск был забит полностью. Метрика в Grafana показывала 30% свободных перед инцидентом просто потому, что снимала точку раз в минуту, а забивание произошло скачком за считаные минуты во время ночного полного ребилда — данные успели устареть до срабатывания алерта, а порог алерта в 90% занятости вообще не рассматривал резкий рост, только абсолютное значение.

Что именно съело место:

  • Docker build cache — раннер месяцами не чистился, docker builder prune не был настроен по расписанию. Каждый уникальный набор слоёв Dockerfile оставлял кеш навсегда.
  • Dangling-образы — при каждом ребилде базового образа старые слои становились «висячими» (<none>:<none>) и не удалялись автоматически.
  • Артефакты и кеши CI-джоб — GitLab Runner хранит cache по ключу ветки; фича-веток за полгода накопилось несколько сотен, и ни у одной не было expire_in, так что кеши лежали бессрочно.
  • Логи контейнеров — часть сервисов внутри джоб писала verbose-логи в stdout без ротации, а Docker по умолчанию не ограничивает размер json-log-файла.

Ночной пайплайн с полной пересборкой без кеша тянул свежие базовые образы и создавал заметно больше временных файлов, чем обычные инкрементальные сборки — именно он и оказался «последней каплей», которая довела почти полный диск до 100%.

Дальше — почему это выглядело как ошибка линковки, а не как явное «нет места». ld и ar пишут выходной файл через write(), и когда диск кончается посреди записи объектного файла или архива, файл обрывается на середине — получается физически повреждённый .o или .a. Линкер и архиватор в такой ситуации не всегда сообщают «нет места»: они видят усечённый/битый файл и репортуют это как ошибку формата — «unexpected end of file», «archive has no index», «file truncated». Само системное ENOSPC в этот момент действительно возникает на уровне ядра (поэтому оно есть в dmesg на хосте), но в буферизованный stdout контейнера, который собирает GitLab Runner, попадает уже вторичная, «человеческая» ошибка инструмента, а не сырой код ошибки записи. Отсюда и разрыв: на дашборде — вроде бы есть место, в логе джобы — ошибка линкера, и только dmesg хоста показывал настоящую причину.

Что изменили после разбора

Первым делом почистили диск вручную, чтобы вернуть раннер в строй:

docker builder prune -af
docker image prune -af --filter "until=72h"
gitlab-runner cache clean --config /etc/gitlab-runner/config.toml
journalctl --vacuum-size=500M

Дальше — системные изменения, чтобы не повторилось:

Что былоЧто стало
Очистка Docker вручную, по памятиsystemd-таймер docker system prune -af --filter "until=72h" раз в сутки
Кеш CI-джоб без срока жизниcache: policy: pull-push + expire_in: 14 days в .gitlab-ci.yml
Алерт на диск при 90% занятости, раз в минутуАлерт на 80% и отдельно — на резкий рост занятости за 5 минут (predict_linear)
Docker-логи без лимитаlog-opts: max-size: 10m, max-file: 3 в daemon.json
Полная ночная пересборка на том же диске, что и рабочие джобыВынесена на отдельный раздел/диск, не конкурирует за место с обычными пайплайнами

Правило predict_linear в Prometheus добавили специально под этот сценарий — оно ловит не текущий процент занятости, а скорость его роста, и присылает алерт заранее, если диск закончится в ближайший час при сохранении текущего темпа. Подробно настройка такого мониторинга диска описана в отдельном материале: как установить и настроить мониторинг диска на VPS. А сами алерты стали приходить в Telegram, а не только висеть в Grafana — про это тоже есть отдельная инструкция: алерты в Telegram на сервере.

Отдельно пересмотрели, зачем вообще Docker занимал столько места на этом раннере — регулярная чистка build-кеша и dangling-образов теперь идёт по расписанию, а не «когда вспомнили». Если у вас похожая картина — диск раннера или сервера приложений неожиданно съеден слоями и кешем контейнеров — стоит посмотреть отдельный разбор: Docker занимает всё место на диске: причины и решение.

Как не наступить на те же грабли на своём VPS

Несколько практических выводов, которые применимы не только к CI-раннерам:

  • Не доверяйте усреднённой метрике занятости диска — она может отставать от реальности на минуты, если забивание происходит скачком (полная пересборка, распаковка большого архива, дамп базы). Добавляйте алерт на скорость роста, а не только на абсолютный порог.
  • Ошибка линковки/архивации не всегда про компилятор. Если ld, ar, ranlib вдруг начинают жаловаться на «битые» файлы без видимой причины — в первую очередь проверьте df -h и dmesg | grep -i "no space" на самой машине, а не только в контейнере.
  • Держите build-кеш и логи контейнеров под TTL. Cache без expire_in и Docker-логи без max-size — это гарантированное медленное заполнение диска, вопрос только времени.
  • Разносите «шумные» ночные джобы и обычные сборки по разным дискам или хотя бы разным разделам, если это возможно — так у вас не будет ситуации, когда один тяжёлый пайплайн валит все остальные.
  • Проверяйте не только df, но и du. Иногда df и du расходятся из-за удалённых, но ещё открытых файлов (например, залоченных контейнером логов) — это отдельная и довольно частая ловушка, разобранная в материале почему df и du показывают разное.

Если вы разворачиваете собственный self-hosted раннер под CI/CD, изначально закладывайте диск с запасом и отдельным разделом под Docker-данные — тогда даже при накопленном мусоре у вас будет время среагировать на алерт, а не разбирать битые артефакты среди ночи.

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

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

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

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

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

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

Почему ошибка «No space left on device» не попала в лог CI-джобы?

Потому что ld/ar пишут выходной файл через write(), и при обрыве записи из-за нехватки места чаще репортуют это как ошибку формата файла («unexpected end of file», «file truncated»), а не как сырой код ошибки. Само ENOSPC осталось видно только в dmesg на хосте раннера.

Почему метрика диска в Grafana показывала 30% свободного места, если диск был забит на 100%?

Метрика опрашивалась раз в минуту, а забивание произошло скачком за несколько минут во время ночной полной пересборки — данные устарели до следующего опроса, а алерт был настроен только на абсолютный порог в 90%, без учёта скорости роста.

Как быстро проверить, что причина сборки именно в нехватке места, а не в самом коде?

Зайти на раннер и выполнить df -h и docker system df, затем dmesg | grep -iE "enospc|no space" за временной диапазон падения джобы. Если место действительно кончилось — это будет видно сразу, без анализа исходников.

Достаточно ли одного docker system prune для решения проблемы навсегда?

Нет, разовая чистка снимает симптом, но не причину. Нужен таймер на регулярную чистку build-кеша и dangling-образов, TTL на кеш CI-джоб и лимит на размер Docker-логов — иначе диск снова забьётся, просто через другой промежуток времени.

Стоит ли просто увеличить диск раннера, не разбираясь в причине?

Как временная мера — да, это купит время. Но без TTL на кеши и регулярной чистки больший диск заполнится тем же мусором, просто чуть позже — расширение диска стоит делать вместе с настройкой автоочистки, а не вместо неё.

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

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

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