Настройка CI/CD глазами разработчика
Введение
Когда я захожу на Хабр, чтобы разобраться в конкретной проблеме, я ожидаю увидеть решение возникшего вопроса. Но к моему сожалению, частенько не хватает статей, в которых будет разбор определенной темы от корки до корки. Тема, которая будет сегодня освещена, рассказывается от лица backend разработчика. На нашем проекте нет devops’а, который бы мог подсказать, направить. Поэтому нам пришлось выходить из зоны комфорта.
Действительно, почему никто не рассказывает, как в конкретно их случае, в их проекте настроен CI/CD? С конкретным стеком технологий, чтобы «зеленые» их коллеги могли следовать их примеру, вместо изобретения велосипеда? Только ознакомительные отрывки без контекста, которые больше путают, чем помогают.
Перед началом хотелось бы сказать, что мы готовы к обоснованной критике, и вскоре выйдет вторая часть (надеюсь не с видео, где мы извиняемся за свой код на коленях), в которой поправим некоторые моменты и, возможно, используем другой стек технологий, дабы охват тех бедолаг, которым нужны конкретные примеры, был больше.
В этой статье не будет теории, объяснения что такое CI/CD, docker-compose, и т. д. Те, для кого эта статья может быть полезна уже прошли путь самопознания и готовы перейти к реализации. Далее я не буду вам указывать, что нужно делать. Повествование будет идти в контексте «мы сделали так». Если вас устраивает то, про что я тут буду вещать, пользуйтесь на здоровье!)
Стек
Поговорим немного про используемые технологии. Начнем с базовых вещей. Java 17 версии в сборке Gradle, Spring, Junit, БД PostgreSQL. Все это управляется через GitLab. Для развертывания используем контейнеры Docker, ну, и куда же без сервера на Ubuntu.
Чего мы хотим?
Расскажу теперь о том, как у нас в голове выглядела картинка, когда мы начинали. Без понимания алгоритма у вас мало что получится сделать качественно. И вот наше видение:
После того, как делается 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
После ввода первой команды будет предложено несколько настроек (Step 2 на картинке выше) выполнить прям в консоли. Вот некоторые, как у нас:
* Последняя настройка показывает, какой будет использован образ, если не указать в gitlab-ci.yml явно
В настройках runner на GitLab мы поставили следующие настройки, чтобы не тегировать каждую job (но это не обязательно, если вы будете):
На сервере переходим в директорию, в которой установлен 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
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)
Далее алгоритм следующий:
Переходим в директорию проекта, где лежит наши .env и docker-compose.yml.
В переменную DOCKER_TAG записываем название текущего image из файла .env.
Далее останавливаем наш контейнер с приложением и удаляем его и image по которому был создан, дабы не плодить старые версии.
Пулим новую версию приложения и записываем его сигнатуру в .env.
Экспортируем .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
"
Ну вот и все! Это была повесть о том, как начинающие разработчики пробовали на вкус девопщину. Невкусно, если честно. Есть большое желание совершенствоваться в этой области, прокачивать свои навыки, а также почитать ваши комментарии, услышать фидбэк. Всем, кому мы помогли, рад, что ваши нервы остались при вас :-)
Всем карьерного роста! :-)