Если вы уже достаточно долго пишете на 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, мог использоваться и другими проектами.
Сравнение размеров образов
Вот и все. Теперь вы знаете, как ужимать размер образа 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.
Все просто! Удачи вам.
P/s продолжается осенняя распродажа