Git hooks вместо Jenkins: post-receive на bare-репозитории деплоит в продакшн
Настроили post-receive hook на bare-репозитории: пуш в ветку production запускает rsync на целевой сервер и перезагрузку nginx. Без Jenkins, для пяти разработчиков.
Git hooks набирают популярность как лёгкий механизм автоматизации деплоя в небольших командах без тяжёлой CI-инфраструктуры
В марте описывали как поставили Jenkins для запуска Ansible playbooks. Штука рабочая, но с ценой входа: Jenkins надо где-то держать, настраивать, обновлять, смотреть чтобы не упал. Для одного клиента на сопровождении это было оправдано - проект достаточно большой, пайплайн нетривиальный. Но месяц назад к нам пришла небольшая веб-команда, пять разработчиков, которым нужен был простой деплой PHP-приложения. Поднимать Jenkins ради них было бы стрельбой из пушки по воробьям.
Вспомнили про git hooks - и оказалось, что для этого случая хватает с запасом.
Схема
У команды уже был свой GitLab, там же хранился репозиторий приложения. Целевой сервер - VPS с nginx + php-fpm. Требование простое: после того как разработчик делает git push origin production, приложение должно оказаться на сервере и nginx должен подхватить новую версию.
Классическая схема с bare-репозиторием выглядит так:
- На целевом сервере создаём bare-репозиторий - это просто хранилище объектов git без рабочей копии.
- Туда же кладём хук
post-receive. - Разработчики добавляют этот bare-репозиторий как remote и пушат в него.
- Хук срабатывает автоматически после каждого успешного push.
Альтернативный вариант - держать bare-репозиторий отдельно от целевого сервера и делать rsync через SSH. Мы пошли именно так, потому что приложение и его зависимости уже были отдельно настроены на сервере, и перемешивать git-объекты с рабочими файлами не хотелось.
Что внутри хука
#!/bin/bash
REPO_DIR="/var/repo/app.git"
DEPLOY_DIR="/var/www/app"
TARGET_HOST="web01.example.com"
TARGET_PATH="/var/www/app"
while read oldrev newrev refname; do
branch=$(git rev-parse --symbolic --abbrev-ref "$refname")
if [ "$branch" = "production" ]; then
echo "--- Deploy to production ---"
git --work-tree=/tmp/deploy-staging --git-dir="$REPO_DIR" checkout -f production
rsync -az --delete \
--exclude='.git' \
--exclude='var/cache' \
--exclude='var/logs' \
/tmp/deploy-staging/ \
deploy@${TARGET_HOST}:${TARGET_PATH}/
ssh deploy@${TARGET_HOST} "sudo nginx -s reload"
echo "--- Done ---"
fi
done
Несколько деталей которые важны:
--delete в rsync - без него файлы которые удалили из репозитория остаются на сервере. Первый раз налетели именно на это.
--exclude для кешей и логов - иначе rsync затрёт их при каждом деплое. Что тоже неприятно.
sudo nginx -s reload - nginx работает от root, deploy-пользователь его перезапускать не может напрямую. Добавили одну строку в sudoers на целевом сервере: разрешить deploy вызывать именно эту команду без пароля. Не весь sudo, только конкретный бинарник с конкретным аргументом.
Хук кладётся в hooks/post-receive внутри bare-репозитория и делается исполняемым: chmod +x hooks/post-receive.
Что проверили перед тем как отдать команде
SSH-ключи деплойного пользователя добавлены в authorized_keys на целевом сервере. Это очевидно, но лучше проверить руками до того как кто-то из разработчиков будет тыкаться в хук и получать невнятные ошибки.
Хук запускается от пользователя git - не от того разработчика который делает push. Поэтому все пути и права должны быть выставлены для git-пользователя, а не для конкретного человека. Минут двадцать ушло прежде чем дошло.
/tmp/deploy-staging создали заранее и убедились что git-пользователь туда пишет. При первом запуске хука без этой директории ошибка была достаточно криптовой.
Убедились что rsync установлен и на сервере с bare-репозиторием, и на целевом. Звучит банально, но на минимальной VPS это надо проверять.
Что получилось
Разработчик делает git push origin production - через несколько секунд приложение на сервере обновлено, nginx перечитал конфиг. Никакого ручного SSH на сервер, никакого «кто последний раз деплоил». Если push прошёл - деплой случился.
Параллельных пушей в production не бывает по естественным причинам - git принимает push последовательно. Конфликтов хука самого с собой не было ни разу.
Логи деплоя видны прямо в выводе git push - то что хук пишет в stdout, git транслирует обратно в терминал. Просто и удобно.
Где это не работает
Хук не умеет откатываться. Если rsync завершился на полпути - на сервере полуобновлённая версия. Для этого приложения хватает, потому что файлы меняются редко и в основном добавляются. Если бы нужен был атомарный деплой с переключением симлинка - это отдельная история, хук усложнится.
Сборка артефактов тут не предусмотрена. Если нужен composer install или сборка фронтенда - придётся добавлять шаги в хук или делать pre-push хук на стороне разработчика. Оба варианта работают, но добавляют сложность.
Если команда вырастет, если появятся staging-окружение, автотесты, ревью перед деплоем - хук станет мал и Jenkins или что-то похожее придёт своим чередом. Но пока пяти разработчикам и одному серверу этого хватает. Двадцать строк баша против нескольких часов настройки и последующего обслуживания CI-сервера - выбор в данном случае очевиден.