Всем привет, хабровчане и гости сайта
Сегодня решил рассказать о своем опыте, как при помощи 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
Комментарии (21)
Heliki
12.10.2022 21:12+4Параметры для запуска Spring Boot приложения можно не передавать через
-D
в командной строке, а использовать сразу переменные окружения, приведя их имена к определенному виду. Например,spring.datasource.password
станетSPRING_DATASOURCE_PASSWORD
. Это упростит строку запуска до `java -jar app.jar`.> Важно отметить, что переносить элементы этого массива на другую строку нельзя, потому что это все ломает
Можно использовать
\
перед переводом строки
AntonCtrannik
12.10.2022 23:22на мой взгляд при каждом старте поднимать пустую БД и создавать таблицы (spring.jpa.hibernate.ddl-auto=create) не самая лучшая задумка. И таблицы будут пустые.
На мой взгляд лучше сделать свой Docker - образ БД (сразу с таблицами и необходимыми данными) и при старте нужно будет просто поднять свой образromankh3 Автор
12.10.2022 23:23Такая практика вообще не приветствуется. Цель же не в приложении, в развертывании. ddl-auto поставил только для того, чтобы проще и быстрее запустить приложение с базой.
santanico
12.10.2022 23:24+2Интересный факт - ‘git pull’ из листинга выше обновляет только текущую ветку. Поэтому тут надо либо сначала делать ‘git checkout’, а потом - ‘git pull’, либо сразу - ‘git pull -all’.
romankh3 Автор
12.10.2022 23:25Кстати, кстати. Чтобы все ветки обновились, нужно перейти на главную, стянуть и потом уже переходить. Обновлю, спасибо
GaDzik
13.10.2022 01:24+1Очь веселое чтиво)
ryanl
13.10.2022 07:28Согласен, сам только весной этого года окончательно закрепил знания по всему этому DevOps добру (docker, docker-compose, впереди еще k8s), правда на базе .NET.
Очень интересно наблюдать, как другие "дачники" проходят по этому пути, и пишут "я тоже покрасил свой забор".
Не думал, что по этому поводу даже нужно писать статьи на хабре.)
garwall
13.10.2022 08:28+2Даже не спрашивайте меня как это работает - я честно скоммуниздил это дело на stackoverflow))
Переменно rc (return code) присваивается код возврата исполнения предыдущей команды, mvc install, потом это код проверятся на то, отличен ли он от нуля (если код возврата не равен нулю, то это значит, что команда исполнилась с какой-то ошибкой). и если да, то скрипт завершает свою работу, передавая этот код возврата в среду исполнения.
kiloper
gitlab? - не, не слышал.
romankh3 Автор
Не понял тебя, объяснишь ?
Apache02
Существует открытый проект gitlab, хостинг для git репозиториев с автоматизацией. В нем встроен хостинг docker образов. Для автоматизации в gitlab придуман
.gitlab-ci.yml
файл в корне проекта.Вот рабочий пример для небольшого проекта
Скрипты выполняются на раннере, перед выполнением гитлаб сгенерирует пару login+password для доступа к текущему репозиторию и его реестру (она будет действительна до конца выполнения скриптов), с их же помощью склонирует репозиторий в свежую папку и начнет выполнять скрипты.
У вас проект доставляется репозиторием, вместо образа. Нет хранения актуального образа с прода. Стопается контейнер, а потом собирается, когда сборка должна быть до остановки старого (docker-compose сам умеет стопать старый контейнер). Отдельные шелл файлы не нужны, в крайнем случае в Makefile их можно засунуть.
kiloper
Спасибо, что ответили за меня. Не хотелось расписывать:))))))
ris58h
И как это позволит, например, развернуть проект локально одним скриптом?
romankh3 Автор
Это требует значительно больших ресурсов и сил. Нужно и гитлаб развернуть, раннеры запустить, поддерживать все это дело.
В итоге это решает проблему более красиво и качественно, но затраты тоже намного больше.
Плюс не всегда есть вообще возможность развернуть где-то свой гитлаб по разным причинам, начиная от запросов заказчика, заканчивая банальным отсутствием ресурса, чтобы его развернуть и поддерживать.
Плюс это нужно уметь делать, что также требует или компетенции или времени на освоение.
Поэтому с моей точки зрения это не решает задачу, поставленную вначале статьи.
Да и чтобы эти конфигурации написать, нужно также время или опыт в этом.
kksudo
Зачем разворачивать свой gitlab? Есть же публичный бесплатный вариант, кроме того есть бесплатный GitHub Workflows.
`Плюс это нужно уметь делать, что также требует или компетенции или времени на освоение.`
Да, IT это постоянно получение новых знаний и навыков.
Имхо для локальной разработки в одну каску - норм, но для командной работы выглядит слабо с кучей минусов (не зря же появились CI/CD инструменты с кучей готовых либ и Best Practices?).
Как аналогия, для меня это выглядит как заливание файлов по FTP на сервер...
Спасибо за статью, интересно было взглянуть как разработчики без опыта DevOps смотрят на проблему автоматизации.
romankh3 Автор
Нет уверенности в том, что Gitlab/GitHub будет работать как мы ожидаем этого и завязываться полностью на него я бы не стал.
Хотя конечно все верно, в мире глобальном именно так и было бы, как ты описал)