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

от автора

Всем привет, хабровчане и гости сайта

Сегодня решил рассказать о своем опыте, как при помощи docker-compose и bash скрипта настроил развертывание бекенд приложения с базой данных.

Какая была идея? Хотелось при помощи одной команды в терминале разворачивать Java приложение с базой данных так, чтобы можно было передать все необходимые переменные в момент запуска и нигде не хранить их.
Так, чтобы можно было развернуть новую версию приложения даже с телефона, просто заранее заготовив необходимую команду.

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

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

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

Первое, что нам нужно будет — это самое простое приложение, которое будет хотеть подключиться к базе данных и иметь возможность создать какую-то сущность в базе и проверить, что она там есть. Короче говоря напишем REST API для одной таблицы в базе данных))

Прежде чем приступить, предлагаю вам подписаться на мой телеграм канал, где я веду блог об ИТ разработке, в частности на джаве. Я там собираю все свои мысли/статьи. В группе к каналу всегда можно обсудить вопросы по разработке, что очень приветствуется!

Создаем простое SpringBoot приложение

На сайте https://start.spring.io подготовим нужный нам каркас для springboot приложения с уже нужным набором зависимостей. Вот ссылка на него для тех, кто хочет пройти этот путь вместе со мной.

К этому приложению нужно добавить проперти для запуска и несколько классов.

application.properties

spring.datasource.url=jdbc:postgresql://localhost:5432/example spring.datasource.username=example spring.datasource.password=example spring.datasource.driver-class-name=org.postgresql.Driver  spring.jpa.hibernate.ddl-auto=create

Здесь, для простоты, мы указали, что схему хибер накатывал на базу автоматически на основе сущностей предоставленных ему и добавили по умолчанию настройки для базы данных.

Далее, добавим сущность нашу StudentEntity:

import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.Table; import lombok.Data; import lombok.NoArgsConstructor;  @Entity @Table(name = "student") @Data @NoArgsConstructor public class StudentEntity {    @Id   @GeneratedValue(strategy = GenerationType.AUTO)   private long id;    private String name;   private String email; }

Здесь все как обычно, будет база данных с таблицей student, в которой будет три поля: id, name, email.

Далее, нужно создать репозиторий StudentRepository:

import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository;  @Repository public interface StudentRepository extends JpaRepository<StudentEntity, Long> { }

И контроллер:

import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;  @RestController @RequestMapping("students") @RequiredArgsConstructor public class StudentController {    private final StudentRepository studentRepository;    @GetMapping   public List<StudentEntity> findAll() {     return studentRepository.findAll();   }    @GetMapping("/{id}")   public StudentEntity findById(@PathVariable Long id) {     return studentRepository.findById(id).orElse(null);   }    @PostMapping   public StudentEntity save(@RequestBody StudentCreateDto createDto) {     StudentEntity studentEntity = new StudentEntity();     studentEntity.setName(createDto.getName());     studentEntity.setEmail(createDto.getEmail());     return studentRepository.save(studentEntity);   }    @DeleteMapping("/{id}")   public void deleteById(@PathVariable Long id) {     studentRepository.deleteById(id);   }    @PutMapping   public StudentEntity update(@RequestBody StudentEntity studentEntity) {     return studentRepository.save(studentEntity);   } }

И отдельная моделька для создания сущности:

import lombok.Data;  @Data public class StudentCreateDto {   private String name;   private String email; }

И в целом этого нам достаточно. Теперь мы можем по рест апи создать сущность, достать ее из базы, удалить и обновить. Как говорится первое, второе и компот))

Создаем Dockerfile

Чтобы создать docker image, нужно описать dockerfile, в нем будет находится инструкция из чего собрать и как.
Обычно, он находится в корне проекта, файл без расширения. Посмотрим что там внутри, потом опишу по каждому пункту:

FROM adoptopenjdk/openjdk11:ubi ARG JAR_FILE=target/*.jar ENV DB_USERNAME=example ENV DB_PASSWORD=example ENV DB_NAME=example ENV DB_HOST=localhost ENV DB_PORT=5432 ENV APP_PORT=8080 COPY ${JAR_FILE} app.jar ENTRYPOINT ["java", "-Dspring.datasource.password=${DB_PASSWORD}", "-Dspring.datasource.username=${DB_USERNAME}", "-Dspring.datasource.url=jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}", "-Dserver.port=${APP_PORT}", "-jar", "app.jar"]

Первая строка говорит о том, на чем будет базировать наш docker image. В нашем случае это openjdk11:

FROM adoptopenjdk/openjdk11:ubi

Далее мы создаем аргумент с джарником приложения. Ождаем любой файл с расширением .jar в папке target. Это сделано потому, что собранный проект на мавене кладет джарник в эту папку:

ARG JAR_FILE=target/*.jar

Далее, регистрируем переменные из переменных среды:

ENV DB_USERNAME=example ENV DB_PASSWORD=example ENV DB_NAME=example ENV DB_HOST=localhost ENV DB_PORT=5432 ENV APP_PORT=8080

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

Следующий этап — это мы копируем джарник к себе и присваиваем ему имя app.jar:

COPY ${JAR_FILE} app.jar

И заключительный этап — это описание как мы будем запускать наше приложение:

ENTRYPOINT ["java", "-Dspring.datasource.password=${DB_PASSWORD}", "-Dspring.datasource.username=${DB_USERNAME}", "-Dspring.datasource.url=jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}", "-Dserver.port=${APP_PORT}", "-jar", "app.jar"]

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

$ java -Dspring.datasource.password=${DB_PASSWORD} -Dspring.datasource.username=${DB_USERNAME} -Dspring.datasource.url=jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME} -Dserver.port=${APP_PORT} -jar app.jar

Важно отметить, что переносить элементы этого массива на другую строку нельзя, потому что это все ломает. Почему? Я хз, вот иначе не работает. И ошибка не описывает почему именно))

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

На этом настройка Dockerfile закончена, идем дальше

Создаем docker-compose.yml файл

Оркестрантом нашей движухи будет docker-compose. Он будет отвечать за запуск нашего докер на основе Dockerfile и базы данных на основе открытого docker image для PostgreSQL.
Сделаем точно также — покажем готовый файл, а потом опишем что там:

version: "3.9"  services:   example-app:     container_name: example-app     depends_on:       -   example-db     ports:       - "${APP_PORT}:${APP_PORT}"     build:       context: ..     environment:       DB_USERNAME: ${DB_USERNAME:?dbUserNameNorProvided}       DB_PASSWORD: ${DB_PASSWORD:?dbPasswordNotProvided}       DB_NAME: ${DB_NAME:?dbNameNotProvided}       DB_HOST: example-db       DB_PORT: 5432       APP_PORT: ${APP_PORT:?appPortNotProvided}     restart: unless-stopped   example-db:     container_name: example-db     image: 'postgres:13.1-alpine'     ports:       - "${DB_PORT}:5432"     environment:       - POSTGRES_USER=${DB_USERNAME}       - POSTGRES_PASSWORD=${DB_PASSWORD}       - POSTGRES_DB=${DB_NAME}     restart: unless-stopped

Значит входом в описание является services. После него идет список сервисов, то есть докер контейнеров, которые нужно запустить.

У нас их будет два — это приложение и база данных.

Начнем с базы данных:

  example-db:     container_name: example-db     image: 'postgres:13.1-alpine'     ports:       - "${DB_PORT}:5432"     environment:       - POSTGRES_USER=${DB_USERNAME}       - POSTGRES_PASSWORD=${DB_PASSWORD}       - POSTGRES_DB=${DB_NAME}     restart: unless-stopped

Мы указали имя контейнера, откуда брать docker image (в нашем случае это ‘postgres:13.1-alpine’).
Далее, указали порты, в которых будет запущен этот сервис. Причем нужно внимательно следить за руками:

    ports:       - "${DB_PORT}:5432"

Левая сторона динамически настраивается переменной DB_PORT и указывает на порт, что будет использоваться во вне сети docker-compose(как именно там это устроено я не опишу, если будут желающие — заходите в комментарии), а вот правая указывает на порт, что будет использоваться в сети.

Далее, указаны переменные, что умеет принимать docker image, обычно это указано где-то на docker hub:

    environment:       - POSTGRES_USER=${DB_USERNAME}       - POSTGRES_PASSWORD=${DB_PASSWORD}       - POSTGRES_DB=${DB_NAME}

Далее, поговорим о нашем приложении:

  example-app:     container_name: example-app     depends_on:       -   example-db     ports:       - "${APP_PORT}:${APP_PORT}"     build:       context: .     environment:       DB_USERNAME: ${DB_USERNAME:?dbUserNameNorProvided}       DB_PASSWORD: ${DB_PASSWORD:?dbPasswordNotProvided}       DB_NAME: ${DB_NAME:?dbNameNotProvided}       DB_HOST: example-db       DB_PORT: 5432       APP_PORT: ${APP_PORT:?appPortNotProvided}     restart: unless-stopped

Здесь точно также указали имя будущего контейнера

Появляется важная настройка — depends_on:

depends_on:       -   example-db

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

Да порты точно также, как описано было уже выше:

ports:       - "${APP_PORT}:${APP_PORT}"

В этом сервисе docker-compose мы не ссылаемся на готовый docker image, поэтому будем использовать другом подход:

build:       context: .

Выше говорится о том, что нужно собрать docker image по указанному пути «.», то есть в том же директории, что и docke-compose.yml. Если бы наш Dockerfile находился где-то в другом месте, мы бы указали соответствующий путь к нему.

И, последнее, это переменные среды, которые передадутся в docker контейнер:

    environment:       DB_USERNAME: ${DB_USERNAME:?dbUserNameNorProvided}       DB_PASSWORD: ${DB_PASSWORD:?dbPasswordNotProvided}       DB_NAME: ${DB_NAME:?dbNameNotProvided}       DB_HOST: example-db       DB_PORT: 5432       APP_PORT: ${APP_PORT:?appPortNotProvided}

Эти переменные мы уже видели — их мы инициализировали в Dockerfile.

Важно также заметить, что хост базы данных для нашего приложения будет не localhost, как могло бы показаться, а имя сервиса — example-db. Ну вот так работает это здесь, принимаем как должное и идем дальше)

Также стоит указать о том, как передаются здесь переменные. Конструкция ${ENV_VAR_NAME:?errorMessage} говорит о том, что будут искать переменную окружения ENV_VAR_NAME, а если не найдут, то будет ошибка с сообщением errorMessage.

Опция, о которой я не сказал, говорит о том, что контейнеры будут автоматически перезапущены:

restart: unless-stopped

И таким образом, мы описали и осознали docker-compose.yml.

Теперь, хочется все собрать воедино в одном скрипте, чтобы не делать несколько команд.

Создаем start.sh скрипт

Вот здесь будет собрана вся логика, нужная для запуска. Это наша главная точка входа.

Чтобы автоматизировать процессы сборки приложения и развертывания, как раз и нужен нам start.sh bash скрипт. Посмотрим на него:

#!/bin/bash  # Pull new changes git pull  # Checkout to needed git branch git checkout $1  # Prepare JAR mvn clean mvn install rc=$? # if maven failed, then we will not deploy new version. if [ $rc -ne 0 ] ; then   echo Could not perform mvn clean install, exit code [$rc]; exit $rc fi  # Add env vars to .env config file echo "$2" >> ./target/.env echo "$3" >> ./target/.env echo "$4" >> ./target/.env echo "$5" >> ./target/.env echo "$6" >> ./target/.env  # Ensure, that docker-compose stopped docker-compose --env-file ./target/.env stop  # Start new deployment with provided env vars in ./target/.env file docker-compose --env-file ./target/.env up --build -d

в Целом это выстраданная конструкция, которая решает множество задач.
Первое, что мы делаем — это стягиваем себе последние изменения проекта, далее выбираем ветку, которую нужно будет собрать:

# Pull new changes git pull  # Checkout to needed git branch git checkout $1

Когда проект обновлен, ветка выбрана, приходит время для сборки проекта:

# Prepare JAR mvn clean mvn install

Следущая часть отвечает за проверку успешности прохождения билда. Что это значит? Если при сборке была ошибка, то скрипт остановит свою работу:

rc=$? # if maven failed, then we will not deploy new version. if [ $rc -ne 0 ] ; then   echo Could not perform mvn clean install, exit code [$rc]; exit $rc fi

Даже не спрашивайте меня как это работает — я честно скоммуниздил это дело на stackoverflow))

Следующий этап очень хитрый)) Так как без файла с конфигурациями .env docker-compose не умеет видеть переменные среды в строках, когда нам нужно указать конкретные порты (как оказалось это так, чему я был крайне удивлен), а хранить где-то файл с конфигурациями считаю верхом небезопасности, то я решил генерировать этот файл в папке сборки мавера target и потом ссылаться на этот файл при запуске docker-compose. Почему именно в той папке? Ну таким образом мы не добавляем ненужные файлы в проект, плюс при следующей сборке проекта, эти конфигурации будут обнулены. Вот как я заполняется файл .env:

# Add env vars to .env config file echo "$2" >> ./target/.env echo "$3" >> ./target/.env echo "$4" >> ./target/.env echo "$5" >> ./target/.env echo "$6" >> ./target/.env

Таким образом конфигурации подготовлены, и казалось бы можно запускать docker-compose, но я решил перестраховаться(от незнания моего, может этого и не нужно. Если есть люди знающие, подскажите) и перед запуском предполагаю, что может быть уже запущек docker-compose и я его останавливаю следующей командой:

# Ensure, that docker-compose stopped docker-compose --env-file ./target/.env stop

Как видно в команде, мы указывает на путь к файлу конфигурации.

И вот наконец-то мы можем запустить наш docker-compose, который уже будет иметь все необходимые файлы конфигурации:

# Start new deployment with provided env vars in ./target/.env file docker-compose --env-file ./target/.env up --build -d

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

Может конечно это уже слишком и удалять не стоит, но так ощущается более безопасным для меня.

Теперь, чтобы запустить наше окружение (да да, теперь мы можем говорить именно так)) ), нам нужно выполнить в терминале следующую команду:

bash start.sh ${BRANCH_NAME} DB_USERNAME=${DB_USERNAME} DB_PASSWORD=${DB_PASSWORD} DB_NAME=${DB_NAME} DB_PORT=${DB_PORT} APP_PORT=${APP_PORT}

Разумеется в ${VAN_NAME} нужно подставить свое значение.

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

bash start.sh main DB_USERNAME=example_prod DB_PASSWORD=fghlkfgmhflkghm DB_NAME=example_prod DB_PORT=5555 APP_PORT=8099

Далее нужно будет подождать, пока соберется проект, когда скачаются все необходимые docker image, когда соберется наш и запустится все.

Чтобы проверить, что все работает, можно написать команду docker ps, если все прошло правильно, то ответ должен быть таким:

И все, теперь можно по запросу http://localhost:8099/students получить пустой массив, так как у нас база будет пустая, но это будет ответ работающего приложения!

Да, можно пойти посмотреть, как будет выглядеть файл конфигурации:

DB_USERNAME=example_prod DB_PASSWORD=fghlkfgmhflkghm DB_NAME=example_prod DB_PORT=5555 APP_PORT=8099

Создаем stop.sh скрипт

Да, последнее, что нужно добавить — это отдельно файл для остановки docker-compose. Именно остановки, не удаления. Для этого создадим в корне проекта stop.sh файл и заполним его:

#!/bin/bash  # Ensure, that docker-compose stopped docker-compose --env-file ./target/.env stop  # Ensure, that the old application won't be deployed again. mvn clean

И на этом я думаю все!

В итоге мы получили рабочий подход к развертыванию приложения с базой данных при помощи одной строки на сервере. Такую строчку можно выполнить даже с телефона))

Все желающие предложить свое / оспорить описанное приглашаются в комментарии!

Вся кодовая база лежит в открытом доступе на гитхабе, вы вольны пользоваться ею как вам заблагорассудится: https://github.com/romankh3/springboot-postgres-docker-deployment-example

Всем мирного неба над головой!

Чтобы быть в курсе новых статей, подпишись на телеграм канал: https://t.me/romankh3


ссылка на оригинал статьи https://habr.com/ru/post/692198/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *