Pull to refresh

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

Reading time11 min
Views14K

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

Сегодня решил рассказать о своем опыте, как при помощи 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

Tags:
Hubs:
Total votes 8: ↑5 and ↓3+4
Comments21

Articles