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


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


Disclamer: У меня не так много опыта в CI/CD, так что статья скорее для новичков. Мы рассмотрим самый простой пример деплоя на несколько контуров с доставкой с помощью scp и запуском linux сервиса. Буду рад услышать конструктивную критику с предложениями по оптимизации скрипта сборки.

Коротко о 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. Образ, который будет использоваться по умолчанию, если не установлен другой.

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


Добавленный ранер GitLab


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


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

Как создавать службы 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, пользоваться им в целом удобно.