Оптимальный Docker Image для Spring Boot

Рассмотрим популярные способы упаковки приложения в контейнер. Напишем свой оптимальный Dockerfile для Spring Boot.

· 10 минуты на чтение
Оптимальный Docker Image для Spring Boot

Мы живём в эпоху микросервисной архитектуры, и контейнеры стали основным средством упаковки приложения, а затем его доставки в различные среды. При этом многие разработчики не уделяют достаточно внимания на то, как правильно упаковать сервис, как сделать это оптимально, и главное не оставить дыры в безопасности. В этой статье мы разберём 4 способа упаковки:

Для экспериментов будем использовать следующий проект: github.com/Example-uPagge/spring_boot_docker. Это Spring Boot приложение, которое содержит пару простых контроллеров, репозитории с H2 базой данных.

Видео на YouTube
📹

На освное этой статьи я выступал с докладом на конференции Podlodka Java Crew: https://www.youtube.com/watch?v=MAFYm9tv-R4

Спонсор поста

Простой Dockerfile

😺
Ветка в проекте: 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

😺
Ветка в проекте: spring-boot-maven-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 провел некоторые оптимизации за нас, о которых он сообщает в этом логе:

  1. Он явно еще не умеет собирать образы под M1.
  2. Установил 5 активных процессоров.
  3. Посчитал доступную память для JVM. А далее распределил ее.
  4. Отобразил все дополнительные ключи, которые применил при запуске.

Выводы по spring-plugin

Ещё более простой способ упаковки, который использует оптимизации, так как понимает особенности SpringBoot Java приложений. Главное — следите за тем, что он делает под капотом, чтобы эти достоинства не превратились в проблемы.

Если у вас arm процессор, то образ всё равно соберётся под amd64.

Для запуска приложения создаётся отдельный пользователь. Это считается более безопасным способом.

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

Хороший способ упаковки для простых пет-проектов.

Плагин Jib

😺
Ветка в проекте: jib-plugin

Это инструмент с открытым исходным кодом от 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
⚠️
После сборки я искал образ в своём локальном хранилище. А потом до меня дошло. В этом режиме докер не используется, поэтому образ был отправлен в registry на Docker Hub.

Спуливаем образ и изучаем с помощью dive:

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

  • 78 + 48 mb — это linux и различные сертификаты.
  • 140 mb — это JDK.
  • 55 mb — слой с релизными зависимостями
  • 14 kb — слой со снепшотными зависимостями
  • 981 byte — это только ресурсы. Папка resources
  • 22 kb — это написанный нами код.
⚠️
Jib запускает ваш образ от имени root пользователя.

Подобно плагину от спринга Jib вынес зависимости в отдельные слои, но он пошёл ещё дальше и для папки с ресурсами также создал отдельный слой. Ведь ресурсы тоже редко меняются, но весить при этом они могут много. Например, если у вас много скриптов миграции Liquibase.

Сборка с использованием докера

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

mvn compile jib:dockerBuild

Размер и структура слоёв не отличается от образа, который мы собрали без докера.

Выводы по Jib

Если у вас arm процессор, то образ всё равно соберётся под amd64.

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

Продвинутая сборка с Dockerfile

😺
Ветка в проекте: dockerfile-pro

Хочешь сделать что-то хорошо, сделай это сам. Никакой плагин не знает ваше приложение так, как его знаете вы. Поэтому следующий уровень — это написание оптимизированных 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
⚠️
Возможно, что флаг --multi-release 9 вам не подойдет, попробуйте его изменить

Эта команда выведет вам названия модулей, которые используется приложением и всеми зависимостями. В моём случае это:

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"]
⚠️
Если в проекте не используются snapshot-зависимости, то удалите 'snapshot-dependencies/BOOT-INF/lib/' из команды jlink. Иначе сборка упадёт, так как папок /BOOT-INF/lib/ не будет.
⚠️
Для сборки этого Dockerfile на Windows замените 'dependencies/BOOT-INF/lib/*':'snapshot-dependencies/BOOT-INF/lib/*' на 'dependencies/BOOT-INF/lib/*';'snapshot-dependencies/BOOT-INF/lib/*'Всё дело в разделителе, в linux используется ':', тогда как в Windows ';'.

Возможно, выглядит страшно, но здесь почти всё вам уже знакомо. Не забудьте выполнить команду 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 особое внимание.

Struchkov Mark
Struchkov Mark
Задавайте вопросы, если что-то осталось не понятным👇