Ночной автодеплой выкатил ветку разработчика на боевой сайт
В четыре утра дежурный увидел красный уровень в мониторинге: сайт отдавал 500-е на половине запросов, в футере вылезла надпись «DEBUG MODE», а в личном кабинете пропала кнопка оплаты. Никто не деплоил — по крайней мере, никто из тех, кто должен был. Разбор растянулся на два часа и уперся в вебхук, который годами делал не то, что от него ожидали.
Содержание
Что сломалось на проде
Утренняя смена начала с жалоб от пользователей: часть страниц каталога отдавала 500, на некоторых включился баннер «функция в разработке», а платежная форма перестала показывать способы оплаты. Первая реакция — откатить последний релиз. Но по календарю релизов ночью ничего не планировалось: последний плановый деплой был днем раньше, все прошло штатно.
Проверили systemd-юнит приложения:
systemctl status app.service
journalctl -u app.service --since "02:00" --until "05:00"
В логе нашлась строка, которой там быть не должно:
Feb 03 02:47:11 web01 deploy.sh[8821]: Deploying commit a1b2c3d (ref refs/heads/feature/new-checkout)
Feb 03 02:47:14 web01 deploy.sh[8821]: git checkout -f a1b2c3d
Feb 03 02:47:16 web01 deploy.sh[8821]: systemctl restart app.service
Ветка feature/new-checkout — рабочая ветка одного из разработчиков, недописанная переделка формы оплаты с флагом DEBUG=true в конфиге и намеренно отключенным блоком выбора платежных методов (заглушка на время локальной отладки). Именно этот коммит и оказался на проде.
Что показали логи и метрики
Дальше подняли историю деплоев за последние недели, чтобы понять, разовая это случайность или систематическая дыра:
grep "Deploying commit" /var/log/deploy/deploy.log | tail -30
Оказалось, что таких «внеплановых» деплоев за месяц было еще три — просто предыдущие три раза ветки разработчиков были рабочими и не ломали функциональность визуально, поэтому их никто не заметил. Совпадение по времени: все четыре случая — это push в удаленный репозиторий между полуночью и пятью утра, когда разработчики из другого часового пояса заканчивали смену и отправляли промежуточный код «на сохранение».
Метрики Grafana подтвердили: всплеск 5xx начался ровно в 02:47:16 — момент рестарта сервиса, синхронно с записью в логе деплоя. Никакой деградации до этого не было: нагрузка ровная, диск и память в норме, база данных не показывала аномалий по pg_stat_activity. То есть это не постепенная деградация, а мгновенный переключатель — характерный признак того, что причина не в железе и не в нагрузке, а в конкретном событии.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотезы, которые отбросили
Прежде чем дойти до вебхука, проверили три версии, каждая из которых казалась правдоподобной на старте.
Взлом сервера или CI. Первая мысль — кто-то получил доступ к раннеру или к серверу и подложил код вручную. Проверили auth.log и last на предмет посторонних SSH-сессий, сверили ps aux на подозрительные процессы, посмотрели список последних логинов в GitLab/Gitea. Ничего постороннего: коммит подписан валидным ключом разработчика, сессия деплоя пришла с того же IP, что и обычные вебхуки git-сервера.
Плановая миграция или cron-задача. Проверили crontab -l под пользователем деплоя и systemd-таймеры — ни один не был назначен на 02:47, и ни один не трогает deploy.sh.
Проблема на стороне хостинга. Исключили сетевые и инфраструктурные сбои: dmesg, состояние диска, метрики гипервизора — все чисто, инцидент строго локальный для приложения.
Отбросив эти три версии, сосредоточились на самом deploy.sh и на том, кто и как его вызывает.
Настоящая причина: вебхук без проверки ветки
Автодеплой был настроен через вебхук git-сервера: при пуше в репозиторий git-сервер бьет POST-запросом на эндпоинт на сервере, который принимает JSON пейлоад и запускает deploy.sh с хешем коммита из поля after. Слушатель на сервере — простой скрипт на Python, поднятый как systemd-сервис:
@app.route("/hooks/deploy", methods=["POST"])
def deploy_hook():
payload = request.json
commit = payload["after"]
subprocess.Popen(["/opt/deploy/deploy.sh", commit])
return "", 204
Проблема — в том, чего в этом коде нет. Пейлоад пуша содержит поле ref (refs/heads/main, refs/heads/feature/new-checkout и так далее), но обработчик его не читает вообще. Любой пуш в любую ветку репозитория — хоть в main, хоть в личную ветку на другом конце офиса — вызывал один и тот же деплой на проде.
Настройки вебхука на стороне git-сервера это подтвердили: в разделе «Webhooks» у репозитория триггер был выставлен на событие Push Events без фильтра по ветке — то есть галочка «Branch filter» была пустой, что де-факто означает «все ветки»:
Trigger: Push Events
Branch filter: (пусто = все ветки)
URL: http://web01.internal:8088/hooks/deploy
Когда автодеплой настраивали больше года назад, в репозитории физически не пушили ничего, кроме main — разработка велась в отдельном форке, а в основной репозиторий заливали уже готовые релизы. За прошедшее время процесс поменялся: команда выросла, перешли на feature-ветки внутри одного репозитория, а настройку вебхука никто не пересмотрел, потому что она «и так работала».
Почему это не всплывало раньше
Отдельный вопрос — почему баг с фильтром ветки не проявлялся месяцами. Разбор истории показал две причины.
Во-первых, deploy.sh не делал git checkout в чистый временный каталог, а работал прямо в рабочей копии продакшена:
#!/bin/bash
cd /var/www/app
git fetch origin
git checkout -f "$1"
composer install --no-dev
systemctl restart app.service
Прямой checkout в боевую директорию без промежуточного билда и без smoke-теста — сам по себе риск, независимо от бага с веткой: если бы фильтр ветки работал правильно, но кто-то запушил в main недоделанный коммит, результат был бы тем же.
Во-вторых, большинство ночных пушей в рабочие ветки случайно не задевали визуально заметный функционал — правки логики бэкенда, рефакторинг, тесты. Разработчики просто не знали, что их промежуточные пуши куда-то деплоятся, поэтому и не связывали дальнейшие мелкие баги (пропавшую иконку, лишний лог в консоли) с собственным пушем накануне ночью. Инцидент с чекаутом стал первым случаем, где сломанное было видно невооруженным глазом — отсюда и разбор.
Что изменили после инцидента
Правки распределили по трем уровням: фильтр событий на git-сервере, логика скрипта деплоя и процесс вокруг него.
На уровне git-сервера выставили явный фильтр ветки в настройках вебхука:
Branch filter: main
Для Gitea и GitLab это делается прямо в UI репозитория (Settings → Webhooks → Branch filter), для самодельных хуков на голом git — через проверку ref в hook-скрипте на стороне сервера.
В обработчике вебхука добавили проверку ветки как вторую линию защиты — даже если фильтр на git-сервере снова кто-то случайно снимет:
@app.route("/hooks/deploy", methods=["POST"])
def deploy_hook():
payload = request.json
if payload.get("ref") != "refs/heads/main":
return "", 204 # игнорируем всё, кроме main
commit = payload["after"]
subprocess.Popen(["/opt/deploy/deploy.sh", commit])
return "", 204
Сам deploy.sh переписали так, чтобы сборка шла во временный каталог, и переключение на новую версию происходило атомарно через symlink — по образцу подхода из статьи про автодеплой из git на VPS:
#!/bin/bash
set -euo pipefail
COMMIT="$1"
RELEASE_DIR="/var/www/releases/$COMMIT"
git clone --quiet /var/www/app.git "$RELEASE_DIR"
cd "$RELEASE_DIR"
git checkout -f "$COMMIT"
composer install --no-dev --quiet
# smoke-тест перед переключением
if ! curl -sf http://127.0.0.1:8081/health; then
echo "Smoke test failed, aborting deploy of $COMMIT" >&2
exit 1
fi
ln -sfn "$RELEASE_DIR" /var/www/current
systemctl reload app.service
Добавили уведомление о каждом деплое в отдельный канал команды, чтобы любой неожиданный релиз был виден сразу, а не постфактум в логах:
curl -s -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" \
-d chat_id="$CHAT_ID" \
-d text="Deploy to prod: commit $COMMIT ($(date))"
Отдельно защитили ветку main в настройках git-сервера — запретили прямой пуш без merge request и обязали проходить хотя бы один ревью, что снимает и смежный риск: даже правильно отфильтрованный по ветке автодеплой все равно опасен, если в main можно залить что угодно в обход код-ревью.
Наконец, для проверки изменений перед проектом завели staging-копию сайта на отдельном VPS с тем же стеком, что и прод, но меньшей конфигурации — feature-ветки теперь по желанию можно деплоить туда через отдельный вебхук с собственным фильтром, не трогая боевой сервер вообще.
Чек-лист: как проверить свой автодеплой на ту же дыру
Если у вас есть похожая схема — вебхук git-сервера плюс скрипт, который дергает git checkout и рестарт сервиса, — стоит пройтись по пунктам:
- В настройках вебхука явно указан фильтр по ветке (
mainилиrelease/*), а не «все события пуша». - Обработчик на сервере сам проверяет поле
refиз пейлоада, а не доверяет только настройке на стороне git-сервера. - Ветка, с которой деплоится прод, защищена от прямого пуша — только через merge request.
- Сборка идет во временный каталог, переключение атомарное (symlink или blue-green), а не
git checkout -fпрямо в боевой директории. - Перед переключением есть хоть какой-то smoke-тест — пусть даже простой запрос к
/health. - Каждый деплой логируется отдельно от общих логов приложения и присылает уведомление в чат команды.
- Есть быстрый и понятный путь отката — см. также разбор о том, что делать, если обновление положило прод.
Если своей CI/CD-инфраструктуры пока нет и автодеплой настраивается с нуля, дешевле сразу закладывать фильтр по ветке и защиту main, чем чинить это после первого инцидента — как показывает этот разбор, «работает уже год» не значит «настроено правильно», это может значить «просто повезло с тем, что пушили».
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Как быстро проверить, что вебхук деплоит только нужную ветку?
Сделайте тестовый пуш в произвольную ветку-однодневку и посмотрите лог деплоя (journalctl -u <deploy-service> или файл лога скрипта) — если запись о деплое появилась, фильтр не работает, и это нужно чинить немедленно, до следующего инцидента.
Достаточно ли фильтра branch filter на git-сервере, или нужна проверка в самом скрипте?
Лучше делать обе проверки. Фильтр на git-сервере — первая линия защиты, но настройки веб-интерфейса иногда сбрасываются при обновлении, миграции репозитория или ручной правке — проверка ref в самом обработчике не даст лишнему деплою пройти, даже если фильтр вдруг исчез.
Что делать, если инцидент уже произошел и на проде оказался чужой недописанный код?
Прежде всего откатить последний известный рабочий коммит на main тем же деплой-скриптом, затем проверить состояние базы данных на предмет миграций, которые могла запустить сломанная ветка, и только после этого разбирать причину — восстановление сервиса важнее анализа в моменте.
Нужен ли отдельный staging-сервер, если команда маленькая?
Даже минимальный staging на недорогом VPS того же стека снимает большую часть риска: feature-ветки деплоятся туда автоматически, а на прод код попадает только через merge в main вручную или по расписанию, а не по первому же пушу.
Как быстро понять, что причина именно в вебхуке, а не в самом коде приложения?
Сверьте время первого 5xx с временем записи о деплое в логе деплой-скрипта (не в логе приложения) — если они совпадают до секунды, а до этого метрики были ровными, дело почти наверняка в неожиданном деплое, а не в постепенной деградации кода или инфраструктуры.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →