Сборка прошла, тесты прошли, а на прод уехал прошлый коммит
Пайплайн зелёный от начала до конца: линтер прошёл, юнит-тесты прошли, интеграционные тесты прошли, деплой отчитался «success». А открываешь прод — и там ведёт себя код так, будто вчерашнего фикса вообще не было. Первая мысль — «показалось, сейчас обновлю страницу» — не помогает. Вторая — «наверное, кеш» — тоже не помогает. Дальше начинается разбор, в котором самое неприятное открытие обычно не в коде, а в порядке, в котором CI выполняет работу.
Содержание
Что случилось на проде
Контекст был обычным: два коммита в main за несколько минут. Первый — правка округления цены в корзине, обычный багфикс, помечен как срочный. Второй, чуть раньше — рефактор валидации промокода, не срочный, но тоже влитый в main тем же утром. Оба прошли ревью, оба запустили свои пайплайны в GitLab CI отдельно друг от друга, потому что раннеров хватало и параллельный запуск никто не ограничивал.
Через пятнадцать минут после того, как пайплайн с фиксом округления отчитался «deploy: success», в проде цена по-прежнему округлялась неправильно. Первым делом посмотрели на сам под/контейнер — работает, не падает, рестартов нет. Посмотрели в интерфейс CI — у нужного коммита стадия deploy зелёная, время выполнения нормальное, ошибок в логах job нет. По всем признакам деплой состоялся успешно, просто задеплоил, судя по поведению, не то.
Это тот случай, когда «оно вроде задеплоилось» и «оно реально работает» — два разных утверждения, и разница между ними обнаруживается только когда кто-то из пользователей уже написал в поддержку.
Что показали логи CI/CD и сервера
Первым делом подняли историю пайплайнов за последний час и построили таймлайн по временным меткам started_at / finished_at каждой job, а не по номеру пайплайна:
Pipeline #4181 (commit 9f21a4c, "refactor: promo code validation")
build 14:02:03 -> 14:03:41
test 14:03:41 -> 14:19:52 <- аномально долго
deploy 14:19:55 -> 14:20:40
Pipeline #4182 (commit c77e6b0, "fix: cart price rounding")
build 14:05:11 -> 14:06:40
test 14:06:40 -> 14:09:58
deploy 14:10:02 -> 14:10:44
Коммит с фиксом округления (c77e6b0) был запушен позже, но его пайплайн отработал быстрее и задеплоился первым — в 14:10. А пайплайн более раннего коммита (9f21a4c) провисел на стадии тестов 16 минут вместо обычных трёх и задеплоился в 14:20, то есть на десять минут позже. Оба деплоя писали в один и тот же environment production без какой-либо синхронизации между собой — просто docker pull нужного тега и docker compose up -d.
Дальше зашли на сервер и посмотрели, какой образ реально запущен:
docker inspect app_web_1 --format \
'{{index .Config.Labels "org.opencontainers.image.revision"}}'
Ответ — 9f21a4c. Это подтвердило то, что уже было понятно по таймлайну: на проде лежит образ более раннего коммита, потому что его деплой физически выполнился позже и молча перезаписал контейнер, который до этого секунд четыреста отработал с правильным фиксом.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКакие гипотезы отбросили
Прежде чем разбираться с порядком пайплайнов, проверили более простые и частые причины — не хотелось чинить несуществующую проблему.
- CDN или браузерный кеш. Запросили API напрямую через
curlс заголовкомCache-Control: no-cacheмимо CDN, на бэкенд-порт сервера. Округление всё равно было старым — значит, дело не в кеше статики или ответов на грани CDN. - Задеплоена не та ветка. Проверили
CI_COMMIT_REF_NAMEв обоих пайплайнах — обаmain, оба триггерились по одному и тому же правилуonly: - main. Ветка была правильной в обоих случаях. - Docker собрал образ из кеша слоёв и не подхватил новый код. Сравнили дайджесты образов (
docker image inspect --format '{{.Id}}'до и после) — они отличались, оба образа были пересобраны заново, а не переиспользованы из кеша. Слой с приложением реально содержал новый код в обоих случаях, проблема не в сборке. - За балансировщиком остался старый инстанс со старым образом. В проде на тот момент — один инстанс, без blue-green и без canary. Проверили — под нагрузкой отвечал только он, «двух версий одновременно» не было, значит дело не в неполном roll-out на нескольких нодах.
Все четыре гипотезы отпали за десять-пятнадцать минут проверки, и стало ясно, что билд, тесты и доставка кода сами по себе не сломаны — сломался порядок, в котором два независимых деплоя писали поверх друг друга. Если бы дело действительно оказалось в неудачном обновлении, а не в гонке, дальнейшие шаги были бы другими — это отдельная тема, разобранная в статье про план отката после неудачного обновления.
Настоящая причина: гонка двух независимых пайплайнов
В GitLab CI (и в GitHub Actions, и в Jenkins с параллельными джобами) по умолчанию ничего не мешает двум пайплайнам для одной и той же ветки деплоиться в одну и ту же среду одновременно или в произвольном порядке. Стадия deploy просто запускается, когда предыдущие стадии того же пайплайна отработали успешно — никакой проверки «а не задеплоил ли уже кто-то более свежий коммит после меня» в стандартной конфигурации нет.
В нашем случае пайплайн более раннего коммита (9f21a4c) застрял на интеграционных тестах — они дергали песочницу платёжного провайдера, а та в этот момент отвечала медленно и с ретраями (в конфиге job было retry: 2), из-за чего стадия тестов растянулась с обычных трёх минут до шестнадцати. Пока она тянулась, коммит с фиксом округления успел прособраться, протестироваться и задеплоиться полностью — и на десять минут прод был в правильном состоянии. А затем «отставший» пайплайн наконец добрался до стадии деплоя и без каких-либо проверок перезаписал контейнер образом более старого коммита, откатив фикс назад, при этом сам отчитавшись «success» — с его точки зрения всё действительно прошло успешно, он просто не знал, что кто-то другой уже задеплоил более новый код после него.
Итог одной фразой: два пайплайна для одной ветки и одной production-среды выполнялись независимо, каждый уверенно докладывал об успехе, а какой из них должен победить — не решал никто. Победил тот, кто закончил последним, а не тот, кто был свежее.
Как подтвердили гипотезу
Чтобы убедиться, что дело именно в порядке завершения, а не в разовой случайности, сделали три проверки.
Во-первых, сопоставили время finished_at стадии deploy обоих пайплайнов через API GitLab:
curl -s --header "PRIVATE-TOKEN: $TOKEN" \
"https://gitlab.example.com/api/v4/projects/$ID/pipelines/4181/jobs" \
| jq '.[] | select(.stage=="deploy") | {finished_at, status}'
Разница в десять минут между завершением деплоев подтвердилась цифрами, а не ощущениями.
Во-вторых, подняли историю Slack-уведомлений от CI (у нас на каждый деплой уходило сообщение в канал) — сообщение о деплое 9f21a4c действительно пришло позже, чем сообщение о деплое c77e6b0, то есть перезапись была не гипотезой, а зафиксированным фактом с точным временем.
В-третьих, специально воспроизвели похожую ситуацию на стейджинге: запустили вручную пайплайн для старого коммита с искусственной задержкой на стадии тестов (sleep 600 в тестовом job) и почти одновременно — пайплайн для нового коммита без задержки. Результат повторился один в один: новый коммит на несколько минут оказывался в проде, а затем его тихо перезаписывал более медленный «старый» пайплайн. Это было финальным подтверждением: причина не в конкретном коммите и не в конкретном тесте, а в самой схеме без защиты от гонки.
Что изменили после инцидента
Правили сразу в двух направлениях: не дать медленному пайплайну задеплоиться поверх уже задеплоенного нового кода, и в принципе видеть, что происходит на проде, без раскопок в логах.
Первое — явная проверка «я всё ещё актуален» прямо перед деплоем. Перед тем как выполнять сам деплой, job сверяет коммит, который собирается выкатывать, с текущим HEAD ветки в удалённом репозитории:
deploy:
stage: deploy
environment: production
script:
- LATEST_SHA=$(git ls-remote origin refs/heads/main | awk '{print $1}')
- |
if [ "$LATEST_SHA" != "$CI_COMMIT_SHA" ]; then
echo "В main уже есть более новый коммит ($LATEST_SHA), пропускаем деплой устаревшего $CI_COMMIT_SHA"
exit 0
fi
- ./deploy.sh
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
Это не идеальная защита от гонки в самом строгом смысле — теоретически можно представить момент, когда оба пайплайна проходят проверку почти синхронно, — но она полностью закрыла тот сценарий, который у нас реально случился: медленный пайплайн, отставший от HEAD на один коммит, теперь просто пропускает деплой вместо того, чтобы перезаписать более свежий код.
Второе — сериализация самих деплоев через resource_group, чтобы деплой-джобы для одного и того же environment вообще не могли исполняться параллельно, а вставали в очередь:
deploy:
stage: deploy
resource_group: production
script:
- ./deploy.sh
Это не гарантирует автоматически «побеждает самый новый», но убирает саму возможность, что два процесса деплоя пишут в один контейнер одновременно, и делает порядок предсказуемым и наблюдаемым, а не зависящим от того, чья джоба на раннере освободилась раньше.
Третье — маркировка образа при сборке git-ревизией и коротким скриптом проверки после каждого деплоя:
ARG GIT_SHA
LABEL org.opencontainers.image.revision=$GIT_SHA
# после деплоя
DEPLOYED=$(docker inspect app_web_1 --format \
'{{index .Config.Labels "org.opencontainers.image.revision"}}')
echo "На проде сейчас коммит: $DEPLOYED"
Этот же вывод стали кидать в тот же Slack-канал, что и уведомление о деплое, — теперь по каналу видно не только «деплой запущен», но и «какой коммит реально оказался на сервере», и расхождение видно сразу, а не через десять минут жалоб от пользователей.
Отдельно завели алерт в мониторинге: если в течение пяти минут в один и тот же environment зафиксировано больше одного успешного деплоя, дежурному приходит уведомление — само по себе это не ошибка, но повод посмотреть на таймлайн вручную, пока не стало поздно.
Заодно пересмотрели сам процесс выкладки: в паре сервисов деплой всё ещё делался руками по SSH без пайплайна вообще, а там гонка коммитов не единственная проблема — это отдельно разобрано в статье про антипаттерн ручного деплоя по SSH. А там, где деплой уже автоматизирован, но всё ещё с секундной остановкой контейнера при обновлении, заодно перешли на схему из материала про деплой без простоя — она попутно снижает и цену подобных гонок, потому что старый и новый под какое-то время сосуществуют явно, а не переключаются одним docker compose up -d.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
У нас не GitLab CI, а GitHub Actions или Jenkins — эта проблема касается и нас?
Да, механизм одинаковый в любой системе CI/CD, где деплой-стадия не проверяет актуальность коммита перед выполнением. В GitHub Actions для той же цели используют concurrency: group: production с cancel-in-progress: true — это отменяет более старые запущенные workflow для того же ключа группы, когда стартует новый. В Jenkins то же самое реализуют через Lockable Resources или явную проверку git-ревизии в скрипте деплоя, как в примере выше.
Проще ли просто запретить параллельный запуск пайплайнов вообще?
Это радикальный, но рабочий вариант — например, ограничить одновременное число раннеров для стадии deploy до одного через resource_group. Минус в том, что при действительно долгом тесте (упавший внешний сервис, флап с ретраями) очередь на деплой будет копиться, и время до выката вырастет для всех, а не только для проблемного коммита. Проверка «я всё ещё HEAD» решает конкретно сценарий из этой статьи с меньшей ценой.
Как быстро проверить прямо сейчас, какой коммит реально крутится на проде?
Самый надёжный способ — не смотреть на статус CI, а спросить сам работающий контейнер, если образ помечен git-ревизией при сборке: docker inspect <container> --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'. Если ревизии в лейблах нет — это тоже стоит исправить в первую очередь, отдельно от разбора конкретного инцидента.
Поможет ли blue-green или canary деплой от этой проблемы?
Частично и не сама по себе. Blue-green и canary решают другую задачу — постепенную и обратимую доставку, снижение риска при самой выкатке. Гонка двух независимых пайплайнов для одной среды при этом никуда не девается: оба всё так же могут переключить трафик на свою версию в произвольном порядке, если между ними нет проверки актуальности коммита или сериализации деплоев.
Нужно ли теперь тестировать саму защиту от гонки?
Да, и удобнее всего — так же, как проверяли гипотезу в этом разборе: на стейджинге вручную запустить два пайплайна с искусственной задержкой в одном из них и убедиться, что «отстающий» действительно пропускает деплой, а не проезжает мимо проверки из-за неудачно подобранного тайминга.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →