Настройка GitLab CI

На примере Java приложения рассматриваем сборку и деплой на сервер с помощью GitLab CI.

· 12 минуты на чтение
Настройка GitLab CI

Из-за прекращения поддержки Bitbucket Server нам пришлось перейти на GitLab. Поскольку в Bitbucket Server отсутствовала встроенная система CI/CD, для управления непрерывной интеграцией использовался TeamCity. Однако из-за проблем с интеграцией TeamCity и GitLab мы решили протестировать GitLab Pipeline и остались довольны результатом.

Спонсор поста

Коротко о Gitlab Pipline

GitLab Pipeline — это система автоматизации, которая позволяет разделять процесс сборки на отдельные задачи (например: сборка, тестирование, деплой), чтобы их можно было выполнять последовательно или параллельно.

Последовательные задачи передают свои результаты следующим, а параллельные могут выполняться одновременно. Например, сборка может одновременно деплоить артефакты на сервер и публиковать их в Nexus.

Раннеры и задачи
Так выглядит схема для последовательной сборки

Не все задачи выполняются одним и тем же раннером. Если в проекте доступно несколько раннеров, задачи могут распределяться между ними. Например, первый раннер может выполнить одну задачу, а второй — следующую.

Раннеры различаются типами, в зависимости от того, как они выполняют задачи. Один из наиболее популярных типов — executor docker. Он создает новый чистый контейнер для каждой задачи, что гарантирует изоляцию задач. Промежуточные результаты можно передавать между контейнерами с помощью кэширования, что позволяет избежать повторной сборки уже готовых частей.

Кэширование

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

Каждый раннер хранит кэш в папке /cache, где для каждого проекта создается отдельная директория. Кэш сохраняется в виде zip-архивов, содержащих промежуточные результаты, которые можно повторно использовать между задачами в рамках одной сборки.

Однако кэш имеет локальную природу — каждый раннер использует собственную папку для хранения, что может стать проблемой при использовании нескольких раннеров. Например, если первый раннер закэшировал данные в одной задаче, а вторую задачу взялся выполнять другой раннер, этот кэш недоступен. Позже мы обсудим, как можно решить эту проблему с помощью общего кэша или внешнего хранилища.

Для кэша можно задать одну из следующих политик:

  • pull — загрузка кэша перед выполнением задачи;
  • push — сохранение кэша после завершения задачи;
  • pull-push — загрузка кэша перед выполнением и обновление после завершения.

Политику следует выбирать в зависимости от требований конкретного пайплайна. Например, если задача точно не меняет кэш, его можно только загрузить, не обновляя по завершении задачи.

Также важно учитывать ограничения кэширования:

  • На весь pipeline можно задать только один набор папок для кэша и выбрать для него только одну политику. Например, если вы уверены, что задача не изменит кэш, его можно не загружать заново, но выбор отдельных папок для загрузки ограничен: нельзя, например, загрузить только две папки из четырех.
  • Нет возможности задать глобальный кэш и дополнительно указать для него дополнительные папки для отдельных задач.

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

Артефакты

Кэш предназначен для временного хранения промежуточных данных. Если необходимо сохранить результаты между сборками, лучше использовать артефакты — они дольше хранятся и доступны в других сборках, тогда как кэш может быть перезаписан или удален.

Артефакт — это файлы, считающиеся завершенным продуктом сборки, например, .jar-файлы приложения.

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

Пример настройки артефактов в .gitlab-ci.yml:

artifacts:
  paths:
    - target/*.jar
  expire_in: 1 week

Рекомендации по выбору между кэшем и артефактами:

  1. Если текущее задание может выполняться самостоятельно, но при наличии некоторых данных его выполнение ускорится, используйте кэш.
  2. Если текущее задание зависит от результатов предыдущего, то есть не может выполняться само по себе, используйте артефакты для передачи необходимых файлов и укажите их как зависимости.

Установка Gitlab Runner

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

mkdir ~/runner_name

Сам раннер запускается одной командой. Можно создать любое количество раннеров.

docker run -d --name gitlab-runner-name \
  --restart always \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v /home/user/runner_name:/etc/gitlab-runner:z \
  gitlab/gitlab-runner:latest

После создания раннера его нужно зарегистрировать в GitLab. Регистрация возможна на разных уровнях:

  • Уровень GitLab — для всех проектов,
  • Уровень группы — для всех проектов внутри одной группы,
  • Уровень проекта — только для конкретного проекта.

Заходим в контейнер:

sudo docker exec -ti gitlab-runner-name bash

Регистрация происходит в интерактивном режиме:

gitlab-runner register
Runtime platform                                    arch=amd64 os=linux pid=27 revision=888ff53t version=13.8.0
Running in system-mode.

Enter the GitLab instance URL (for example, https://gitlab.com/):
http://git.company.name/
Enter the registration token:
vuQ6bcjuEPqc8dVRRhgY
Enter a description for the runner:
[c6558hyonbri]: runner_two
Enter tags for the runner (comma-separated):

Registering runner... succeeded                     runner=YJt3v3Qg
Enter an executor: parallels, shell, virtualbox, docker+machine, kubernetes, custom, docker, docker-ssh+machine, docker-ssh, ssh:
docker
Enter the default Docker image (for example, ruby:2.6):
maven:3.3.9-jdk-8
Runner registered successfully. Feel free to start it, but if it's running already the config should be automatically reloaded

В процессе нужно будет ответить на несколько вопросов:

  1. Адрес GitLab;
  2. Токен авторизации — можно найти в настройках группы/проекта в разделе CI/CD Runners;
  3. Описание раннера (например, runner_two);
  4. Теги раннера (можно пропустить, нажав Enter);
  5. Исполнитель сборки — введите docker;
  6. Образ Docker — укажите образ, который будет использоваться по умолчанию (например, maven:3.3.9-jdk-8).

После регистрации в настройках проекта можно увидеть список доступных раннеров.

После регистрации в папке /home/user/runner_name создается файл конфигурации config.toml. Для кэширования промежуточных данных добавим docker volume:

volumes = ["gitlab-runner-builds:/builds", "gitlab-runner-cache:/cache"]
Проблема кэширования

Чтобы несколько раннеров могли совместно использовать единый кэш, добавьте volume volumes = ["gitlab-runner-cache:/cache"] в конфигурацию каждого раннера.

В итоге файл конфигурации config.toml будет выглядеть так:

concurrent = 1
check_interval = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "runner_name"
  url = "gitlab_url"
  token = "token_value"
  executor = "docker"
  [runners.custom_build_dir]
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]
    [runners.cache.azure]
  [runners.docker]
    tls_verify = false
    image = "maven:3.3.9-jdk-8"
    privileged = false
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["gitlab-runner-builds:/builds", "gitlab-runner-cache:/cache"]
    shm_size = 0

После внесения изменений в конфигурацию необходимо перезапустить раннер:

docker restart gitlab-runner-name
Рандомный блок

Что хотим получить?

В проекте у нас есть три окружения:

  • dev-сервер — среда для начальной проверки кода, куда изменения попадают сразу после Merge Request (MR).
  • пре-прод-сервер — среда для полного регрессионного тестирования перед продакшеном.
  • прод-сервер — основная продакшен-среда.

Требования к CI/CD:

  • Запуск unit-тестов для всех Merge Request — для контроля качества кода на начальном этапе.
  • При мерже в ветку dev автоматически повторно запускаются тесты, и после успешного завершения сборки происходит автоматический деплой на dev-сервер.
  • Автоматическая сборка, тестирование и деплой для всех веток формата release/* на пре-прод-сервер. Это обеспечивает проверку стабильности перед продакшеном.
  • При мерже в ветку master деплой не запускается. Релиз на продакшен собирается только при обнаружении тега формата release-*. Вместе с деплоем на прод-сервер происходит загрузка артефактов в корпоративный Nexus для общего доступа.
  • В качестве бонуса настроим отправку уведомлений в Telegram о статусе деплоя, чтобы команда могла отслеживать текущий статус без необходимости заходить в CI/CD.
Спонсор поста 3

🗜 Настройка GitLab CI для Maven

В корне проекта создайте файл .gitlab-ci.yml. В нем будут описаны настройки CI/CD пайплайна, используемого для автоматизации сборки и деплоя.

Настройку буду объяснять частями, в конце прикреплю единый файл.

Вместо дефолтного образа у раннера можно указать нужный образ Maven:

image: maven:latest

Для хранения зависимостей Maven создадим локальный репозиторий .m2 в корне проекта, задав его через переменные среды. Это пригодится при настройке кэширования:

// ... ... ... ... ...

variables:
  MAVEN_OPTS: "-Dmaven.repo.local=./.m2/repository"

// ... ... ... ... ...

Этапы (stages) группируют задачи, определяя их порядок выполнения и обеспечивая параллельное выполнение независимых задач:

// ... ... ... ... ...

stages:
  - build
  - test
  - package
  - deploy
  - notify

// ... ... ... ... ...
  • build – стадия сборки.
  • test – стадия тестирования.
  • package – стадия упаковки артефактов в .jar.
  • deploy – стадия деплоя на сервер.
  • notify – стадия уведомлений в случае неудач.

Далее нужно задать задачи для каждого этапа.

Сборка - Build

Конфигурация для этапа сборки выглядит следующим образом:

// ... ... ... ... ...

build:
  stage: build
  only:
    - dev
    - merge_requests
    - /^release\/.*$/
  except:
    - tags
  script:
    - 'mvn --settings $MAVEN_SETTINGS compile'
  cache:
    paths:
      - ./target
      - ./.m2

// ... ... ... ... ...

Раздел script — выполняет команды Linux, которые используются для сборки. Например, в данной задаче Maven запускается с параметром --settings $MAVEN_SETTINGS.

Переменная $MAVEN_SETTINGS передает GitLab CI файл settings.xml, необходимый для работы с корпоративными репозиториями или нестандартными настройками

Переменная создается в настройках CI/CD группы или проекта как переменная типа File.

Раздел only — определяет, для каких веток или типов задач запускать эту сборку. В данном случае сборка будет выполняться только для веток dev, для Merge Requests, а также для веток формата /release/*.

Поскольку only не различает ветки и теги, добавлен параметр except, исключающий теги из сборки. Это важно для защиты продакшена: если случайно создать тег формата release/, это могло бы инициировать сборку. Поэтому сборка на продакшен выполняется отдельными задачами.

Для защиты от случайных деплоев на продакшен, например, ошибочного деплоя новичком, рекомендуется установить защиту для веток dev, master, /release/*, а также для тегов формата release-*. Эти настройки можно установить в разделе настроек проекта GitLab.

Раздел cache — используется для кэширования зависимостей, чтобы ускорить процесс сборки. Указаны папки ./target и ./.m2 для кэширования, что позволяет повторно использовать скачанные зависимости и скомпилированные файлы между сборками.

Запуск unit тестов – test

Этап тестирования unit-тестов конфигурируется следующим образом:

// ... ... ... ... ...

test:
  stage: test
  only:
    - dev
    - merge_requests
    - /^release\/.*$/
  except:
    - tags
  script:
    - 'mvn --settings $MAVEN_SETTINGS test'
  cache:
    paths:
      - ./target
      - ./.m2

// ... ... ... ... ...

Задача test в этом этапе запускает unit-тесты с помощью команды mvn test. Конфигурация аналогична этапу сборки, с тем отличием, что используется команда test для выполнения тестов

Раздел cache снова включает папки ./target и ./.m2, что позволяет использовать кэшированные зависимости и артефакты из предыдущего этапа. Указание кэша на этом этапе необходимо, чтобы передать кэшированные данные далее в pipeline и избежать повторной загрузки зависимостей.

Упаковка – package

Добавляем задачу для упаковки артефактов с отключенными тестами. Она выполняется только для веток dev и веток формата release/*, так как сборка Merge Request здесь не требуется.

// ... ... ... ... ...

package:
  stage: package
  only:
    - dev
    - /^release\/.*$/
  except:
    - tags
  script:
    - 'mvn --settings $MAVEN_SETTINGS package -Dmaven.test.skip=true'
  artifacts:
    paths:
      - target/*.jar
  cache:
    policy: pull
    paths:
      - ./target
      - ./.m2

// ... ... ... ... ...

В этой задаче выполняется упаковка с помощью команды mvn package -Dmaven.test.skip=true, чтобы создать артефакты без повторного запуска тестов, поскольку тестирование уже было завершено на предыдущем этапе.

Настройки only и except определяют условия выполнения задачи. Указаны только ветки dev и release/*, чтобы исключить сборку для тегов и предотвратить случайный деплой на продакшен.

Кэширование настроено с помощью policy: pull, что позволяет загрузить кэшированные зависимости и артефакты для этой задачи, но не сохранять их для следующей. Этот подход экономит ресурсы, так как на следующих шагах зависимости не понадобятся.

Чтобы сохранить результаты сборки, используется параметр artifacts, который сохраняет .jar-файлы из папки target и передает их в последующие этапы. Это необходимо для деплоя и дальнейшей работы с готовыми артефактами.

Деплой – deploy

Рассмотрим два варианта деплоя: перенос .jar-файла на сервер с помощью SSH и SCP, а также упаковку в Docker.

Вариант 1. Перенос .jar на сервер

Этот способ основан на копировании артефакта на сервер и запуске его в виде Linux-сервиса.

// ... ... ... ... ...

deploy_dev_server:
  stage: deploy
  only:
    - dev
  except:
    - tags
  before_script:
    - which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan $DEV_HOST >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
  script:
    - ssh $DEV_USER@$DEV_HOST "[ ! -f $DEV_APP_PATH/app_name.jar ] || mv $DEV_APP_PATH/app_name.jar $DEV_APP_PATH/app_name-build-$CI_PIPELINE_ID.jar"
    - scp target/app_name.jar $DEV_USER@$DEV_HOST:$DEV_APP_PATH/
    - ssh $DEV_USER@$DEV_HOST "sudo systemctl stop app_name_service && sudo systemctl start app_name_service"

// ... ... ... ... ...

В этом варианте используем переменные среды:

  • $DEV_USER — пользователь, от имени которого выполняется деплой.
  • $DEV_HOST — IP-адрес dev-сервера.
  • $DEV_APP_PATH — путь к директории приложения.
  • $SSH_PRIVATE_KEY — приватный ключ для SSH.

Раздел before_script отвечает за настройки SSH, включая проверку наличия ssh-agent, добавление ключа и настройку директории .ssh для корректного подключения к серверу. Это необходимо, чтобы безопасно подключиться к dev-серверу перед выполнением основного скрипта.

В основном script происходит сам деплой на сервер. Здесь проверяется наличие старой версии .jar-файла, которая, если найдена, переименовывается с добавлением номера сборки $CI_PIPELINE_ID. Затем новый .jar-файл копируется на сервер с помощью SCP, и сервис, управляющий приложением, перезапускается через systemctl.

В разделе script происходит деплой на сервер:

  1. Проверяем наличие старого jar и переименовываем его. $CI_PIPELINE_ID - это глобальный номер сборки Pipeline.
  2. Копируем новый jar на сервер.
  3. Останавливаем и запускаем службу, отвечающую за приложение.
  4. Отправляем уведомление об успехе в телеграм. Об этом ниже.

Для пре-продакшен сервера настройка аналогична, с заменой переменных на $PRE_PROD_*.

// ... ... ... ... ...

deploy_pre_prod:
  stage: deploy
  only:
    - /^release\/.*$/
  except:
    - tags
  before_script:
    - which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan $PRE_PROD_HOST >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
  script:
    - ssh $PRE_PROD_USER@$PRE_PROD_HOST "[ ! -f $PRE_PROD_APP_PATH/app_name.jar ] || mv $PRE_PROD_APP_PATH/app_name.jar $PRE_PROD_APP_PATH/app_name-build-$CI_PIPELINE_ID.jar"
    - scp target/app_name.jar $DEV_USER@$PRE_PROD_HOST:$PRE_PROD_APP_PATH/
    - ssh $PRE_PROD_USER@$PRE_PROD_HOST "sudo systemctl stop app_name_service && sudo systemctl start app_name_service"

// ... ... ... ... ...

Деплой в docker

Предполагается, что у вас уже есть Dockerfile для сборки образа приложения. В этом варианте деплоя добавим стадию save_nexus, чтобы разделить задачи сборки и доставки образа в Nexus и его развертывания на сервере.

Оптимальный Docker Image для Spring Boot
Рассмотрим популярные способы упаковки приложения в контейнер. Напишем свой оптимальный Dockerfile для Spring Boot.

Этап docker_push_dev_nexus собирает и отправляет Docker-образ в Nexus.

docker_push_dev_nexus:
  image: docker:20.10.7
  stage: save_nexus
  only:
    - dev
  except:
    - tags
  before_script:
    - echo "$DOCKER_DEV_PASS" | docker login localhost:8183 --username user --password-stdin
  script:
    - docker build --no-cache -f Dockerfile-dev -t localhost:8183/company/app-name:latest .
    - docker push localhost:8183/company/app-name:latest
    - docker image rm localhost:8183/company/app-name:latest

В before_script происходит авторизация в Nexus с использованием переменной $DOCKER_DEV_PASS. После этого в script образ собирается по Dockerfile, пушится в Nexus и удаляется локально.

На следующем этапе подключаемся к серверу и перезапускаем контейнер.

deploy_dev_server:
  stage: deploy
  only:
    - dev
  except:
    - tags
  before_script:
    - which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan $DEV_HOST >> ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts
  script:
    - ssh $DEV_USER@$DEV_HOST -t 'bash -ci "update-app"'

Этап deploy_dev_server использует before_script для настройки SSH-подключения и добавления ключа доступа. В script выполняется команда подключения к dev-серверу с вызовом команды update-app, которая может быть настроена как alias на сервере для выполнения всех необходимых команд для перезапуска контейнера. Здесь можно указать перезапуск одного контейнера или полное обновление с помощью docker-compose в зависимости от требований.

Настройка деплоя на прод

Для продакшен-деплоя можно объединить этапы сборки, тестирования и упаковки в одну задачу.

Если проект доставляется через Docker, то для продакшен-деплоя можно использовать образ с препрода, добавив ему новый тег — это ускоряет процесс развертывания и защищает от ошибок повторной сборки.

Этап package_prod отвечает за сборку артефактов для продакшена, выполняя все действия, необходимые для создания .jar-файлов.

// ... ... ... ... ...

package_prod:
  stage: package
  only:
    - /^release-.*$/
  except:
    - branches
  script:
    - 'mvn --settings $MAVEN_SETTINGS package'
  artifacts:
    paths:
      - target/*.jar

// ... ... ... ... ...

В задаче выполняется сборка с использованием Maven, а результат — .jar-файлы — сохраняется в artifacts для использования на следующих этапах.

Конфигурация деплоя на продакшен аналогична деплою на пре-прод, но использует переменные, характерные для продакшена. Дополнительно добавлена задача для публикации артефактов в корпоративный Nexus. Обе задачи выполняются параллельно, что ускоряет релизный процесс.

// ... ... ... ... ...

deploy_prod:
  stage: deploy
  only:
    - /^release-.*$/
  except:
    - branches
  ...

deploy_nexus_server:
  stage: deploy
  only:
    - /^release-.*$/
  except:
    - branches
  script:
    - 'mvn --settings $MAVEN_SETTINGS deploy -Dmaven.test.skip=true'

// ... ... ... ... ...

Эта задача запускает команду mvn deploy, отправляя артефакты в Nexus.

🌟 Бонус: уведомления о деплое в Telegram

Настройка отправки уведомлений в Telegram после сборки — удобный способ информировать команду и менеджеров о статусе сборки. После успешного или неудачного завершения деплоя в Telegram отправляется сообщение с информацией о статусе сборки, названии проекта и ветке.

Для отправки уведомлений добавьте в конце скрипта деплоя вызов скрипта ci-notify.sh, который будет отправлять статус:

// ... ... ... ... ...

script:
  - ...
  - sh ci-notify.sh ✅

// ... ... ... ... ...

Создайте файл ci-notify.sh в корне проекта рядом с .gitlab-ci.yml. Этот скрипт отправляет сообщение в Telegram через curl:

#!/bin/bash

TIME="10"
URL="https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage"
TEXT="Deploy status: $1%0A-- -- -- -- --%0ABranch:+$CI_COMMIT_REF_SLUG%0AProject:+$CI_PROJECT_TITLE"

curl -s --max-time $TIME -d "chat_id=$TELEGRAM_CHAT_ID&disable_web_page_preview=1&text=$TEXT" $URL >/dev/null

Этот скрипт отправляет запрос к Telegram API, используя переданный эмодзи для статуса сборки. Например, при успешном деплое в скрипт передается ✅, при ошибке — ❌.

Добавьте в GitLab CI/CD следующие переменные:

  • $TELEGRAM_BOT_TOKEN – токен вашего бота в Telegram, который можно получить при создании бота через BotFather.
  • $TELEGRAM_CHAT_ID – идентификатор чата или беседы, куда будет отправляться сообщение. Узнать идентификатор можно с помощью специального бота.

Для информирования о неудачных сборках можно создать отдельные задачи, которые сработают при падении деплоя:

// ... ... ... ... ...

notify_error:
  stage: notify
  only:
    - dev
    - /^release\/.*$/
  script:
    - sh ci-notify.sh ❌
  when: on_failure

notify_error_release:
  stage: notify
  only:
    - /^release-.*$/
  except:
    - branches
  script:
    - sh ci-notify.sh ❌
  when: on_failure

// ... ... ... ... ...

Эти задачи настроены на срабатывание при неудачных сборках (when: on_failure). notify_error отправляет уведомления для задач деплоя в dev и release-ветках, а notify_error_release — для прод-среды, срабатывая только на тегах формата release-*.

Заключение

Мы рассмотрели основные этапы настройки CI/CD пайплайна в GitLab для автоматизации сборки, тестирования и деплоя приложения, а также отправки уведомлений о статусе процесса в Telegram. Такая конфигурация позволяет повысить стабильность и предсказуемость развертываний, обеспечивая единый и надежный процесс для разных окружений: от dev и пре-прод до продакшена.

GitLab CI/CD предоставляет широкие возможности для настройки пайплайна, гибко адаптируясь под потребности проекта. Мы разобрали, как:

  • выполнять сборку, тестирование и упаковку артефактов,
  • эффективно использовать кэширование и артефакты,
  • конфигурировать деплой на сервера через SSH или Docker,
  • добавлять уведомления о статусе сборки и деплоя для оперативного информирования команды.

Эти шаги помогут создать структурированный и стабильный процесс доставки изменений в продакшен, снижая риски и повышая скорость работы команды.

Struchkov Mark
Struchkov Mark
Задавайте вопросы, если что-то осталось не понятным👇