ADG Оставить заявку
Блог DevOps 4 мин чтения

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-репозиторием выглядит так:

  1. На целевом сервере создаём bare-репозиторий - это просто хранилище объектов git без рабочей копии.
  2. Туда же кладём хук post-receive.
  3. Разработчики добавляют этот bare-репозиторий как remote и пушат в него.
  4. Хук срабатывает автоматически после каждого успешного 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-сервера - выбор в данном случае очевиден.

Контакт

Нужна такая же инженерная работа?

Опишите задачу и контекст. Ответим в течение рабочего дня, при необходимости подпишем NDA.