Как уменьшить размер образа Docker для JVM

Kate

Administrator
Команда форума
Если вы уже достаточно долго пишете на Kotlin, или Scala, или на любом другом языке, основанном на JVM, то могли заметить: начиная с Java 11 среда Java Runtime Environment (JRE) больше не поставляется в виде отдельного дистрибутива, а распространяется только в составе Java Development Kit (JDK). В результате такого изменения многие официальные образы Docker не предлагают вариант образа «только для JRE». Таковы, например, официальные образы openjdk, образы corretto от Amazon. В моем случае при использовании такого образа в качестве заготовки получался образ приложения, завешивавший на 414 MB, тогда как само приложение занимало всего около 60 MB. Мы стремимся к эффективной и бережливой разработке, поэтому такая расточительность для нас непозволительна.

Давайте же рассмотрим, как можно радикально уменьшить размер Docker-образа для Java.

Задача​


В Java 9 появилась подсистема платформенных модулей (JPMS). С ее помощью можно создать собственный уникальный JRE-образ, оптимизированный именно под наши нужды. Например, если приложение никоим образом не использует сетевой стек или никак не взаимодействует со средой настольного ПК, то можно исключить из образа пакеты java.net и java.desktop, сэкономив несколько мегабайт.

Причем, начиная с Java 11, для JRE не предусмотрен свой отдельный дистрибутив, поэтому ее невозможно установить, не устанавливая JDK.

Все дело в модульности, которая была внедрена в язык Java в версии 9. Нет необходимости пытаться распространять один вариант JRE на все случаи жизни – напротив, любой может создать такой образ JRE, который будет отвечать его личным потребностям.

Именно такой философии придерживаются многие специалисты, занимающиеся поддержкой образов Docker: они обходятся без эксклюзивных образов JRE, а просто поставляют образы в составе JDK.

К сожалению, если вы пользуетесь образами «как есть», то зря растрачиваете пространство реестре образов Docker, а также впустую расходуете полосу передачи данных на вашей локальной машине и в сети, загружая и скачивая эти файлы. JDK оснащается инструментами, исходниками и документацией, но эти ресурсы в основном не понадобятся вам при эксплуатации вашего приложения.

Давайте для примера воспользуемся этим репозиторием. В нем лежит маленькое приложение, запускающее веб-сервер на порту 8080 и выводящее “Hello, world!” в ответ на запрос GET.

Вот как будет выглядеть Dockerfile для типичного образа, основанного на JDK:

jdk.dockerfile
FROM amazoncorretto:17.0.3-alpine

# Добавить пользователя приложения
ARG APPLICATION_USER=appuser
RUN adduser --no-create-home -u 1000 -D $APPLICATION_USER

# Сконфигурировать рабочий каталог
RUN mkdir /app && \
chown -R $APPLICATION_USER /app

USER 1000

COPY --chown=1000:1000 ./app.jar /app/app.jar
WORKDIR /app

EXPOSE 8080
ENTRYPOINT [ "java", "-jar", "/app/app.jar" ]

Здесь в качестве основы используется образ corretto JDK от Amazon. Для запуска приложения создается пользователь, не обладающий правами администратора. Затем в этот образ копируется jar-файл.

Давайте соберем образ и проверим, каков его размер:

docker build -t jvm-in-docker:jre -f jre.dockerfile .
docker image ls | grep -e "jvm-in-docker.*jdk"

Вот как в моем случае выглядит вывод:

jvm-in-docker jdk 4126e7e5ce37 51 minutes ago 341MB

Т.е, размер образа равен 341 MB. Как-то многовато для jar-файла размером 7 MB, верно? Вот что можно с этим сделать.

Решение​


Наряду с модульностью в Java 9 появился новый инструмент под названием jlink. Этот инструмент нужен для сборки собственного образа JRE, оптимизированного под конкретный вариант использования. В нем предоставляется несколько опций тонкой настройки JRE-образа и модулей, но также существует способ сделать его достаточно универсальным (включить все модули). Сначала давайте рассмотрим универсальный пример:

jre.dockerfile
# базовый образ для сборки JRE
FROM amazoncorretto:17.0.3-alpine as corretto-jdk

# требуется, чтобы работал strip-debug
RUN apk add --no-cache binutils

# собираем маленький JRE-образ
RUN $JAVA_HOME/bin/jlink \
--verbose \
--add-modules ALL-MODULE-PATH \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /customjre

# главный образ приложения
FROM alpine:latest
ENV JAVA_HOME=/jre
ENV PATH="${JAVA_HOME}/bin:${PATH}"

# копируем JRE из базового образа
COPY --from=corretto-jdk /customjre $JAVA_HOME

# Добавляем пользователя приложения
ARG APPLICATION_USER=appuser
RUN adduser --no-create-home -u 1000 -D $APPLICATION_USER

# Конфигурируем рабочий каталог
RUN mkdir /app && \
chown -R $APPLICATION_USER /app

USER 1000

COPY --chown=1000:1000 ./app.jar /app/app.jar
WORKDIR /app

EXPOSE 8080
ENTRYPOINT [ "/jre/bin/java", "-jar", "/app/app.jar" ]

Давайте разберем этот файл:
• Здесь применяется ступенчатая сборка, проходящая в 2 этапа.
• На первом этапе мы используем все тот же образ corretto от Amazon.
• Мы устанавливаем пакет binutils (без него не будет работать jlink), а затем запускаем jlink. Можете посмотреть описание опций в документации Oracle, но наиболее нас интересует в данном файле следующая строка: --add-modules ALL-MODULE-PATH. Она приказывает jlink включить в образ все доступные модули.
• На втором этапе сборки мы копируем получившийся у нас образ JRE из первого этапа и выполняем точно такую же конфигурацию, как проделана здесь.

Теперь давайте соберем этот образ и проверим, каков его размер:

docker build -t jvm-in-docker:jre -f jre.dockerfile .
docker image ls | grep -e "jvm-in-docker.*jre "

У меня получается вот такой вывод:

jvm-in-docker jre 15522f93ea6c 51 minutes ago 103MB

Итак, размер образа составил 103 MB. Втрое меньше, чем в первый раз, а ведь здесь у нас включены все модули! Может быть, и этот результат можно улучшить? Давайте посмотрим!

Когда размер имеет значение​


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

Для этого воспользуемся jdeps. Инструмент Jdeps впервые появился в Java 8 и может использоваться для анализа зависимостей в нашем приложении. Но в данном случае нас наиболее интересуют, какие зависимости в данном случае есть у модуля Java. Самое сложное здесь то, что не все зависимости обязательны для работы самого приложения, но некоторые из них обязательны для используемых нами библиотек. К счастью, jdeps умеет обнаруживать и такие зависимости.

Для упаковки приложений я использую плагин Gradle для создания дистрибутивов. Применить jdeps в таком случае не составляет труда, просто выполните:

./gradlew installDist # так мы соберем и распакуем дистрибутив
jdeps --print-module-deps --ignore-missing-deps --recursive --multi-release 17 --class-path="./app/build/install/app/lib/*" --module-path="./app/build/install/app/lib/*" ./app/build/install/app/lib/app.jar

Здесь “app” – это имя нашего модуля Gradle.

В случае, если вы используете так называемый толстый jar (он же uber-jar) jdeps, вы, к сожалению, не сможете проанализировать зависимости jar внутри jar, поэтому сначала вам придется распаковать jar-файл. Вот как это делается:

mkdir app
cd ./app
unzip ../app.jar
cd ..
jdeps --print-module-deps --ignore-missing-deps --recursive --multi-release 17 --class-path="./app/BOOT-INF/lib/*" --module-path="./app/BOOT-INF/lib/*" ./app.jar
rm -Rf ./app

Как видите, здесь мы сначала распаковываем jar, а потом выполняем jdeps с несколькими аргументами. Подробнее об аргументах можно почитать в документации Oracle, но вот что будет происходить здесь: jdeps выведет на экран список зависимостей модулей. Выглядеть это должно так:

java.base,java.management,java.naming,java.net.http,java.security.jgss,java.security.sasl,java.sql,jdk.httpserver,jdk.unsupported

Примечание:
Кажется, в версии jdeps 17.x.x есть баг, приводящий к
a com.sun.tools.jdeps.MultiReleaseException
. Если вы получаете такое исключение, попробуйте установить jdeps из JDK 18.

Теперь мы должны взять этот список и заменить им ALL-MODULE-PATH в файле Docker из предыдущего шага. Вот так:

jre-slim.dockerfile
# базовый образ для сборки JRE
FROM amazoncorretto:17.0.3-alpine as corretto-jdk

# требуется, чтобы работал strip-debug

RUN apk add --no-cache binutils

# собираем маленький JRE-образ
RUN $JAVA_HOME/bin/jlink \
--verbose \
--add-modules java.base,java.management,java.naming,java.net.http,java.security.jgss,java.security.sasl,java.sql,jdk.httpserver,jdk.unsupported \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /customjre

# главный образ приложения
FROM alpine:latest
ENV JAVA_HOME=/jre
ENV PATH="${JAVA_HOME}/bin:${PATH}"

# копируем JRE из базового образа
COPY --from=corretto-jdk /customjre $JAVA_HOME

# Добавляем пользователя приложения
ARG APPLICATION_USER=appuser
RUN adduser --no-create-home -u 1000 -D $APPLICATION_USER

# Конфигурируем рабочий каталог
RUN mkdir /app && \
chown -R $APPLICATION_USER /app

USER 1000

COPY --chown=1000:1000 ./app.jar /app/app.jar
WORKDIR /app

EXPOSE 8080
ENTRYPOINT [ "/jre/bin/java", "-jar", "/app/app.jar" ]

Давайте соберем этот образ и проверим, каков его размер:

docker build -t jvm-in-docker:jre-slim -f jre-slim.dockerfile .
docker image ls | grep -e "jvm-in-docker.*jre-slim"

Вот что у меня получилось:

jvm-in-docker jre-slim c8513c84b324 58 minutes ago 55.1MB

То есть, размер образа всего 55 MB. В 6 раз меньше, чем у исходного! Весьма впечатляет.
Но здесь есть засада. Если ваше приложение активно разрабатывается, то в какой-то момент вы можете добавить к библиотеке зависимость, которая свяжет ее с модулем Java, не включенным в ваш образ. В таком случае вам потребуется заново проанализировать зависимости, чтобы собрать работающий образ. В идеале этот процесс даже можно автоматизировать, но именно вам решать, стоит ли дело таких хлопот. Образ JRE, в который включены все модули, пригоден для многоразового использования во множестве проектов – поэтому мы сэкономим место в реестре образов. При этом самые специфические образы могут быть использованы всего в одном проекте каждый.

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

Резюме​


Как видите, совсем немного постаравшись, можно ужать размер образа как минимум втрое.

Есть два варианта:
• Собрать универсальный образ JRE, в котором будут включены все модули, и который можно будет использовать с любым приложением;
• Собрать специализированный образ JRE, который будет рассчитан на конкретный вариант использования – поэтому получится небольшим, но при этом не таким универсальным.

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

В нашем проекте мы решили остановиться на более универсальном варианте, чтобы на том уровне, где расположен образ JRE, мог использоваться и другими проектами.

Сравнение размеров образов
image



Вот и все. Теперь вы знаете, как ужимать размер образа Docker для работы с JVM.
Файлы Docker из приведенных примеров выложены здесь: monosoul/jvm-in-docker.

Бонус​


Мы также включаем пару приватных CA-сертификатов в хранилище сертификатов certificates JRE. Вот как это делается при использовании файла Dockerfile из вышеприведенного примера:

jre-with-certs.dockerfile
# базовый образ для сборки JRE
FROM amazoncorretto:17.0.3-alpine as corretto-jdk

# требуется, чтобы работал strip-debug
RUN apk add --no-cache binutils

# Собираем маленький образ JRE
RUN $JAVA_HOME/bin/jlink \
--verbose \
--add-modules ALL-MODULE-PATH \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /customjre

# главный образ приложения
FROM alpine:latest
ENV JAVA_HOME=/jre
ENV PATH="${JAVA_HOME}/bin:${PATH}"

# копируем JRE из базового образа
COPY --from=corretto-jdk /customjre $JAVA_HOME

# добавляем дополнительный CA-сертификат для root
ADD https://example.com/extra-ca.pem $JAVA_HOME/lib/security/extra-ca.pem
RUN echo "<sha256 sum of the certificate> $JAVA_HOME/lib/security/wolt-ca.pem" | sha256sum -c - && \
cd $JAVA_HOME/lib/security && \
keytool -cacerts -storepass changeit -noprompt -trustcacerts -importcert -alias extra-ca -file extra-ca.pem

# Добавляем пользователя приложения
ARG APPLICATION_USER=appuser
RUN adduser --no-create-home -u 1000 -D $APPLICATION_USER

# Конфигурируем рабочий каталог
RUN mkdir /app && \
chown -R $APPLICATION_USER /app

USER 1000

COPY --chown=1000:1000 ./app.jar /app/app.jar
WORKDIR /app

EXPOSE 8080
ENTRYPOINT [ "/jre/bin/java", "-jar", "/app/app.jar" ]

Давайте посмотрим, что происходит после строки 25:
• (строка 26) Сначала скачиваем сертификат, а затем кладем его в каталог с образом.
• (строки 27-28) Затем проверяем хеш-сумму сертификата, чтобы убедиться, что он не скомпрометирован.
• (строка 29) После этого импортируем сертификат в хранилище сертификатов JRE.

Все просто! Удачи вам.


 
Сверху