Мы живём в эпоху микросервисной архитектуры, и контейнеры стали основным средством упаковки приложения, а затем его доставки в различные среды. При этом многие разработчики не уделяют достаточно внимания на то, как правильно упаковать сервис, как сделать это оптимально, и главное не оставить дыры в безопасности. В этой статье мы разберём 4 способа упаковки:
- Простой Dockerfile, запустили сборку, получили образ.
- Сборка при помощи
spring-boot-maven-plugin
. - Используем специальный плагин Jib от Google.
- Напишем оптимизированный Dockerfile.
Для экспериментов будем использовать следующий проект: github.com/Example-uPagge/spring_boot_docker. Это Spring Boot приложение, которое содержит пару простых контроллеров, репозитории с H2 базой данных.
На освное этой статьи я выступал с докладом на конференции Podlodka Java Crew: https://www.youtube.com/watch?v=MAFYm9tv-R4
Простой Dockerfile
Типичный Dockerfile, который пишут разработчики, выглядит так:
FROM openjdk:17.0.2-jdk-slim-buster
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

Подобно торту образ Docker состоит из стопки слоёв. Каждый представляет собой изменение по отношению к предыдущему слою. Когда мы извлекаем образ Docker из registry, он вытягивается по слоям и кэшируется на хосте.
Наш типичный Docker-образ состоит из базового образа/слоя с Linux, чем он тоньше, тем лучше. Потом идёт слой JDK. И сверху слой приложения, а по факту просто jar.
Spring Boot использует "Far JAR" формат упаковки по умолчанию. Это значит, что все зависимости, необходимые для запуска, добавляются в один Jar файл.
Но хватит теории, переходим к практике. Чтобы получить образ, сначала собираем приложение с помощью maven:
mvn clean package
А затем запускаем сборку образа:
docker build -t upagge/spring-boot-docker:dockerfile .
В этом случае, upagge
– это ваш логин на DockerHub, spring-boot-docker
это название образа, а dockerfile
– тег. Если вы не планируете отправлять образ в DockerHub, то можно указать название не используя логин. Например, так:
docker build -t spring-boot-docker:0.0.1 .
Для анализа образа воспользуемся утилитой dive. Dive позволяет показать отличияслоёв друг от друга: какиефайлы добавлены, какие изменены, какие удалены.
dive upagge/spring-boot-docker:dockerfile

Общий вес образа получился 448 mb, из которых:

- 63+8.4 mb это слой с Linux.
- 324 mb это вес JDK
- 53 mb это наш spring-boot-jar
Обратите внимание на вес слоя приложения: 53 мегабайт, при этом проект почти не содержит кода. При любом изменении в коде нам придётся отправлять по сети 53 мегабайт, сервер будет скачать 53 мегабайт. Остальные слои вряд ли будут меняться, так что докер не будет их передавать по сети, а будет использовать кэш. Чуть позже разберёмся, как это исправить.
Чтобы убедиться, что всё работает, запустим образ командой:
docker run -p 8080:8080 upagge/spring-boot-docker:0.0.1
Открываем браузер и переходим по адресу: http://localhost:8080/api/person/1. Если образ был запущен успешно, то в ответ вы увидите слово valid
.
Выводы по Dockerfile
Вот так легко и просто мы можем упаковать приложение. Но у этого способа есть ряд минусов, которые мы устраним чуть ниже.
Плюсы этого способа:
- Полный контроль. Можно собрать свой образ как душе угодно.
- Довольно простой способ.
Минусы:
- Полный контроль. Можно что-то сломать или пробить дыру в безопасности.
- Мы ещё не начали разрабатывать, а образ уже много весит.
- Большой объём изменяющегося слоя.
- Запускаем от имени root пользователя.
Spring Boot Plugin
SpringBoot максимально упрощает разработку. Добавили пару стартеров, заполнили application.properties
и вуаля, микросервис готов. Я серьёзно, посмотрите проект Spring Data REST, который генерирует вам контроллеры на основе JpaRepository
.
Очевидно, что для контейнеризации тоже что-то придумали. И это старый добрый spring-boot-maven-plugin
плагин. Он умеет не только преобразовывать обычный jar, в jar-файл со встроенным Tomcat-ом, но и соберёт полноценный Docker Image.
Сначала в конфигурации плагина зададим название будущего образа. Если не указать тег, то будет автоматически проставлен latest
.
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<imageName>upagge/spring-boot-docker:spring-plugin</imageName>
</configuration>
</plugin>
</plugins>
</build>
Версия образа
Если вы хотите указать версию образа, то можно сделать это используя переменные maven проекта:
<imageName>upagge/spring-boot-docker:${project.version}</imageName>
Для сборки запускаем команду:
mvn spring-boot:build-image
И вуаля, у нас есть рабочий образ. Исследуем слои, который создал Spring:
dive upagge/spring-boot-docker:spring-plugin

Созданный образ весит 309 мегабайт, что на 139 мегабайт меньше, чем тот, что мы собрали самостоятельно. Но ещё более примечательна структура образа.

- 63 mb — всё ещё Linux;
- 24 mb — различные сертификаты;
- 1.4 kb — тут явно добавляется пользовать, от имени которого будет работать приложение;
- 157 mb — занимает слой с JDK;
- 53 mb — отдельный слой релизных зависимостей;
- 252 kb — слой со всеми загрузчиками Spring Boot;
- 14 kb – слой снепшотных зависимостей;
- 34 kb — это непосредственно код, который мы написали и ресурсы к нему;
Здесь проделана важная оптимизация — зависимости приложения вынесены в отдельные слои. Благодаря этому, они также будут кэшироваться и переиспользоваться докером. Поэтому, при внесении изменений в код, вы будете отправлять не 53 мегабайт (вес jar), а только 3-5 mb.
Причём важная особенность, snapshot-зависимости помещаются в отдельный слой, от релизных. Ведь вероятность их изменений намного выше.
Запустим наш контейнер.

Обратите внимание, как много информации нам выдали перед запуском. Spring провел некоторые оптимизации за нас, о которых он сообщает в этом логе:
- Он явно еще не умеет собирать образы под M1.
- Установил 5 активных процессоров.
- Посчитал доступную память для JVM. А далее распределил ее.
- Отобразил все дополнительные ключи, которые применил при запуске.
Выводы по spring-plugin
Ещё более простой способ упаковки, который использует оптимизации, так как понимает особенности SpringBoot Java приложений. Главное — следите за тем, что он делает под капотом, чтобы эти достоинства не превратились в проблемы.
Для запуска приложения создаётся отдельный пользователь. Это считается более безопасным способом.
Также от нас не требуется создание Dockerfile, что не даёт нам такой гибкости в настройки образа, которая понадобится в сложных проектах.
Хороший способ упаковки для простых пет-проектов.
Плагин Jib
Это инструмент с открытым исходным кодом от Google, которому не нужен docker для работы. Вам также не нужно писать Dockerfile.
Чтобы использовать Jib, добавьте в ваш pom.xml
:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<to>
<image>upagge/spring-boot-docker:jib-plugin</image>
</to>
</configuration>
</plugin>
</plugins>
</build>
Cборка без Docker
Одно из преимуществ этого плагина — это сборка без установки Docker:
mvn compile jib:build
Спуливаем образ и изучаем с помощью dive:

Итоговый вес вышел 321 mb, что на 12 мегабайт больше, чем у предыдущего способа. Из них:

- 78 + 48 mb — это linux и различные сертификаты.
- 140 mb — это JDK.
- 55 mb — слой с релизными зависимостями
- 14 kb — слой со снепшотными зависимостями
- 981 byte — это только ресурсы. Папка resources
- 22 kb — это написанный нами код.
Подобно плагину от спринга Jib вынес зависимости в отдельные слои, но он пошёл ещё дальше и для папки с ресурсами также создал отдельный слой. Ведь ресурсы тоже редко меняются, но весить при этом они могут много. Например, если у вас много скриптов миграции Liquibase.
Сборка с использованием докера
Для этого также добавляем плагин, как в предыдущем пункте, но запускаем другую команду:
mvn compile jib:dockerBuild
Размер и структура слоёв не отличается от образа, который мы собрали без докера.
Выводы по Jib
Никогда не пользовался этим плагином в проектах, только слышал о нём, так что не могу делать каких-то глубоких выводов. Удивило, что он может работать без докера, иногда это может оказаться полезным. Но не вижу смысла в его использовании, когда есть плагин от спринга.
Продвинутая сборка с Dockerfile
Хочешь сделать что-то хорошо, сделай это сам. Никакой плагин не знает ваше приложение так, как его знаете вы. Поэтому следующий уровень — это написание оптимизированных Dockerfile.
Первое, что сделаем — это уменьшим JDK. Это возможно благодаря модулям, которые добавили с 9 версии Java. Все классы в JDK разделили на эти самые модули. Будем использовать те из них, которые действительно необходимы.

Но как узнать, какие модули необходимы приложению, а какие нет? Более того, нужно также не забыть про все зависимости приложения. Если они будут использовать класс из какого-то модуля, который вы не подключите, то приложение в лучшем случае не соберётся, в худшем упадёт в рантайме.
Чтобы определить нужные модули, воспользуемся утилитой jdeps
, которая находится в папке bin
вашего JDK. Но сначала мы должны собрать наш jar, и получить все зависимости. Для этого запускаем команду:
mvn clean install dependency:copy-dependencies
Сканируем все полученные jar:
jdeps --ignore-missing-deps -q -recursive --multi-release 9 --print-module-deps --class-path 'target/dependency/*' target/*.jar
Эта команда выведет вам названия модулей, которые используется приложением и всеми зависимостями. В моём случае это:
java.base,java.compiler,java.desktop,java.instrument,java.management,java.prefs,java.rmi,java.scripting,java.security.jgss,java.sql.rowset,jdk.httpserver,jdk.jfr,jdk.unsupported
Теперь, когда мы знаем,какие модули из JDK задействованы в приложении, соберём собственный JDK из этих модулей с помощью утилиты jlink
.
jlink --add-modules [сюда_ваши_модули] --strip-debug --no-man-pages --no-header-files --compress=2 --output javaruntime
Вам необходимо заменить значение флага --add-modules на наименования пакетов, которые нашёл jdeps. В результате выполнения этой команды вы получите папку javaruntime в корне проекта. Это урезанная версия JDK конкретно под ваш jar. Урезанная версия в моем случае весит 50 mb, а целиком JDK весит 315 mb.
Займёмся оптимизацией spring-boot-jar. Думаю, ни для кого не секрет, что spring-boot-jar можно просто распаковать, используя архиватор:

Здесь находятся все зависимости, включая Tomcat, код и ресурсы приложения. Есть смысл сделать также, как делают spring-plugin и jib: разложить всё по слоям.
Но прежде распакуем наш jar не архиватором, а воспользуемся утилитой layertools
. Она позволяет распаковать наш jar, но делает это немного умнее.
Создадим отдельную папку и перейдём в неё:
mkdir build-app && cd build-app
После чего запустим команду распаковки:
java -Djarmode=layertools -jar ../target/*.jar extract
Должны появиться 4 папки:
application
. Здесь лежит ваш код.snapshot-dependencies
. Здесь лежат snapshot зависимости.spring-boot-loader
. Здесь лежат запускаторы спринга.dependencies
. Здесь лежат релизные зависимости.
Самое время собрать все полученные знания в единый Dockerfile.
FROM eclipse-temurin:17 as app-build
ENV RELEASE=17
WORKDIR /opt/build
COPY ./target/spring-boot-*.jar ./application.jar
RUN java -Djarmode=layertools -jar application.jar extract
RUN $JAVA_HOME/bin/jlink \
--add-modules `jdeps --ignore-missing-deps -q -recursive --multi-release ${RELEASE} --print-module-deps -cp 'dependencies/BOOT-INF/lib/*':'snapshot-dependencies/BOOT-INF/lib/*' application.jar` \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output jdk
FROM debian:buster-slim
ARG BUILD_PATH=/opt/build
ENV JAVA_HOME=/opt/jdk
ENV PATH "${JAVA_HOME}/bin:${PATH}"
RUN groupadd --gid 1000 spring-app \
&& useradd --uid 1000 --gid spring-app --shell /bin/bash --create-home spring-app
USER spring-app:spring-app
WORKDIR /opt/workspace
COPY --from=app-build $BUILD_PATH/jdk $JAVA_HOME
COPY --from=app-build $BUILD_PATH/spring-boot-loader/ ./
COPY --from=app-build $BUILD_PATH/dependencies/ ./
COPY --from=app-build $BUILD_PATH/snapshot-dependencies/ ./
COPY --from=app-build $BUILD_PATH/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
Возможно, выглядит страшно, но здесь почти всё вам уже знакомо. Не забудьте выполнить команду mvn clean package
, чтобы получить папку target
с jar файлом.
Dockerfile содержит 2 команды FROM
. Это называется многоэтапная сборка. Первым этапом мы собираем нужный JDK под наше приложение, а вторым этапом собираем образ. Здесь тонкость в том, что во второй этап сборки мы возьмём только нужные файлы, а именно файлы JDK.
Разберём первый этап. Нам не важно, какой образ указывать, главное, чтобы там был установлен JDK. Мы переносим туда наш jar, после чего распаковываем его, узнаем какие используются зависимости и собираем JDK. На этом первый этап сборки завершён. Преходим ко второму.
Здесь в качестве базового образа мы используем образ, на основе которого будет работать приложение в рантайме. Обычно это самый тонкий linux, который возможно. Далее указываем переменную среды JAVA_HOME
и добавляем её в PATH
.
После чего мы копируем наш JDK и папки слоёв jar-инка из предыдущего этапа сборки, для этого вместе с COPY
используем флаг --from=app-build
, который говорит, что мы копируем файлы не с нашей машины, а с этапа сборки под алиасом app-build
. Не забудьте про последовательность, чем меньше шанс изменения слоя, тем раньше он указывается. Последней строкой прописываем loader спринга, который запустит наше приложение.
Переходим к исследованию слоев образа. Наш образ весит всего 173 mb.


- 63 mb — это Linux;
- 338 kb — добавилось из-за создания пользователя;
- 56 mb — наш кастомный JDK;
- 53 mb — релизные зависимости;
- 252 kb — загрузчики спринга;
- 14 kb — снепшот зависимости;
- 34 kb — наш код и ресурсы;
Выводы по Dockerfile-pro
Мы устранили почти все минусы Dockerfile, превратив их в достоинства. При такой сборке у нас есть возможность настроить все максимально под себя. Сто́ит отметить, что это самый маленький образ из всех.
При этом вы можете использовать возможности сборки под различные платформы. У плагинов такая возможность отсутствует.
Резюмирую
В этой статье мы рассмотрели использование различные способы создания образа. Каждый имеет свои плюсы и минусы.
Если необходимо быстро получить результат, то воспользуйтесь spring-boot-maven-plugin или Jib. Помните, что они пока не умеют собирать образы под ARM процессоры, а jib запускает приложение от имени root пользователя.
Правильно написанный Dockerfile будет лучшим вариантом для большой системы. Уделите написанию Dockerfile особое внимание.