image

Если вы уже достаточно долго пишете на 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.

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

P/s продолжается осенняя распродажа

Комментарии (14)


  1. fshp
    19.10.2022 19:36
    +2

    В итоге имеем 2 образа вместо одного.

    Кастомный дистрибутив jre полезен для доставки его на компьютеры пользователей. Вот для этого инструкция будет полезна.

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


    1. karavan_750
      19.10.2022 22:31

      Есть мнение, что дебаг-символы/дебаг-утилиты не должны выезжать за пределы stage-стендов.
      Потому, наличие разных образов для dev, test, prod не является чем-то предосудительным.


      1. ggo
        20.10.2022 11:42

        наличие разных образов для dev, test, prod не является чем-то предосудительным.

        Не то чтобы, это ужас-ужас.

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


        1. karavan_750
          20.10.2022 14:11
          -1

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


          1. fshp
            20.10.2022 18:41
            +1

            Вы никогда не снимали дампы с прода?

            А что вы будете делать, если вдруг ваше приложение начнет жрать 100% cpu без видимых на то причин?


            1. karavan_750
              20.10.2022 22:24

              А что вы будете делать, если вдруг ваше приложение начнет жрать 100% cpu без видимых на то причин?

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


              1. fshp
                20.10.2022 22:36

                Недавний релиз это видимая причина.

                А вот если приложение пару месяцев не трогали, а потом оно в полнолуние взорвалось.


                1. karavan_750
                  20.10.2022 23:34

                  Сбор метрик, рестарт приложения, дебаг на тестовом стенде.


                  1. ggo
                    21.10.2022 10:15
                    +1

                    Не переживайте. У вас все еще впереди. ;)

                    Всем рано или поздно приходится когда-нибудь снимать дампы с прода. Понятно, это происходит не каждый день. Но бывают кейсы, когда ошибка на тесте не воспроизводится. Даже при всем обилии инструментов трейсинга трафика и логирования.


  1. sancoder
    19.10.2022 19:59

    Есть еще вариант использовать GraalVM - это машина, которая компилирует java class файлы в машинный код, попутно откусывая все ненужное. В итоге - hello world приложение сокращено до 1.5МБ.

    https://www.youtube.com/watch?v=6wYrAtngIVo


    1. sandersru
      20.10.2022 00:39

      Которое за собой тянет *.so библиотеки от graal.

      Статическая линковка ~10-12 мб и того около 20мб на двоих с alpine


      1. sancoder
        20.10.2022 01:18

        Как жаль что презентуюзий не знал об этом и показал вывод команды ldd в видео.


        1. sandersru
          20.10.2022 01:40

          на 19.30 - 19mb статика


  1. razornd
    19.10.2022 20:07
    +4

    Ну или можно использовать образы LibericaJdk. У них есть образы jre-alpine (55 MB), и jre-alpine-musl (чуть меньше 50 MB).