Настройка CI/CD глазами разработчика

Kate

Administrator
Команда форума

Стек​

Поговорим немного про используемые технологии. Начнем с базовых вещей. Java 17 версии в сборке Gradle, Spring, Junit, БД PostgreSQL. Все это управляется через GitLab. Для развертывания используем контейнеры Docker, ну, и куда же без сервера на Ubuntu.

Чего мы хотим?​

Расскажу теперь о том, как у нас в голове выглядела картинка, когда мы начинали. Без понимания алгоритма у вас мало что получится сделать качественно. И вот наше видение:

4da2a13a6a284cb3118d686bc67bac02.png

После того, как делается push на локальной машине, запускается pipeline, за которым можно следить на GitLab в Build -> Pipelines, состоящий из трех стадий: build, test, deploy. Первые две понятно, чем занимаются, а вот deploy поинтереснее.

На сервере запущено 2 контейнера. В одном база, в другом наше, собственно, приложение. Если build и test прошли успешно, то далее предстоит нелёгкий путь от сбора image, до его запуска на сервере.

Подготовка​

GitLab Runner​

Чтобы CI/CD заработал, был создан и запущен GitLab Runner на сервере. Чтобы это сделать следуйте в Settings -> CI/CD -> Runners -> New project runner. Вводим tag (в дальнейшем runner_serv). После создания открывается инструкция, как вам его установить. Предварительно устанавливаем gitlab-runner себе на сервер. После этого не стесняемся следовать описанным пунктам.

Настройка GitLab Runner

Настройка GitLab Runner
После ввода первой команды будет предложено несколько настроек (Step 2 на картинке выше) выполнить прям в консоли. Вот некоторые, как у нас:

  • GitLab instance URL:
  • Executor: docker
  • Default image: docker:stable*
* Последняя настройка показывает, какой будет использован образ, если не указать в gitlab-ci.yml явно

В настройках runner на GitLab мы поставили следующие настройки, чтобы не тегировать каждую job (но это не обязательно, если вы будете):

82a4b8e1cc651dc0bed77107405076cf.png

На сервере переходим в директорию, в которой установлен runner и открываем файл config.toml. У нас это /etc/gitlab-runner/config.toml. Там можно увидеть все настройки вашего runner’а. Находим строчку volumes = ["/cache"] и поправляем на volumes = ["/cache", "/certs/client", "/var/run/docker.sock:/var/run/docker.sock"]. Эта настройка примонтирует указанные тома*. То что мы добавили нам пригодится, когда мы будем использовать Docker in Docker.

*Не забудьте перезапустить Runner!

Остальные инструменты, необходимые на сервере​

На сервере должны быть установлены docker, docker-compose, Java 17, Gradle, Git, Postgres. В ссылках должно быть добавлено JAVA_HOME, git.

В директории вашего проекта долно лежать два файла: .env и docker-compose.yml. Рассмотрим каждый из них.

.env

Здесь лежит тэг текущего образа, на основе которого крутится контейнер с нашим приложением. Выглядит так:

.env

.env


docker-compose.yml

version: '3'

services:
database:
container_name: "PlannerPostgresDB"
image: (замените на свое имя из docker hub)/postgres:2.0
restart: always
environment:
POSTGRES_DB: planner
POSTGRES_USER: postgres
POSTGRES_PASSWORD: planner
DATABASE_URL: jdbc:postgresql://PlannerPostgresDB:5432/planner
ports:
- "5433:5432"

planner:
image: ${DOCKER_TAG}
container_name: "PlannerServ"
depends_on:
- database
environment:
DATABASE_URL: jdbc:postgresql://PlannerPostgresDB:5432/planner
DATABASE_HOST: PlannerPostgresDB
DATABASE_PORT: 5432
DATABASE_NAME: planner
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: planner
ports:
- "8080:8080"
Выше уже говорим, что 2 контейнера на сервере запускаются. Вот и они. База всегда остается не тронута, а вот дружочка PlannerServ мы постоянно перезаписываем. Об этом чуть позже, пока что обозначаем структуру.

Медятина​

Теперь можем перейти к основному разделу. Тут самый мёд. Ну, медятина!

В корневой директории проекта создаём файл gitlab-ci.yml. Объяснять и показывать буду по частям, чтобы вы смогли всё прочувствовать. В конце будет приведён код целиком для вашего удобства )

Тут не интересно. Просто указываем наши stage.

stages:
- build
- test
- deploy
Указываем, какие будем использовать image в дальнейшем для каждой job. Это дефолтные образы, которые сами установятся, если их нет. Не надо их ставить на сервер заранее.

variables:
GRADLE_IMAGE: 'gradle:8.6-jdk17-alpine'
DOCKER_IMAGE: 'docker:stable'
Дальше нам нужно из первой job проверить собирается ли проект и вытащить оттуда в артефакты только наш jar файл проекта.

build:
image: $GRADLE_IMAGE
stage: build
script:
- gradle assemble
artifacts:
paths:
- build/libs/*.jar
Перед следующей частью будет небольшое отклонение. Так как мы используем для проверки правильности работы и взаимодействия компонентов нашего проекта JUnit, мы хотели бы видеть после выполнения pipeline статистику по выполненным (или, увы, проваленным) тестам. Для этого нам нужно в build.gradle добавить:

test {
useJUnitPlatform()
}
Это поможет нам сгенерировать отчеты по выполненным тестам в формате xml, который кушает GitLab. Так же в src/test/resources добавляем файл application-test.properties и пишем туда

spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
Кто не знаком с этой приятной штукой, почитайте в любых доступных источниках. Не забудьте на ваш класс, в котором выполняется тест contextLoads() добавить аннотацию для properties, описанных выше: @TestPropertySource(locations = "classpath:application-test.properties")

Теперь перейдем конкретно к stage test. Тут просим gradle выполнить тесты и записать результат в артефакты в reports.

test:
image: $GRADLE_IMAGE
stage: test
needs:
- build
script:
- gradle check
artifacts:
when: always
reports:
junit: build/test-results/test/**/TEST-*.xml
Две описанных выше стадии должны выполняться в любом случае при пуше на сервер. Следующая же (deploy) только в случае пуша в main ветку вашего проекта. Поэтому не забываем это указать.

Стоит еще отметить блок needs, который показывает, что эта job будет выполнена только в случае успешного завершения указанной.

А вот и наш deploy! Не приятно, если честно, познакомиться. Мы разделили его на две job. В одном сборка и пуш на hub, а во втором развертывание на сервере. В целом, по названиям все понятно, но пояснить стоило. И так, по порядку.

Тут мы используем dind, чтобы можно было воспользоваться docker и, в целом, безопасно все собрать и запушить. Переменные, которые написаны с «$» не считая наших описанных выше images лежат в нашем GitLab. А именно Settings -> CI/CD -> Variables.

Создаваемый image имеет следующую сигнатуру: $DOCKER_HUB_USERNAME/planner:$CI_COMMIT_SHORT_SHA , где planner название нашего приложения (по-хорошему тоже надо скрыть, но это мелочи уже). Почему именно такая сигнатура? Чтобы проще было мониторить, мы точно знаем к какому коммиту относится определенная версия нашего приложения. Удобно.

А вот и код:

push to hub:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
stage: deploy
needs:
- build
- test
image: $DOCKER_IMAGE
services:
- docker:dind
script:
- docker build -t planner .
- echo "$DOCKER_HUB_PASSWORD" | docker login --username $DOCKER_HUB_LOGIN --password-stdin
- docker tag planner $DOCKER_HUB_USERNAME/planner:$CI_COMMIT_SHORT_SHA
- docker push $DOCKER_HUB_USERNAME/planner:$CI_COMMIT_SHORT_SHA

Теперь, когда у нас есть свеженькая версия нашего приложения осталось только ее запустить на сервере. Ничего сложно правда?



А вот и deploy:

Hidden text
deploy:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
stage: deploy
needs:
- build
- test
- push to hub
image: $GRADLE_IMAGE
before_script:
- command -v ssh-agent >/dev/null || ( apk update -qq && apk add openssh-client -qq)
- eval $(ssh-agent -s)
- chmod 400 "$SSH_PRIVATE_KEY"
- ssh-add "$SSH_PRIVATE_KEY"
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- mkdir -p ~/.ssh && touch ~/.ssh/known_hosts
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- export $(grep -v '^#' .env | xargs -0)
- apk update -qq && apk add -qq openssh-client
script:
- ssh root@78.40.217.105 "cd ./planner &&
DOCKER_TAG=\$(grep '^DOCKER_TAG=' .env | awk -F '=' '{print \$2}') &&
docker stop PlannerServ &&
docker rm PlannerServ &&
docker rmi -f \$DOCKER_TAG &&
docker pull $DOCKER_HUB_USERNAME/planner:$CI_COMMIT_SHORT_SHA &&
sed -i \"s/^DOCKER_TAG=.*/DOCKER_TAG=$DOCKER_HUB_USERNAME\/planner:$CI_COMMIT_SHORT_SHA/\" .env &&
export $(cat .env | xargs) &&
docker-compose up -d --build


Две эти картинки описывают наше состояние с разницей в пару месяцев. Давайте все-таки разбираться что да как. Даже если у вас Runner развернут на сервере по соседству с приложением, у вас нет возможности (мы не нашли способа) обратиться наружу. Для этого придется организовать на сервере подключение через ssh. Это, кстати, удобно в любом случае, чтобы не использовать разные Putty, а просто иметь возможность подключаться через консоль на вашей машинке.
Напишите в комментариях, если нужна подробная инструкция как это сделать. Мы с радостью вам расскажем. Если коротко, то есть два ключа, один из которых хранится на сервере, а второй на устройстве. Если все норм с ключами – соединение установлено. Это как раз и настраиваем вот тут:
before_script:
- command -v ssh-agent >/dev/null || ( apk update -qq && apk add openssh-client -qq)
- eval $(ssh-agent -s)
- chmod 400 "$SSH_PRIVATE_KEY"
- ssh-add "$SSH_PRIVATE_KEY"
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- mkdir -p ~/.ssh && touch ~/.ssh/known_hosts
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- export $(grep -v '^#' .env | xargs -0)
Далее алгоритм следующий:
  1. Переходим в директорию проекта, где лежит наши .env и docker-compose.yml.
  2. В переменную DOCKER_TAG записываем название текущего image из файла .env.
  3. Далее останавливаем наш контейнер с приложением и удаляем его и image по которому был создан, дабы не плодить старые версии.
  4. Пулим новую версию приложения и записываем его сигнатуру в .env.
  5. Экспортируем .env и запускаем docker-compose.

Первый раз со скрипом​

Все, что было описано выше прекрасно работает, но есть нюанс. Это первый запуск. Один раз вам придется собрать образы ручками. Базу и сервер при инициации проекта надо самому загрузить на docker hub с вашей локальной машины и на сервере их запулить. В файл .env добавьте сигнатуру образа, который установили. Не забудьте про команду export $(cat .env | xargs). Ну и в конце на сервере надо запустить docker-compose.

Подведение итогов​

Теперь у нас имеется и Continuous Integration, и Continuous Delivery. Как и обещал, файл gitlab-ci.yml в полном составе:
stages:
- build
- test
- deploy

variables:
GRADLE_IMAGE: 'gradle:8.6-jdk17-alpine'
DOCKER_IMAGE: 'docker:stable'

build:
image: $GRADLE_IMAGE
stage: build
script:
- gradle assemble
artifacts:
paths:
- build/libs/*.jar

test:
image: $GRADLE_IMAGE
stage: test
needs:
- build
script:
- gradle check
artifacts:
when: always
reports:
junit: build/test-results/test/**/TEST-*.xml


push to hub:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
stage: deploy
needs:
- build
- test
image: $DOCKER_IMAGE
services:
- docker:dind
script:
- docker build -t planner .
- echo "$DOCKER_HUB_PASSWORD" | docker login --username $DOCKER_HUB_LOGIN --password-stdin
- docker tag planner $DOCKER_HUB_USERNAME/planner:$CI_COMMIT_SHORT_SHA
- docker push $DOCKER_HUB_USERNAME/planner:$CI_COMMIT_SHORT_SHA

deploy:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
stage: deploy
needs:
- build
- test
- push to hub
image: $GRADLE_IMAGE
before_script:
- command -v ssh-agent >/dev/null || ( apk update -qq && apk add openssh-client -qq)
- eval $(ssh-agent -s)
- chmod 400 "$SSH_PRIVATE_KEY"
- ssh-add "$SSH_PRIVATE_KEY"
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- mkdir -p ~/.ssh && touch ~/.ssh/known_hosts
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- export $(grep -v '^#' .env | xargs -0)
- apk update -qq && apk add -qq openssh-client
script:
- ssh root@78.40.217.105 "cd ./planner &&
DOCKER_TAG=\$(grep '^DOCKER_TAG=' .env | awk -F '=' '{print \$2}') &&
docker stop PlannerServ &&
docker rm PlannerServ &&
echo \$DOCKER_TAG &&
docker rmi -f \$DOCKER_TAG &&
docker pull $DOCKER_HUB_USERNAME/planner:$CI_COMMIT_SHORT_SHA &&
sed -i \"s/^DOCKER_TAG=.*/DOCKER_TAG=$DOCKER_HUB_USERNAME\/planner:$CI_COMMIT_SHORT_SHA/\" .env &&
export $(cat .env | xargs) &&
docker-compose up -d --build
"
 
Сверху