MAATRIX / Блог / Два пайплайна перемешали артефакты в общей папке раннера

Два пайплайна перемешали артефакты в общей папке раннера

MAATRIX

В пятницу вечером фронтенд начал сыпать «chunk load failed» у части пользователей, а бэкенд иногда отдавал 500 на ручке, которую час назад чинили в хотфиксе. Деплой прошёл штатно, тесты зелёные, откат помог — но ненадолго, потому что при следующей выкладке всё повторилось. Разбор растянулся на два дня и упёрся не в код, а в то, как раннер публикует артефакты сборки. Расскажу по порядку: что мы видели, какие версии отбрасывали и что оказалось на самом деле.

Что сломалось

Стек — обычный: GitLab CE на своём сервере, self-hosted GitLab Runner на shell-executor (без Docker, исторически так сложилось — часть шагов сборки завязана на локально установленные тулчейны прямо на хосте раннера). Пайплайн собирает фронтенд (webpack, хэшированные чанки) и бэкенд (Go-бинарник), после чего шаг publish копирует результат в общую папку /srv/www/releases/incoming/, откуда отдельный systemd-сервис deploy-watcher подхватывает готовую сборку и раскладывает по продовым директориям.

В пятницу почти одновременно случились два пайплайна:

  • обычный пайплайн на пуш в main (плановый релиз);
  • хотфикс, запущенный вручную через «Run pipeline» с веткой hotfix/payment-timeout.

Раннер был настроен с concurrent = 4, поэтому оба пайплайна стартовали параллельно, не встав в очередь. Каждый прошёл сборку в своей изолированной рабочей директории (GitLab Runner сам разносит job'ы по подпапкам builds/0/..., builds/1/... — с этим всё было в порядке). А вот шаг публикации у обоих писал в один и тот же путь /srv/www/releases/incoming/, причём с флагом rsync -a --delete.

Итог на проде: часть JS-чанков осталась от сборки main, часть — от хотфикса, а version.json (в нём зашит CI_COMMIT_SHA) указывал на хотфикс. Браузер запрашивал чанк с хэшем, которого в актуальном build-манифесте хотфикса просто не существовало — отсюда «chunk load failed». Бэкенд-бинарник тоже подтянулся не тот: часть проверок из хотфикса не попала в собранный файл, потому что go build второго пайплайна стартовал, когда первый ещё не закончил rsync в целевую папку.

Что показали логи и метрики

Первым делом посмотрели Sentry — всплеск ошибок «Loading chunk N failed» ровно с момента деплоя, плюс единичные 500 с трейсом внутри обработчика, которого в проде якобы не должно было быть (он удалён в хотфиксе). Это уже намекало, что задеплоился не тот код, который ожидали.

Дальше — логи самих job'ов в GitLab CI. У обоих пайплайнов совпадающее по времени окно:

Job #4821 (main):     publish stage started 14:02:03, rsync ...
Job #4823 (hotfix):   publish stage started 14:02:09, rsync ...
Job #4821 (main):     rsync finished 14:02:41
Job #4823 (hotfix):   rsync finished 14:02:19

Хотфиксный rsync стартовал позже, но закончил раньше — сборка фронтенда у него была меньше по объёму. Значит, часть времени оба процесса rsync -a --delete писали (и удаляли лишнее) в одну и ту же папку одновременно.

Проверили это по временным меткам файлов на самом раннере:

ls -la --time-style=full-iso /srv/www/releases/incoming/static/js/ | sort -k6,7

Файлы группировались в две пачки мтайма с разницей в несколько секунд — ровно два разных rsync, а не один согласованный набор.

Логи deploy-watcher подтвердили финальный штрих: сервис слушал inotifywait на директорию и по правилу «нет новых событий 5 секунд — считаем сборку готовой» запускал раскладку на прод. Пауза между записью последнего файла из хотфикса и опозданием main (тот всё ещё писал) как раз попала в этот пятисекундный дебаунс — deploy-watcher решил, что сборка готова, и забрал папку в перемешанном состоянии.

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

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

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

Какие гипотезы отбросили

Прежде чем дойти до реальной причины, проверили и закрыли несколько версий — стоит перечислить, потому что каждая по отдельности выглядела правдоподобно.

  • CDN отдаёт устаревший кэш. Зашли напрямую на origin, curl'ом вытащили те же файлы с диска — рассинхрон был уже там, до всякого CDN. Версия отпала сразу.
  • Задеплоили не тот тег/коммит. Проверили git log и CI_COMMIT_SHA в логах обоих job'ов — оба пайплайна собирали именно те коммиты, которые от них ожидались. Проблема не в git, а в том, что случилось после сборки.
  • Webpack собирает нестабильные хэши чанков. Пересобрали фронтенд из того же коммита дважды подряд на чистом раннере — хэши совпали побитово. Сборка детерминирована, дело не в ней.
  • Диск раннера сыплется, файлы бьются при записи. Проверили smartctl -a на диске раннера и dmesg на предмет I/O-ошибок — чисто. Файловая система тоже не жаловалась.
  • Слияние веток резолвнулось неправильно и подтащило чужой код. Посмотрели diff мерж-коммита хотфикса — конфликтов не было, слияние чистое, хвостов от main в диффе нет.

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

В чём была реальная причина

Причина — не в раннере и не в сборке, а в шаге publish, который старше самого CI. Когда-то давно, до перехода на GitLab CI, релизы выкладывали руками через rsync в общую папку, и это же место по инерции стало точкой стыковки CI с деплоем: «CI просто кладёт файлы туда же, куда раньше клали руками». Тогда пайплайн был один и запускался строго последовательно — гонка была физически невозможна.

Когда concurrent в config.toml раннера подняли с 1 до 4 (чтобы ускорить параллельные тесты в MR), про шаг publish никто не вспомнил — он не был размечен как требующий эксклюзивного доступа. GitLab Runner исправно изолирует друг от друга *рабочие* директории job'ов, но абсолютный путь /srv/www/releases/incoming/, прописанный прямо в .gitlab-ci.yml, — это путь вне зоны изоляции раннера, обычная папка на файловой системе хоста. Ни раннер, ни GitLab об этой папке ничего не знают и никак её не защищают.

# .gitlab-ci.yml — было
publish:
  stage: publish
  script:
    - rsync -a --delete build/ /srv/www/releases/incoming/

Как только два пайплайна оказались в стадии publish одновременно, оба процесса rsync -a --delete начали писать (и подчищать «лишние», по их мнению, файлы) в одну папку без какой-либо блокировки. --delete в этой ситуации особенно опасен: он не просто добавляет файлы, а активно убирает из целевой директории всё, чего нет в источнике — то есть один job мог удалить ещё не прочитанные deploy-watcher'ом файлы другого job'а. А эвристика deploy-watcher («тишина 5 секунд — сборка готова») забирала папку в произвольный момент этой гонки, а не после гарантированного завершения обоих процессов.

Проверили гипотезу прямым экспериментом на тестовом раннере: запустили два rsync -a --delete с разным содержимым в одну целевую папку с секундным сдвигом — получили ровно ту же картину перемешанных мтаймов, что и в проде. Причина подтверждена воспроизведением, а не только логами постфактум.

Почему это не поймали раньше

Отдельный вопрос — почему проблема не всплыла сразу после поднятия concurrent, а тянулась незамеченной какое-то время. Ответ прозаичный: два пайплайна в стадии publish одновременно раньше случались редко. Обычный поток разработки — один пуш в main за раз, MR идут через отдельные раннеры для тестов, а не для публикации. Совпадение по секундам плюс достаточно большая сборка фронтенда (чтобы rsync растянулся на десятки секунд, а не долю секунды) — редкое стечение обстоятельств. Ручной запуск хотфикса, наложившийся на плановый релиз в то же окно, — как раз такой случай.

Это классическая ловушка гонки состояний: код годами работает «как задумано», потому что условия для срабатывания складываются нечасто, а не потому что условия невозможны в принципе. Мониторинг диска и CI ничего не показывал: место было, ошибок в логах rsync не было (он не падает, если параллельно кто-то ещё пишет в ту же папку, он просто честно делает своё дело поверх чужого).

Что изменили после инцидента

Разбили исправление на три независимых слоя — каждый закрывает свою часть проблемы, чтобы не полагаться на один-единственный фикс.

1. Публикация стала атомарной, а не «дозаписью» в общую папку.

publish:
  stage: publish
  script:
    - target="/srv/www/releases/${CI_PIPELINE_ID}"
    - mkdir -p "$target"
    - rsync -a build/ "$target/"
    - ln -sfn "$target" /srv/www/releases/incoming.tmp
    - mv -T /srv/www/releases/incoming.tmp /srv/www/releases/incoming

Каждый пайплайн собирает свою версию в отдельную папку с уникальным именем (CI_PIPELINE_ID гарантированно уникален), а переключение на неё — это атомарная замена симлинка через mv -T, а не построчная синхронизация файлов в общий каталог. Даже если два пайплайна финишируют почти одновременно, «выигрывает» тот, чей mv выполнился последним, но папка целиком принадлежит одному коммиту — перемешивания на уровне файлов больше физически не может быть.

2. Шаг publish вынесли на отдельный тег раннера с concurrent = 1.

[[runners]]
  name = "publish-runner"
  url = "https://gitlab.example.com/"
  executor = "shell"
  tag_list = ["publish"]
  limit = 1

Стадии build и test по-прежнему параллелятся на основном раннере с concurrent = 4, а publish явно помечен тегом и выполняется только на раннере с лимитом в один одновременный job. GitLab сам ставит такие job'ы в очередь, если предыдущий ещё не завершился — гонка исключена на уровне очереди, а не только на уровне файловой системы.

3. deploy-watcher перестал угадывать готовность по тишине inotify.

Раньше сервис решал, что сборка готова, по эвристике «нет новых событий N секунд». Теперь CI сам пишет последним файлом manifest.json с полями commit_sha и ready: true, и deploy-watcher реагирует не на любую файловую активность, а конкретно на появление этого файла — то есть на явный сигнал «всё остальное уже записано», а не на предположение по паузе.

# deploy-watcher: было — эвристика по тишине
inotifywait -e close_write -t 5 /srv/www/releases/incoming/

# стало — ждём явный маркер готовности
inotifywait -e create -m /srv/www/releases/ | grep --line-buffered manifest.json

Дополнительно перед раскладкой на прод добавили сверку: скрипт считает sha256sum собранных JS-файлов и сверяет список с тем, что зашит в manifest.json из самой сборки — если хотя бы одного файла из манифеста нет на диске, деплой останавливается с ошибкой, а не публикует неполный набор.

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

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

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

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

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

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

Разве GitLab Runner не изолирует job'ы друг от друга?

Изолирует — но только рабочую директорию самого job'а (builds/<id>/...). Как только скрипт в .gitlab-ci.yml сам пишет в произвольный абсолютный путь на файловой системе хоста (как было с /srv/www/releases/incoming/), это уже вне зоны ответственности раннера, и никакой встроенной защиты от гонки там нет.

Почему просто не вернуть concurrent = 1 на весь раннер?

Это убрало бы и симптом, и всю пользу от параллельных тестов в MR — сборки и тесты снова стали бы очередью на один слот. Вместо этого разделили раннеры по тегам: параллелизм остался там, где он безопасен (build/test), а стадия публикации получила отдельный раннер с жёстким лимитом в один job.

Помог бы переход на Docker executor вместо shell?

Частично. Docker executor изолирует файловую систему job'а лучше, но проблема была не в изоляции job'ов между собой, а в общей внешней папке, куда оба job'а сознательно писали через rsync. Контейнерная изоляция сама по себе не убирает гонку в общем ресурсе, к которому оба контейнера намеренно обращаются по одному и тому же смонтированному пути.

Как быстро проверить, есть ли похожий риск в своих пайплайнах?

Поищите в .gitlab-ci.yml шаги, которые пишут в абсолютные пути вне $CI_PROJECT_DIR (публикация артефактов, деплой-скрипты, общие кэш-папки руками через cp/rsync), и для каждого такого шага задайте вопрос: что случится, если этот же скрипт запустится ещё раз параллельно прямо сейчас.

Нужен ли для этого мощный сервер под раннеры?

Нет, проблема была не про ресурсы, а про порядок операций. Отдельный раннер для стадии publish с лимитом в один job может жить даже на небольшом VPS — он большую часть времени простаивает и просыпается только на секунды публикации.

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

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

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