Деплой проекта через CICD Gitlab - mcodex

Деплой проекта через CICD Gitlab

Исходные данные:
VPS Ubuntu 24/Nginx/PHP8.5/MySQL/Laravel/Supervisor, 2 сервера: dev и production

Шаг 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

Добавляем в него строку:

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

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

which supervisorctl
which systemctl

Шаг 3. Настройка SSH-ключей

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

ssh deployer@ip-address-here

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

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

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

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

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

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

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

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

base64 -w 0 ~/.ssh/id_ed25519

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

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

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

Если подключение и клонирование прошло успешно, тестовую папку можно удалить.

Шаг 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_

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

Результат на данном этапе:
Проект работает по прежнему из папки 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 — текущий проект

#!/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

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"

# на production-сервере добавить опцию --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
  • Устанавливает права

Шаг 6. Переключение проекта на новую папку

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
    - docker-php-ext-install intl pcntl zip pdo_sqlite
    - 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"

        # 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. Миграции (один раз на релиз; делаем ДО переключения)
        php artisan migrate --force

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

        # 7. Перезапуск Horizon (graceful)
        sudo supervisorctl signal SIGTERM horizon:* || php artisan horizon:terminate

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

        # 9. Чистим старые релизы
        cd "$BASE/releases" && ls -1t | tail -n +$((KEEP_RELEASES+1)) | 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"

        # 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. Миграции (один раз на релиз; делаем ДО переключения)
        php artisan migrate --force

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

        # 7. Перезапуск Horizon (graceful)
        sudo supervisorctl signal SIGTERM horizon:* || php artisan horizon:terminate

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

        # 9. Чистим старые релизы
        cd "$BASE/releases" && ls -1t | tail -n +$((KEEP_RELEASES+1)) | 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

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