Диск раннера кончился, а сборки падали с ошибкой линковки
Ночью упала половина сборок в CI, и все — с ошибкой линковки. Никто не трогал тулчейн, никто не менял зависимости, но ld вдруг стал жаловаться на битые объектные файлы. Разбор занял почти сутки, потому что настоящая причина не имела ничего общего с компилятором — диск раннера был забит под ноль, а сам факт нехватки места нигде не появился в логах сборки.
Содержание
- Что сломалось: сборки падали с непонятной ошибкой линковки
- Что видели в логах и на дашбордах
- Гипотеза первая: сломался тулчейн после обновления базового образа
- Гипотеза вторая: гонка при параллельной сборке
- Настоящая причина: диск раннера был забит под ноль в момент сборки
- Что изменили после разбора
- Как не наступить на те же грабли на своём VPS
Что сломалось: сборки падали с непонятной ошибкой линковки
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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →