Java
February 26

Настройка 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 instance URL: https://gitlab.com/
  • Executor: docker
  • Default image: docker:stable*

* Последняя настройка показывает, какой будет использован образ, если не указать в 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=truespring.datasource.url=jdbc:h2:mem:testdbspring.datasource.driverClassName=org.h2.Driverspring.datasource.username=saspring.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 [email protected] "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  - deployvariables:  GRADLE_IMAGE: 'gradle:8.6-jdk17-alpine'  DOCKER_IMAGE: 'docker:stable'build:  image: $GRADLE_IMAGE  stage: build  script:    - gradle assemble  artifacts:    paths:      - build/libs/*.jartest:  image: $GRADLE_IMAGE  stage: test  needs:    - build  script:    - gradle check  artifacts:    when: always    reports:      junit: build/test-results/test/**/TEST-*.xmlpush 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_SHAdeploy:  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 [email protected] "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      "

Ну вот и все! Это была повесть о том, как начинающие разработчики пробовали на вкус девопщину. Невкусно, если честно. Есть большое желание совершенствоваться в этой области, прокачивать свои навыки, а также почитать ваши комментарии, услышать фидбэк. Всем, кому мы помогли, рад, что ваши нервы остались при вас :-)

Всем карьерного роста! :-)

Источник