Из-за прекращения поддержи Bitbucket Setver пришлось переехать на GitLab.
В Bitbucket Server не было встроенного CI/CD, поэтому использовали Teamcity. Из-за проблемы интеграции Teamcity с GitLab, мы попробовали GitLab Pipline. И остались довольны.
Disclamer: У меня не так много опыта в CI/CD, так что статья скорее для новичков. Буду рад услышать конструктивную критику с предложениями по оптимизации скрипта сборки :)
Коротко о Gitlab Pipline
Если говорить простыми словами: сборка делится на задачи. Какие-то задачи выполняются последовательно и передают результаты следующим задачам, какие-то задачи распаралеливаются: например можно одновременно деплоить на сервер и в nexus.
Не обязательно один раннер отвечает за все задачи в сборке. Если на проекте два раннера, то первую задачу может взять один ранер, после ее выполнения вторую задачу возьмет другой раннер.
Раннеры бывают разных типов. Мы рассмотрим executor docker. Для каждой задачи создается новый чистый контейнер. Но между контейнерами можно передавать промежуточные результаты — это называется кэширование.
Кэширование и его особенности
Механизм кэширования разрабатывал какой-то одаренный человек. Поэтому сразу разобраться, как оно работает, будет не просто. В отдельной статье можно прочитать о кэшировании подробнее.
Каждый раннер хранит кэш в папке /cache
. Для каждого проекта в этой папке создается еще папка. Сам кэш хранится в виде zip архива.
Из-за наличия у каждого раннера своей папки для кэша, возникает проблема. Если один раннер кэшировал папку в первой задаче, а второй раннер взялся за выполнение второй задачи, то у него не окажется кэша из первой задачи. Чуть далее рассмотрим как это исправить.
Можно определить только один набор папок для кэша. Также только для всего набора можно выбрать политику кэширования. Например если вы уверены, что задача не изменит кэш, то можно его не загружать. Но нельзя запретить загружать например две папки из четырех.
Нет возможности установить глобальный кэш и добавить к нему дополнительные папки для конкретных задач.
Так же стоит знать, что кэш не удаляется после окончания сборки. При следующей такой же сборке старый кэш будет доступен.
Эти особенности усложняют создание инструкций для CI CD.
Артефакты
Помимо кэша между сборками можно передавать артефакты.
Артефакт — это файлы, которые считаются законченным продуктом сборки. Например .jar файлы приложения.
В отличие от кэша, артефакт передается между раннерами. Из-за этого многие злоупотребляют использованием артефактов там, где стоит использовать кэш.
Следуйте следующим правилам:
- Если текущее задание может выполняться самостоятельно, но в присутствии контента работа будет идти быстрее, используйте кэш.
- Если текущее задание зависит от результатов предыдущего, то есть не может выполняться само по себе, используйте артефакты и зависимости.
Установка 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!
Тут все просто:
- Адрес вашего gitlab.
- Токен авторизации. Посмотреть его можно в настройках гурппы/проекта в разделе CI/CD Runners.
- Название раннера.
- Теги ранера, можно пропустить нажав Enter.
- Исполнитель сборки. Вводим docker.
- Образ, который будет использоваться по умолчанию, если не установлен другой.
После этого в настройках проекта можно посмотреть доступные раннеры.
После регистрации, в папке /home/user/runner_name
появится файл с настройками конфигурации config.toml
. Нам нужно добавить docker volume для кэширования промежуточных результатов.
volumes = ["gitlab-runner-builds:/builds", "gitlab-runner-cache:/cache"]
Проблема кэширования.
В начале статьи я рассказал о проблеме кеширования. Ее можно решить с помощью монтирования одного volume к разным раннерам. То есть во втором своем раннере так же укажите volumes = ["gitlab-runner-cache:/cache"]
. Таким образом разные раннеры будут иметь единый кэш.
В итоге файл конфигурации выглядит так:
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
Что хотим получить от CI CD?
У нас на проекте было 3 контура:
- dev-сервер, на него все попадает сразу после MR;
- пре-прод-сервер, на него все попадает перед попаданием на прод, там проходит полное регресс тестирование;
- прод-сервер, собственно сама прод среда.
Что нам было необходимо от нашего CI/CD:
- Запуск unit-тестов для всех MergeRequest
- При мерже в
dev
ветку повторный запуск тестов и автоматический деплой на dev-сервер. - Автоматическая сборка, тестирвоание и деплой веток формата
release/*
на пре-прод-сервер. - При мерже в
master
ничего не происходит. Релиз собирается при обнаружении тега форматаrelease-*
. Одновременно с деплоем на прод-сервер будет происходить загрузка в корпоративный nexus. - Бонусом настроим уведомления о статусе деплоя в Telegram.
Настройка GitLab CI для Maven
Что делать если у вас Gradle? Загляните в оригинал статьи, там я рассказываю про настройку и для Gradle. Она не сильно отличается.
Создайте в корне проекта файл .gitlab-ci.yml
.
Настройку буду объяснять блоками, в конце прикреплю единый файл.
Вы можете указать нужный образ, вместо дефолтного образа у раннера.
image: maven:latest
Устанавливаем место хранения папки .m2
через переменные среды variables
. Это понадобится, когда будем настраивать кэширование.
// ... ... ... ... ...
variables:
MAVEN_OPTS: "-Dmaven.repo.local=./.m2/repository"
// ... ... ... ... ...
Далее указываются этапы сборки. Они позволяют группировать задачи для одновременного выполнения.
// ... ... ... ... ...
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 команды.
Переменная GitLab CI/CD $MAVEN_SETTINGS
необходима для передачи файла settings.xml
, если вы используете нестандартные настройки, например корпоративные репозитории. Переменная создается в настройках CI/CD для группы/проекта. Тип переменной File.
Раздел only
указывает для каких веток и тегов выполнять задачу. Чтобы не собирать каждую запушенную ветку устанавливаем: dev
, merge_requests
и ветки формата /release/*
.
Раздел only
не разделяет ветка это или тег. Поэтому мы указываем параметр except
, который исключает теги. Из-за этого поведения за сборку на прод отвечают отдельные задачи. В противном случае, если бы кто-то создал тег формата release/*
, то он бы запустил сборку.
Для защиты от случайного деплоя в прод джуном, рекомендую установить защиту на ветки dev
, master
, /release/*
, а так же на теги release-*
. Делается это в настройках проекта GitLab.
Раздел cache
отвечает за кэширование. Чтобы каждый раз не выкачивать зависимости добавляем в кэш папку ./target
и ./.m2
.
Запуск unit тестов — test
Создаем задачу для запука тестирования.
// ... ... ... ... ...
test:
stage: test
only:
- dev
- merge_requests
- /^release\/.*$/
except:
- tags
script:
- 'mvn --settings $MAVEN_SETTINGS test'
cache:
paths:
- ./target
- ./.m2
// ... ... ... ... ...
Ничем не отличается от стадии сборки, только команда мавена test
. Кэш необходимо указать и в этой задаче, чтобы получить его и отправить дальше.
Упаковка — 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
// ... ... ... ... ...
Обратите внимание на policy: pull
и artifacts
. В следующих задачах не нужны исходники и зависимости, так что policy: pull
отключает кэширование. Для сохранения результатов сборки используем artifacts
, который сохраняет .jar
файлы и передает их следующим задачам.
Деплой — deploy
Теперь осталось задеплоить артефакт на dev-сервер с помощью ssh и scp.
// ... ... ... ... ...
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"
- sh ci-notify.sh
// ... ... ... ... ...
Не забудьте создать переменные в GitLab CI CD:
$DEV_USER
— пользователь системы, от чьего имени будет происходить деплой.$DEV_HOST
— ip адрес сервера.$DEV_APP_PATH
— путь до папки приложения.$SSH_PRIVATE_KEY
— приватный ключ.
Раздел before_script
отвечает за выполнение настроек перед основным скриптом. Мы проверяем наличие ssh-agent, устанавливаем его при отсутствии. После чего добавляем приватный ключ, устанавливаем правильные права на папки.
В разделе script
происходит деплой на сервер:
- Проверяем наличие старого jar и переименовываем его.
$CI_PIPELINE_ID
— это глобальный номер сборки Pipeline. - Копируем новый jar на сервер.
- Останавливаем и запускаем службу, отвечающую за приложение.
- Отправляем уведомление об успехе в телеграм. Об этом ниже.
Как создавать службы linux для Spring Boot приложения, я написал в отдельной статье.
На пре-прод делаем по аналогии, только меняем переменные на $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"
- sh ci-notify.sh
// ... ... ... ... ...
Настройка деплоя на прод
Для прод-деплоя можно объединить сборку, тестирование и упаковку в одну задачу.
// ... ... ... ... ...
package_prod:
stage: package
only:
- /^release-.*$/
except:
- branches
script:
- 'mvn --settings $MAVEN_SETTINGS package'
artifacts:
paths:
- target/*.jar
// ... ... ... ... ...
Мы защищаемся от срабатывания на ветки формата release-*
, нам нужно срабатывание только по тегу.
Деплой аналогичен пре-проду, изменяется только only
и переменные.
Дополнительно появляется задача с отправкой артефакта в корпоративный 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'
// ... ... ... ... ...
Итоговый .gitlab-ci.yml
:
image: maven:latest
variables:
MAVEN_OPTS: "-Dmaven.repo.local=./.m2/repository"
stages:
- build
- test
- package
- deploy
- notify
build:
stage: build
only:
- dev
- merge_requests
- /^release\/.*$/
except:
- tags
script:
- 'mvn --settings $MAVEN_SETTINGS compile'
cache:
paths:
- ./target
- ./.m2
test:
stage: test
only:
- dev
- merge_requests
- /^release\/.*$/
except:
- tags
script:
- 'mvn --settings $MAVEN_SETTINGS test'
cache:
paths:
- ./target
- ./.m2
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
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"
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@$DEV_HOST:$DEV_APP_PATH/
- ssh $PRE_PROD_USER@$PRE_PROD_HOST "sudo systemctl stop app_name_service && sudo systemctl start app_name_service"
package_prod:
stage: package
only:
- /^release-.*$/
except:
- branches
script:
- 'mvn --settings $MAVEN_SETTINGS package'
artifacts:
paths:
- target/*.jar
deploy_prod:
stage: deploy
only:
- /^release-.*$/
except:
- branches
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 $PROD_HOST >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- ssh $PROD_USER@$PROD_HOST "[ ! -f $PROD_APP_PATH/app_name.jar ] || mv $PROD_APP_PATH/app_name.jar $PROD_APP_PATH/app_name-build-$CI_PIPELINE_ID.jar"
- scp target/app_name.jar $PROD_USER@$PROD_HOST:$PROD_APP_PATH/
- ssh $PROD_USER@$PROD_HOST "sudo systemctl stop app_name_service && sudo systemctl start app_name_service"
Бонус: уведомления о деплое в Telegram
Полезная фишка для уведомления менеджеров. После сборки в телеграм отправляется статус сборки, название проекта и ветка сборки.
В задачах деплоя последней командой пропишите:
// ... ... ... ... ...
script:
- ...
- sh ci-notify.sh
// ... ... ... ... ...
Сам файл необходимо добавить в корень проекта, рядом с файлом .gitlab-ci.yml
.
Содержимое файла:
#!/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
Скрипт отправляет запрос к API Telegram, через curl. Параметром скрипта передается emoji статуса билда.
Не забудьте добавить новые параметры в CI/CD:
$TELEGRAM_BOT_TOKEN
— токен бота в телеграмм. Получить его можно при создании своего бота.$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
задача отрабатывает только, когда одна из предыдущих завершилась неудачно.
Удобно, что теперь и код и сборка находятся в одном месте. Несмотря на недостатки GitLab CI, пользоваться им в целом удобно.