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

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

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

Мы живём в эпоху микросервисной архитектуры, и контейнеры стали основным средством упаковки приложений и их доставки в различные среды.

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

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

📹
На основе этой статьи я выступил с докладом на конференции 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-реестра, он вытягивается по слоям, которые кэшируются на хосте.

Наш Docker-образ состоит из следующих слоёв:

  • Базовый слой — минимальная версия Linux (чем он тоньше, тем лучше).
  • JDK слой.
  • Слой приложения, представляющий собой jar-файл.

Spring Boot по умолчанию использует формат “Fat JAR”, который включает в себя все зависимости, необходимые для запуска.

Чтобы создать образ, сначала собираем приложение с помощью Maven:

mvn clean package

Затем запускаем сборку Docker-образа:

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 upagge/spring-boot-docker:dockerfile

Общий размер образа составил 448 MB, из которых:

  • 63 MB + 8.4 MB — слой с Linux,
  • 324 MB — слой JDK,
  • 53 MB — наш Spring Boot jar-файл.

Обратите внимание, что слой с приложением весит 53 MB, даже при минимальном количестве кода. При любом изменении в коде нам придётся заново отправлять и загружать этот слой, в то время как остальные слои будут кэшироваться и не потребуют повторной передачи. В дальнейшем мы рассмотрим, как можно уменьшить размер этого слоя.

Чтобы убедиться, что всё работает, запускаем образ:

 docker run -p 8080:8080 upagge/spring-boot-docker:0.0.1

Открываем браузер и переходим по адресу: http://localhost:8080/api/person/1. Если образ запущен успешно, вы увидите слово valid.

Выводы по Dockerfile

Использование простого Dockerfile — это лёгкий и быстрый способ упаковать приложение, однако у него есть как плюсы, так и минусы.

Плюсы этого способа:

  • Полный контроль над образом: можно настроить его как угодно.
  • Простота реализации.

Минусы:

  • Полный контроль означает, что можно случайно нарушить безопасность.
  • Даже при минимальной разработке образ уже имеет значительный вес.
  • Большой объём изменяемого слоя (приложение).
  • Запуск приложения от имени пользователя root.

Spring Boot Plugin

😺
Ветка в проекте: spring-boot-maven-plugin

Spring Boot значительно упрощает разработку приложений. Добавляем несколько стартеров, заполняем файл application.properties — и готово, микросервис готов. Серьёзно, взгляните на проект Spring Data REST: он автоматически генерирует контроллеры на основе интерфейсов JpaRepository.

Для контейнеризации Spring Boot также предлагает удобное решение — это плагин spring-boot-maven-plugin. Он не только преобразует обычный jar-файл в самодостаточный jar со встроенным Tomcat, но и может собрать полноценный Docker-образ.

Сначала зададим название для будущего Docker-образа в конфигурации плагина. Если тег не указан, будет автоматически проставлен тег 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>

Для сборки Docker-образа используем команду:

mvn spring-boot:build-image

И вот у нас готов рабочий образ. Теперь исследуем его слои с помощью утилиты dive:

dive upagge/spring-boot-docker:spring-plugin

Созданный образ весит 309 MB, что на 139 MB меньше, чем образ, который мы собрали вручную. Но самое интересное — это структура слоёв:

  • 63 MB — всё ещё слой с Linux.
  • 24 MB — различные сертификаты.
  • 1.4 KB — добавлен пользователь, от имени которого будет работать приложение.
  • 157 MB — слой JDK.
  • 53 MB — слой релизных зависимостей.
  • 252 KB — слой загрузчиков Spring Boot.
  • 14 KB — слой с snapshot-зависимостями.
  • 34 KB — непосредственно код приложения и ресурсы.

Здесь проделана важная оптимизация — зависимости приложения вынесены в отдельные слои, что позволяет Docker кэшировать их и повторно использовать. Это значит, что при изменении кода нам нужно будет пересобирать и передавать только 3-5 MB, а не все 53 MB (вес jar-файла).

Snapshot-зависимости также вынесены в отдельный слой, так как они чаще изменяются по сравнению с релизными зависимостями.

Запустим наш контейнер.

docker run -p 8080:8080 upagge/spring-boot-docker:spring-plugin

Перед запуском Spring Boot выводит в логи множество информации, включая произведённые оптимизации:

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

Выводы по spring-plugin

Это ещё более простой способ упаковки приложений, который автоматически применяет оптимизации с учётом особенностей Spring Boot Java-приложений. Однако важно следить за тем, что плагин делает “под капотом”, чтобы избежать потенциальных проблем.

Плюсы:

  • Плагин создаёт Docker-образ без необходимости написания Dockerfile.
  • Используются оптимизации, такие как кэширование зависимостей и выделение snapshot-зависимостей в отдельный слой.
  • Создаётся отдельный пользователь для запуска приложения, что улучшает безопасность.

Минусы:

  • Отсутствие гибкости. В сложных проектах может потребоваться настройка Docker-образа, которую этот плагин не предоставляет.
  • Образы по умолчанию собираются под архитектуру amd64, что может быть проблемой на процессорах ARM (например, M1).

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

Плагин Jib

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

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

Одним из главных преимуществ Jib является возможность сборки Docker-образов без необходимости установки Docker на вашем компьютере. Для этого используется команда:

mvn compile jib:build
👀
После сборки я сначала попытался найти образ в локальном хранилище Docker, но потом понял, что Docker в этом режиме не используется, поэтому образ был отправлен напрямую в реестр Docker Hub.

Для анализа слоёв образа используем утилиту dive:

dive upagge/spring-boot-docker:jib-plugin

Итоговый размер образа составил 321 MB, что на 12 MB больше по сравнению с образом, собранным с помощью spring-boot-maven-plugin. Структура слоёв получилась следующей:

  • 78 MB + 48 MB — слои с Linux и различными сертификатами.
  • 140 MB — слой с JDK.
  • 55 MB — слой с релизными зависимостями.
  • 14 KB — слой со snapshot-зависимостями.
  • 981 byte — слой с ресурсами (папка resources).
  • 22 KB — это код приложения.
Jib запускает контейнер от имени пользователя root, что важно учитывать с точки зрения безопасности.

Как и плагин Spring Boot, Jib выносит зависимости в отдельные слои, но он идёт дальше: для папки с ресурсами (resources) также создаётся отдельный слой. Это полезно, потому что ресурсы, такие как скрипты миграции Liquibase, часто не меняются, но могут занимать значительный объём.

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

Для сборки с Docker нужно добавить Jib в конфигурацию Maven, как указано выше, но выполнить другую команду:

mvn compile jib:dockerBuild

азмер и структура слоёв останутся такими же, как и при сборке без Docker.

Выводы по Jib

Jib — это мощный инструмент для упаковки приложений в контейнеры, особенно когда Docker недоступен. Однако, как и в случае с другими плагинами, есть несколько нюансов:

  • Jib собирает образ под архитектуру amd64, даже если у вас ARM-процессор (например, M1). Это может быть проблемой на некоторых системах.
  • Jib автоматически оптимизирует слои образа, разделяя зависимости и ресурсы на отдельные слои, что ускоряет процесс сборки и уменьшает объём передаваемых данных при внесении изменений.
Спонсор поста 3

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

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

Если хочешь сделать что-то хорошо, сделай это сам. Никакой плагин не знает ваше приложение так хорошо, как знаете его вы. Поэтому следующий шаг — это написание оптимизированного Dockerfile.

Уменьшение размера JDK

Первое, что мы сделаем — уменьшим размер JDK. Благодаря модулям, которые появились с версии Java 9, это стало возможным. Все классы в JDK теперь разделены на модули, и мы будем использовать только те из них, которые действительно необходимы для работы приложения.

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

Для определения нужных модулей воспользуемся утилитой jdeps, которая находится в папке bin вашего JDK. Но прежде чем её использовать, нужно собрать проект и получить все зависимости. Выполним команду:

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

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

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 с помощью утилиты jlink:

jlink --add-modules [сюда_ваши_модули] --strip-debug --no-man-pages --no-header-files --compress=2 --output javaruntime

Замените значение флага --add-modules на список модулей, найденных jdeps. В результате выполнения этой команды вы получите папку javaruntime в корне проекта — это урезанная версия JDK, настроенная именно для вашего приложения.

В моём случае урезанная версия весила 50 MB, тогда как полный JDK занимает 315 MB.

Оптимизация Spring Boot JAR

Следующий шаг — оптимизация spring-boot-jar. Не секрет, что spring-boot-jar можно распаковать обычным архиватором.

Внутри находятся все зависимости, включая Tomcat, а также код и ресурсы приложения. Однако, чтобы оптимизировать процесс сборки, стоит разложить содержимое JAR-файла по слоям, как это делают плагины spring-boot-maven-plugin и jib.

Но вместо обычной распаковки архиватором, мы воспользуемся утилитой layertools, которая распаковывает JAR-файл более разумным способом.

Создадим отдельную папку и перейдём в неё:

mkdir build-app && cd build-app

Затем запустим команду для распаковки JAR-файла:

java -Djarmode=layertools -jar ../target/*.jar extract

После выполнения команды появятся 4 папки:

  • application — ваш код.
  • snapshot-dependencies — snapshot-зависимости.
  • spring-boot-loader — загрузчики Spring Boot.
  • dependencies — релизные зависимости.

Подготовка Dockerfile

Теперь, когда мы разобрались с отдельными шагами, самое время собрать всё это в единый 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. В противном случае сборка завершится ошибкой, так как эта папка не будет создана.
Для сборки этого Dockerfile на Windows необходимо заменить 'dependencies/BOOT-INF/lib/*':'snapshot-dependencies/BOOT-INF/lib/*' на 'dependencies/BOOT-INF/lib/*';'snapshot-dependencies/BOOT-INF/lib/*'.

Всё дело в разделителе: в Linux используется :, тогда как в Windows — ;.

Этот Dockerfile может показаться сложным, но на самом деле здесь почти всё уже вам знакомо. Не забудьте выполнить команду mvn clean package, чтобы получить папку target с JAR-файлом.

Dockerfile содержит две инструкции FROM, что называется многоэтапной сборкой. На первом этапе мы создаём кастомный JDK, настроенный под наше приложение, а на втором — собираем сам образ. Важный момент: на втором этапе мы копируем только нужные файлы, в том числе файлы JDK, собранные на первом этапе.

Первый этап: сборка кастомного JDK. В этом этапе важно только то, что образ содержит JDK. Мы копируем туда наш JAR-файл, распаковываем его, определяем необходимые зависимости и создаём JDK с помощью jlink. На этом этапе сборка завершается.

Второй этап: сборка финального образа Для этого этапа мы используем минимальный образ Linux (например, debian:buster-slim), который подходит для работы в рантайме. Затем задаём переменную окружения JAVA_HOME и добавляем её в PATH. После этого копируем кастомный JDK и папки слоёв из предыдущего этапа, используя флаг --from=app-build, который позволяет копировать файлы не с локальной машины, а с этапа сборки под именем app-build.

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

💡
Папку с ресурсами можно также вынести в отдельный слой.

Исследование образа

Наш образ весит всего 173 MB, что является отличным результатом для такого типа приложений:

  • 63 MB — слой с Linux.
  • 338 KB — добавилось из-за создания пользователя.
  • 56 MB — наш кастомный JDK.
  • 53 MB — релизные зависимости.
  • 252 KB — загрузчики Spring Boot.
  • 14 KB — snapshot-зависимости.
  • 34 KB — наш код и ресурсы.

Выводы по Dockerfile-pro

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

При этом вы можете использовать возможности сборки под различные платформы. У плагинов такая возможность отсутствует.

Резюмирую

В этой статье мы рассмотрели различные способы создания Docker-образов для Spring Boot приложений. Каждый из них имеет свои преимущества и недостатки.

Если вам нужно быстро получить результат, используйте spring-boot-maven-plugin или Jib. Однако стоит помнить, что они пока не поддерживают сборку образов для ARM процессоров, а Jib запускает приложение от имени пользователя root, что может повлиять на безопасность.

Для крупных систем наилучшим вариантом будет правильно написанный Dockerfile. Уделите особое внимание его созданию, так как это позволит вам оптимизировать образ под специфические требования вашего приложения.

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