Zero-downtime деплой через CICD Gitlab - mcodex

Zero-downtime деплой через CICD Gitlab

Содержание

Описание задачи и требования к проекту

Проект разрабатыватся на локальной машине, есть 2 сервера, dev и prodcution.
Стэк одинаковый: VPS Ubuntu/Nginx/PHP/MySQL/Laravel/Supervisor

Шаги в инструкции выполняются на обеих серверах. В результате получаем 2 развернутых версии проекта:
на dev-сервере (ветка develop) и production (ветка main).

После применения инструкции все дальнейшие загрузки обновлений происходят без ручного вмешательства на серверах, на dev-сервере — после пуша в ветку develop, на production — после пуша (или PR) в main и клика на кнопке деплоя (опциональный предохранитель).

Перед внедрением CICD, необходимо проверить следующие пункты:

  1. На серверах есть полномочия sudo
  2. В проекте есть тесты
  3. В проекте есть публично доступный роут GET /health/ready, возвращает код 200.
  4. Перед пушем в gitlab прогоняем тесты, линтер, pint.

Шаг 1. Начальная настройка проекта

Разворачиваем проекты на серверах из репозитория Gitlab вручную, без CICD, настраиваем Nginx, подключаем СУБД и т.д. Проверяем, что все работает.
Предположим, проект развернут в папке /var/www/my-project.com/classic-deploy

Сразу решаем, в какой папке будет расположен проект, разворачиваемый через CICD.
Допустим, он будет в папке /var/www/my-project.com/cicd-deploy

Шаг 2. Создаем пользователя для деплоя

2.1 На обеих серверах добавляем пользователя deployer. Через него Gitlab будет делать деплой.

sudo adduser deployer

2.2 Добавляем пользователя deployer в группу www-data

sudo usermod -aG www-data deployer

Проверяем, что добавлен в группу

groups deployer

в списке должна быть www-data

2.3 Добавляем возможность запуска от имени deployer определенных команд без пароля.
Для этого создаем файл конфига:

sudo visudo -f /etc/sudoers.d/deploy

Добавляем в него строку (версию PHP при необходимости изменить):

deployer ALL=(ALL) NOPASSWD: /usr/bin/setfacl, /usr/bin/supervisorctl, /usr/bin/systemctl reload php8.5-fpm

Перед добавлением можно проверить, где находятся файлы

which supervisorctl
which systemctl
which setfacl

Проверяем валидность конфига файлов sudoers

visudo -c

Ожидаемый вывод

/etc/sudoers: parsed OK
/etc/sudoers.d/README: parsed OK
/etc/sudoers.d/deploy: parsed OK

Шаг 3. Настраиваем SSH-ключи

3.1 Создаем ключ

Логинимся на сервер как пользователь deployer

ssh deployer@ip-address-here

Создаем новый ключ, без ключевой фразы, для дев-сервера используем префикс dev

ssh-keygen -t ed25519 -C "gitlab-deploy-prod"

Появляется папка /home/deployer/.ssh, в ней файлы ключа id_ed25519.pub и id_ed25519.
Имена файлов ключей лучше использовать дефолтные, иначе може возникнуть ошибка при подключении к Gitlab.

Задаем права на ключи

chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub
chmod 600 ~/.ssh/known_hosts

3.2 Добавляем публичный ключ в authorized_keys

Сначала проверяем, какой файл SSH-сервер реально читает для пользователя deployer

sudo sshd -T -C user=deployer | grep -Ei 'authorizedkeysfile|authorizedkeyscommand|pubkeyauth|allowusers|allowgroups|denyusers|denygroups'

Получаем вывод

pubkeyauthentication yes
authorizedkeyscommand none
authorizedkeyscommanduser none
authorizedkeysfile .ssh/authorized_keys_123
pubkeyauthoptions none

Смотрим значение authorizedkeysfile. Это файл в папке /home/deployer/.ssh
Может быть как дефолтным authorized_keys, так и переопределенным, как в примере выше

Добавляем ключ deployer’a:

cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys_123

Содержимое файла публичного ключа добавляем в ключи в репозитории проекта на Gitlab
Settings->Reposirory->Deploy keys->Add new key

В названии ключа указываем сервер и пользователя, что бы не путаться, например, «Production deployer»

После добавления найти ключ в вкладке "Privately accessible deploy keys" и нажать Enable. 
Ключ появится во вкладке "Enabled deploy keys"

Секретный ключ кодируем в base64

base64 -w 0 ~/.ssh/id_ed25519

Полученный вывод пока копируем в текстовый файл, он понадобится в следующем шаге.

Проверяем, что ключ работает. Для этого в произвольной папке клонируем репозиторий.

mkdir ~/test
cd ~/test
git clone git@gitlab.com:your-project.git

После проверки удаляем тестовую папку, закрываем соединение под deployer.

Шаг 4. Задаем переменные Gitlab

В Gitlab, в папке проекта открываем Settings->CICD->Variables
Создаем переменные:
PROD_HOST — IP адрес, например, 123.1.123.12
PROD_BASE — путь к папке cicd-проекта, /var/www/my-project.com/cicd-deploy
PROD_APP_URL — публичный URL проекта, для запуска health check, например, https://my-project.com.
PROD_USER — deployer
PROD_SSH_PRIVATE_KEY_B64 — приватный ключ в формате base64 из предыдущего шага
Тип переменной — variable (default), visibility: visible, protect variable — выключено
Для дев-севера аналогично, но с префиксом DEV_

В результате в репозитории есть набор переменных

переменные Gitlab

Результат на данном этапе:
Проект работает по прежнему из папки classic-deploy, на сервере добавлен пользователь deployer.

Шаг 5. Создаем структуру проекта для CICD

Допустим, проект, развернутый через CICD будет расположен в папке /var/www/my-project.com/cicd-deploy

В этом случае структура downtime-zero проекта будет следующей

/var/www/my-porject.com/cicd-deploy/
  ├── current -> releases/20260501-1430-abc1234симлинк на активный релиз
  ├── releases/
  │ ├── 20260430-1731-2b5cc17/
  │ ├── 20260501-0900-69025b4/
  │ └── 20260501-1430-abc1234/текущий
  └── shared/
    ├── .env
    └── storage/

5.1 Делаем первый деплой вручную.

Логинимся под root, создаем папку ~/deploy, в нее кладем файл first_deploy.sh

Заменить:
BASE — путь к новой папке проекта
OLD_PATH — текущий проект
REPO — репозиторий Gitlab

#!/usr/bin/env bash
umask 0002
set -euo pipefail

BASE=/var/www/my-project.com/cicd-deploy
OLD_PATH=/var/www/my-porject.com/classic-deploy
SHARED=$BASE/shared
TS=$(date +%Y%m%d-%H%M)
REL=$BASE/releases/${TS}-manual
REPO=git@gitlab.com:your-project-repo.git

mkdir -p $BASE/releases
mkdir -p $BASE/shared/storage/{app/public,framework/cache/data,framework/sessions,framework/testing,framework/views,logs}
sudo chown -R deployer:www-data $BASE
sudo find $BASE -type d -exec chmod 2775 {} \;
sudo find $BASE -type f -exec chmod 0664 {} \;

cp -a $OLD_PATH/.env $BASE/shared/.env
cp -a $OLD_PATH/storage/. $BASE/shared/storage/
sudo chown -R deployer:www-data $BASE/shared
sudo find $BASE/shared -type d -exec chmod 2775 {} \;
sudo find $BASE/shared -type f -exec chmod 0664 {} \;
sudo chmod 0640 $BASE/shared/.env

# Default ACL, чтобы файлы/директории, создаваемые php-fpm (www-data) уже после
# деплоя (sources/*, логи и т.п.), тоже оставались доступны deployer и наоборот —
# setgid/umask этого не гарантируют для новых записей.
sudo setfacl -R -m u:deployer:rwx,g:www-data:rwx,o::--- "$BASE/shared/storage"
sudo setfacl -R -d -m u:deployer:rwx,g:www-data:rwx,o::--- "$BASE/shared/storage"

sudo -u deployer git clone --depth 1 --branch develop "$REPO" "$REL"

sudo -u deployer ln -sfn "$SHARED/.env" "$REL/.env"
sudo -u deployer rm -rf "$REL/storage"
sudo -u deployer ln -sfn "$SHARED/storage" "$REL/storage"

# на проде добавить --no-dev
sudo -u deployer bash -c "cd '$REL' && composer install --optimize-autoloader --no-interaction"
sudo -u deployer php "$REL/artisan" storage:link
sudo -u deployer php "$REL/artisan" config:cache
sudo -u deployer php "$REL/artisan" route:cache
sudo -u deployer php "$REL/artisan" view:cache
sudo -u deployer php "$REL/artisan" event:cache
sudo -u deployer php "$REL/artisan" migrate --force

sudo rm -rf "$BASE/current.new"
sudo -u deployer ln -sfn "$REL" "$BASE/current.new"
sudo -u deployer mv -Tf "$BASE/current.new" "$BASE/current"

Что делает скрипт:

  • Создает структуру проекта
  • Копирует shared-файлы из текущего проекта
  • Создает первый релиз с префиксом manual
  • Устанавливает права

Перед запуском проверяем текущую версию CLI PHP, она должна соответствовать требованиям пакетов в composer.lock, иначе возникнет ошибка при установке пакетов.

php -v

При необходимости, переключаем на нужную версию:

sudo update-alternatives --set php /usr/bin/php8.5

Запускаем скрипт

cd ~/deploy
bash first_deploy.sh

В результате в папке /var/www/my-project.com/cicd-deploy создается структура ciccd-проекта, но он пока не работает, так как Nginx отправляет запросы на старый проект.

Шаг 6. Переключаем Nginx на новый проект

6.1 В файле конфигурации Nginx меняем путь для root на
/var/www/my-project.com/cicd-deploy/current/public

Перезагружаем Nginx

nginx -t
systemctl reload nginx 

6.2 Меняем конфигурацию supervisor

nano /etc/supervisor/conf.d/horizon.conf

## меняем пути  
command=php /var/www/my-project.com/cicd-deploy/current/artisan horizon
directory=/var/www/my-project.com/cicd-deploy/current
stdout_logfile=/var/www/my-project.com/cicd-deploy/shared/storage/logs/horizon.log

supervisorctl reread && supervisorctl update

6.3 Важно: не забыть проверить и заменить пути в заданиях cron, пути (если есть) в файле .env, в файле конфигураций.

Теперь проект работает из новой папки, релиз -manual.

Проверяем, что все работает, адрес /health/ready возвращает JSON с 200

Шаг 7. Делаем первый деплой через Gitlab CICD Pipeline.

7.1 На локальном сервере добавляем в корневую проекта файл пайплайна .gitlab-ci.yaml

stages:
  - test
  - deploy

# ──────────────── ТЕСТЫ ────────────────
test:
  stage: test
  image: php:8.5-cli
  cache:
    key:
      files: [composer.lock]
    paths: [vendor/]
  variables:
    DB_CONNECTION: sqlite
    DB_DATABASE: ":memory:"
  before_script:
    - apt-get update -qq && apt-get install -y -qq git unzip libicu-dev libzip-dev libsqlite3-dev libpng-dev libjpeg-dev libfreetype6-dev libwebp-dev
    - docker-php-ext-configure gd --with-jpeg --with-freetype --with-webp
    - docker-php-ext-install intl pcntl zip pdo_sqlite gd
    - curl -sS https://getcomposer.org/installer | php
    - php composer.phar install --prefer-dist --no-ansi --no-progress
    - cp .env.example .env
    - php artisan key:generate
  script:
    - php artisan test --compact
  rules:
    - if: $CI_COMMIT_BRANCH == "main" # запускаем тест при пуше в main
    - if: $CI_COMMIT_BRANCH == "develop" # запускаем тест при пуше в develop
    - if: $CI_PIPELINE_SOURCE == "merge_request_event" # запускаем тест при PR
# тесты не запускаются при пуше в feature-ветки

# ──────────────── ДЕПЛОЙ НА DEV ────────────────
deploy:dev:
  stage: deploy
  needs: [test]
  image: alpine:latest
  variables:
    KEEP_RELEASES: "5"
    HEALTH_URL: "${DEV_APP_URL}/health/ready"
  script:
    - apk add --no-cache openssh-client curl
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - echo "$DEV_SSH_PRIVATE_KEY_B64" | base64 -d > ~/.ssh/id_deploy
    - chmod 600 ~/.ssh/id_deploy
    - ssh-keyscan -H "$DEV_HOST" >> ~/.ssh/known_hosts
    - |
      ssh -i ~/.ssh/id_deploy "$DEV_USER@$DEV_HOST" \
        "BASE='$DEV_BASE' SHA='$CI_COMMIT_SHORT_SHA' FULL_SHA='$CI_COMMIT_SHA' REPO='$CI_REPOSITORY_URL' KEEP_RELEASES='$KEEP_RELEASES' bash -se" <<'EOSSH'
        set -euo pipefail
        umask 0002
        TS=$(date +%Y%m%d-%H%M)
        REL="$BASE/releases/${TS}-${SHA}"
        SHARED="$BASE/shared"

        git config --global --add safe.directory "$REL"

        # 1. Клонируем релиз (shallow) от develop
        git clone --depth 1 --branch develop "$REPO" "$REL"
        cd "$REL"
        echo "$FULL_SHA" > RELEASE

        # 2. Гарантируем структуру shared (на случай первого деплоя/чистого shared)
        mkdir -p "$SHARED/storage/app/public" \
                 "$SHARED/storage/framework/cache/data" \
                 "$SHARED/storage/framework/sessions" \
                 "$SHARED/storage/framework/testing" \
                 "$SHARED/storage/framework/views" \
                 "$SHARED/storage/logs"

        # 2.1 Default ACL на shared/storage: файлы/директории, создаваемые php-fpm
        # (www-data) во время работы приложения (sources/*, логи и т.п.), должны
        # оставаться доступны deployer и наоборот — setgid/umask этого не
        # гарантируют для новых записей, поэтому фиксируем через ACL при каждом деплое.
        sudo setfacl -R -m u:deployer:rwx,g:www-data:rwx,o::--- "$SHARED/storage"
        sudo setfacl -R -d -m u:deployer:rwx,g:www-data:rwx,o::--- "$SHARED/storage"

        # 3. Линкуем shared
        ln -sfn "$SHARED/.env" .env
        rm -rf storage && ln -sfn "$SHARED/storage" storage

        # 3. Установка зависимостей (на новом релизе, не трогая current)
        composer install --optimize-autoloader --no-interaction --no-progress

        # 4. Symlink public/storage -> storage/app/public
        php artisan storage:link

        # 5. Кеши конфигов/роутов/вью — в новом релизе
        php artisan config:cache
        php artisan route:cache
        php artisan view:cache
        php artisan event:cache

        # 5.1 Генерация Swagger-документации (актуализируем api-docs.json в shared/storage)
        php artisan l5-swagger:generate

        # 5. Миграции (один раз на релиз; делаем ДО переключения)
        php artisan migrate --force

        # 6. Атомарное переключение symlink current
        ln -sfn "$REL" "$BASE/current.new"
        mv -Tf "$BASE/current.new" "$BASE/current"

        # 7. Перезапуск Horizon (graceful) — supervisor поднимет его на новом релизе
        php artisan horizon:terminate

        # 8. Reload php-fpm для сброса opcache (graceful, без downtime)
        sudo systemctl reload php8.5-fpm

        # 9. Чистим старые релизы (всего храним KEEP_RELEASES, включая current)
        cd "$BASE/releases" || exit 1

        KEEP_ARGS=(-e "$(basename "$REL")")
        if [ -L "$BASE/current" ]; then
          KEEP_ARGS+=(-e "$(basename "$(readlink -f "$BASE/current")")")
        fi

        ls -1dt -- */ \
          | sed 's:/$::' \
          | tail -n +"$((KEEP_RELEASES + 1))" \
          | { grep -vx "${KEEP_ARGS[@]}" || true; } \
          | xargs -r rm -rf
      EOSSH
    # 10. Health check после деплоя
    - |
      echo "Health check: $HEALTH_URL"
      for i in 1 2 3 4 5; do
        code=$(curl -s -o /tmp/health.out -w "%{http_code}" "$HEALTH_URL" || echo "000")
        echo "attempt $i: HTTP $code"
        if [ "$code" = "200" ]; then
          cat /tmp/health.out; echo
          exit 0
        fi
        sleep 3
      done
      echo "Health check failed"; cat /tmp/health.out 2>/dev/null || true
      exit 1
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"

# ──────────────── ДЕПЛОЙ НА PROD ────────────────
deploy:prod:
  stage: deploy
  when: manual
  needs: [test]
  image: alpine:latest
  variables:
    KEEP_RELEASES: "5"
    HEALTH_URL: "${PROD_APP_URL}/health/ready"
  script:
    - apk add --no-cache openssh-client curl
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - echo "$PROD_SSH_PRIVATE_KEY_B64" | base64 -d > ~/.ssh/id_deploy
    - chmod 600 ~/.ssh/id_deploy
    - ssh-keyscan -H "$PROD_HOST" >> ~/.ssh/known_hosts
    - |
      ssh -i ~/.ssh/id_deploy "$PROD_USER@$PROD_HOST" \
        "BASE='$PROD_BASE' SHA='$CI_COMMIT_SHORT_SHA' FULL_SHA='$CI_COMMIT_SHA' REPO='$CI_REPOSITORY_URL' KEEP_RELEASES='$KEEP_RELEASES' bash -se" <<'EOSSH'
        set -euo pipefail
        umask 0002
        TS=$(date +%Y%m%d-%H%M)
        REL="$BASE/releases/${TS}-${SHA}"
        SHARED="$BASE/shared"

        git config --global --add safe.directory "$REL"

        # 1. Клонируем релиз (shallow) от main
        git clone --depth 1 --branch main "$REPO" "$REL"
        cd "$REL"
        echo "$FULL_SHA" > RELEASE

        # 2. Гарантируем структуру shared (на случай первого деплоя/чистого shared)
        mkdir -p "$SHARED/storage/app/public" \
                 "$SHARED/storage/framework/cache/data" \
                 "$SHARED/storage/framework/sessions" \
                 "$SHARED/storage/framework/testing" \
                 "$SHARED/storage/framework/views" \
                 "$SHARED/storage/logs"

        # 2.1 Default ACL на shared/storage: файлы/директории, создаваемые php-fpm
        # (www-data) во время работы приложения (sources/*, логи и т.п.), должны
        # оставаться доступны deployer и наоборот — setgid/umask этого не
        # гарантируют для новых записей, поэтому фиксируем через ACL при каждом деплое.
        sudo setfacl -R -m u:deployer:rwx,g:www-data:rwx,o::--- "$SHARED/storage"
        sudo setfacl -R -d -m u:deployer:rwx,g:www-data:rwx,o::--- "$SHARED/storage"

        # 3. Линкуем shared
        ln -sfn "$SHARED/.env" .env
        rm -rf storage && ln -sfn "$SHARED/storage" storage

        # 3. Установка зависимостей (на новом релизе, не трогая current)
        composer install --no-dev --optimize-autoloader --no-interaction --no-progress

        # 4. Symlink public/storage -> storage/app/public
        php artisan storage:link

        # 5. Кеши конфигов/роутов/вью — в новом релизе
        php artisan config:cache
        php artisan route:cache
        php artisan view:cache
        php artisan event:cache

        # 5.1 Генерация Swagger-документации (актуализируем api-docs.json в shared/storage)
        php artisan l5-swagger:generate

        # 5. Миграции (один раз на релиз; делаем ДО переключения)
        php artisan migrate --force

        # 6. Атомарное переключение symlink current
        ln -sfn "$REL" "$BASE/current.new"
        mv -Tf "$BASE/current.new" "$BASE/current"

        # 7. Перезапуск Horizon (graceful) — supervisor поднимет его на новом релизе
        php artisan horizon:terminate

        # 8. Reload php-fpm для сброса opcache (graceful, без downtime)
        sudo systemctl reload php8.5-fpm

        # 9. Чистим старые релизы (всего храним KEEP_RELEASES, включая current)
        cd "$BASE/releases" || exit 1

        KEEP_ARGS=(-e "$(basename "$REL")")
        if [ -L "$BASE/current" ]; then
          KEEP_ARGS+=(-e "$(basename "$(readlink -f "$BASE/current")")")
        fi

        ls -1dt -- */ \
          | sed 's:/$::' \
          | tail -n +"$((KEEP_RELEASES + 1))" \
          | { grep -vx "${KEEP_ARGS[@]}" || true; } \
          | xargs -r rm -rf
      EOSSH
    # 10. Health check после деплоя
    - |
      echo "Health check: $HEALTH_URL"
      for i in 1 2 3 4 5; do
        code=$(curl -s -o /tmp/health.out -w "%{http_code}" "$HEALTH_URL" || echo "000")
        echo "attempt $i: HTTP $code"
        if [ "$code" = "200" ]; then
          cat /tmp/health.out; echo
          exit 0
        fi
        sleep 3
      done
      echo "Health check failed"; cat /tmp/health.out 2>/dev/null || true
      exit 1
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Необходимо убедится, что адрес /health/ready доступен, возвращает JSON с 200.

7.2 Тестируем пайплайн

Сначала проверяем дев-сервер.

На локальной машине в ветке develop делаем правки, коммитим и пушим в репозиторий Gitlab, в удаленную ветку develop.
Gitlab запускает пайплайн для develop, посмотреть можно в резделе Project->Build->Pipelines.
Сначала выполняется задача с тестами, затем задача с деплоем на дев-сервер. Если все успешно — появляется зеленая метка Passed. Проверяем на дев-сервере, что обновление загрузилось.

Если какая-либо задача в пайплайне завершилась с ошибкой — появится метка Failed, нажав на нее можно увидеть какая именно задача зафейлилась, нажав на задачу можно посмотреть лог, в чем именно ошибка. Там же можно перезапустить задачу, если исправление ошибки не требует нового коммита.

Теперь проверяем деплой на production.

Текущее состояние production перед тестом:

  • две папки проектов: /classic-deploy и /cicd-deploy
  • проект работает из папки /cicd-deploy, current указывает на релиз ‘-manual’

Для запуска пайплайна в удаленную ветку main необходимо загрузить коммит слияния с веткой develop.

Есть 2 варианта, можно выбрать любой:
a. Смерджить в локальном репозитории develop в main и затем запушить main в удаленный.
b. Смерджить ветку develop в main в самом Gitlab.

Выбираем вариант b.
Переходим Project->Code->Merge request, нажимаем Create merge request
Появляется надпись Ready to merge, перед слиянием запускается задача пайплайна с тестом.

Если тесты проходят, ветки сливаются, ‘Ready to merge’ меняется на ‘mered by … ‘ и запускается заново пайплайн.
Снова проходит задача с тестом, а задача деплоя на прод, в отличие от задачи деплоя на dev-сервер, не запускается, а пропускается, становится статус ‘passed’. Это защита от случайного пуша в main, в пайплайне в задаче деплоя на прод стоит опция when:manual, то есть нужен ручной запуск. Заходим в задачу и нажимаем кнопку Run.

После завершения задач на проде появляется новый релиз, current переключен на него.

Проверяем, что проект работает, после этого папку со старым вариантом деплоя (classic-deploy) можно удалить.

Шаг 8. Проверяем откат

Что бы вернутся на предыдущий релиз, необходимо переключить ссылку current на папку предыдущего релиза.

Для этого можно использовать следующий скрипт,

#!/bin/bash
set -euo pipefail
BASE=//var/www/my-project.com/cicd-deploy

cd "$BASE/releases"

PREV=$(ls -1t | sed -n '2p')   # второй сверху = предыдущий
[ -z "$PREV" ] && { echo "No previous release"; exit 1; }

echo "Rolling back to: $PREV"
ln -sfn "$BASE/releases/$PREV" "$BASE/current.new"
mv -Tf "$BASE/current.new" "$BASE/current"

sudo supervisorctl signal SIGTERM horizon:* || true
sudo systemctl reload php8.5-fpm

echo "Done."

Файл скрипта rollback.sh можно положить в папку ~/home/deployer/deploy_scripts/my-project/ и запускать командой

bash rollback.sh

Данный скрипт не выполняет откаты миграции, поэтому, при необходимости их нужно выполнить вручную, в папке последнего релиза.