Настройка GitLab CI CD для Java приложения

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

· 10 минуты на чтение
Настройка GitLab CI CD для Java приложения

Из-за прекращения поддержи Bitbucket Server пришлось переехать на GitLab.

В Bitbucket Server не было встроенного CI/CD, поэтому использовали Teamcity. Из-за проблемы интеграции Teamcity с GitLab, мы попробовали GitLab Pipline. И остались довольны.

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

Коротко о Gitlab Pipline

Если говорить простыми словами: сборка делится на задачи. Какие-то задачи выполняются последовательно и передают результаты следующим задачам, какие-то задачи распаралеливаются: например можно одновременно деплоить на сервер и в nexus.

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

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

Раннеры бывают разных типов. Мы рассмотрим executor docker. Для каждой задачи создается новый чистый контейнер. Но между контейнерами можно передавать промежуточные результаты – это называется кэширование.

Кэширование

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

Каждый раннер хранит кэш в папке /cache. Для каждого проекта в этой папке создается еще папка. Сам кэш хранится в виде zip архива.

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

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

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

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

Эти особенности усложняют создание инструкций для CI CD.

Артефакты

Помимо кэша между сборками можно передавать артефакты.

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

В отличие от кэша, артефакт передается между раннерами. Из-за этого многие злоупотребляют использованием артефактов там, где стоит использовать кэш.

Следуйте следующим правилам:

  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. Название раннера.
  4. Теги ранера, можно пропустить нажав Enter.
  5. Исполнитель сборки. Вводим docker.
  6. Образ, который будет использоваться по умолчанию, если не установлен другой.

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

После регистрации, в папке /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
Рандомный блок

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

У нас на проекте было 3 контура:

  • dev-сервер, на него все попадает сразу после MR;
  • пре-прод-сервер, на него все попадает перед попаданием на прод, там проходит полное регресс тестирование;
  • прод-сервер, собственно сама прод среда.

Что нам было необходимо от нашего CI/CD:

  • Запуск unit-тестов для всех MergeRequest
  • При мерже в dev ветку повторный запуск тестов и автоматический деплой на dev-сервер.
  • Автоматическая сборка, тестирвоание и деплой веток формата release/* на пре-прод-сервер.
  • При мерже в master ничего не происходит. Релиз собирается при обнаружении тега формата release-*. Одновременно с деплоем на прод-сервер будет происходить загрузка в корпоративный nexus.
  • Бонусом настроим уведомления о статусе деплоя в Telegram.

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

Создайте в корне проекта файл .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

Рассмотрим два варианта деплоя. С помощью обычного переноса .jar файла на сервер и упаковка в докер.

Перенос jar на сервер

Осуществим это с помощью стандартных 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"

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

Не забудьте создать переменные в GitLab CI CD:

  • $DEV_USER – пользователь системы, от чьего имени будет происходить деплой.
  • $DEV_HOST – ip адрес сервера.
  • $DEV_APP_PATH – путь до папки приложения.
  • $SSH_PRIVATE_KEY – приватный ключ.

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

В разделе 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, и разделим задачи доставки образа и его запуска на севрере.

Сборка и доставка образа:

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, после чего собираем наш образ и пушим его. Ничего сложного.

Теперь следующий шаг. Подключиться к серверу и перезапустить контейнер.

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"'

Раздел script запускает alias на сервере, который и должен выполнить все команды для перезапуска контейнера. Вы можете перезапустить один контейнер, или перезапустить контейнер в docker-compose. Здесь все индивидуально.

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

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

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

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"

Удобно, что теперь и код и сборка находятся в одном месте. Несмотря на недостатки GitLab CI, пользоваться им в целом удобно.

🌟 Бонус: уведомления о деплое в 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 задача отрабатывает только, когда одна из предыдущих завершилась неудачно.

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