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