Slim Docker image, или Как уменьшить вес Java-приложения

3673

Slim Docker image, или Как уменьшить вес Java-приложения

Я расскажу о том, как мне удалось построить Docker-образ весом всего ~100-200 MB, базирующийся на Debian Buster slim, с использованием Java (версия 13.0.2).

В чем проблема

Если ты читаешь эту статью, значит, так же, как и я, интересуешься новыми технологиями в мире Java. Сейчас существует много готовых решений для быстрого старта проектов любой сложности, приложения пишутся очень быстро, и бизнес хочет получать результат как можно скорее, чтобы целевая аудитория смогла воспользоваться фичей в кратчайшие сроки. А это влечет за собой частые деплои на prod, stage-энвайронменты и т. д.

Все Java-разработчики знают, что приложение может весить, например, 100 MB (вместе с зависимостями), но, чтобы оно успешно запустилось и выполняло свою задачу, ему нужен еще JRE (Java Runtime Environment), который весит в среднем 200 MB. Сюда же нужно добавить операционную систему (далее — ОС), где будет исполняться JRE, а это еще ~50-100 MB (если использовать slim-сборку). Итого получается 350-400 MB.

Размещая Docker-образы (images) в каком-нибудь удаленном хранилище (Container Registry), например ECR, можно значительно сократить время, потраченное на передачу (загрузку или скачивание) данных, чтобы размер образа занимал всего ~100-200 MB. Помимо времени, сокращается также и плата за тарификацию удаленного хранилища: например, ECR берет плату за объем хранилища и количество переданных данных (Data Transfer). Поэтому, экономя деньги компании и время на доставку образов, я написал эту статью, которая покажет, как уменьшить вес Java-приложения с использованием готовых инструментов JDK.

Напишем простое приложение

Я буду использовать Spring Boot версии 2.2.4.RELEASE, куда подключу базовые зависимости:

  • spring-boot-starter-web — для написания простого REST-эндпоинта и запуска приложения в embedded Tomcat.
  • spring-boot-starter-log4j2 — для работы с логированием от Apache Log4j 2.
  • jackson-dataformat-yaml — чтобы Spring смог проинициализировать конфигурацию логирования из YAML-файла.

Собирать приложение будет Maven, поэтому опишем для него POM-файл следующим образом:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>slim-docker-image</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.4.RELEASE</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>13</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-logging</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-log4j2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.apache.tomcat.embed</groupId>
                    <artifactId>tomcat-embed-websocket</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.dataformat</groupId>
            <artifactId>jackson-dataformat-yaml</artifactId>
        </dependency>
    </dependencies>

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Само приложение не представляет из себя ничего интересного, поэтому не буду уделять ему много внимания, а просто покажу класс контроллера:

package com.example.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import static java.lang.String.format;

@Slf4j
@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello(@RequestParam(required = false) String name) {
        if (name == null) {
            name = "World!";
        }
        String response = format("Hello, %s", name);
        log.info("Created response '{}'", response);
        return response;
    }
}

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

После запуска вызовем эндпоинт GET запросом по адресу: localhost:8801/hello?name=Rostyslav

В итоге увидим в консоли примерно следующие логи:

Slim Docker image, или Как уменьшить вес Java-приложения
Slim Docker image, или Как уменьшить вес Java-приложения

Проанализировав содержимое, можем сделать вывод: приложение успешно выполняет свою задачу.

А что получается по размеру Jar?

Размер получился маленький (всего 17,43 MB), за счет того, что были подключены только базовые зависимости; ничего особенного и слишком тяжелого не используется. Но в любом другом большом проекте, где используется много различных библиотек и фреймворков, итоговый размер может достигать 100 MB и даже больше.

Сборка Docker-образа

Возьмем за основу openjdk:13.0.2-slim-buster. Его размер составляет 409 MB:

Напишем простой Dockerfile:

FROM openjdk:13.0.2-slim-buster
RUN mkdir -p /jar
COPY ./target/slim-docker-image.jar /jar/app.jar
ENTRYPOINT ["java","-jar","/jar/app.jar"]

и соберем образ. Итоговый размер получился 427 MB:

Теперь можно запустить контейнер на основе этого образа и убедиться, что приложение успешно запускается, как запускалось и без использования Docker:

Теперь проанализируем, сколько занимает установленный туда JDK 13.0.2:

Как видно на скриншоте, JDK занимает 316 из 409 MB размера всего образа.

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

Модули, jdeps и jlink

Для сборки кастомного slim JRE я буду использовать модульную систему, которая введена с 9-й версии Java. Полное ее название — Java Platform Module System, или JPMS. На помощь также придут инструменты jlink и jdeps, которые поставляются вместе с JDK.

При использовании jlink на выходе получается «урезанный» JRE, который будет включать только те модули, которые требует приложение и его зависимости, а все остальные модули просто не будут включены в сборку.

Прежде чем приступать к процессу сборки, нужно определиться, какие именно модули необходимы для запуска приложения. Для этого мы можем использовать инструмент jdeps. Он позволяет проанализировать JAR и вывести использующиеся модули. Но я бы хотел сразу прояснить некоторые моменты по этому инструменту и рассказать, почему я все же его не использую. Чтобы добиться правильного отображения использующихся модулей, надо потратить много времени, но каждый раз вылазит очередная ошибка, и ты пробуешь нагуглить решение. Например, возьмем следующую команду:

jdeps -cp "java -jar target/slim-docker-image.jar --thin.classpath" target/slim-docker-image.jar

Получается такой результат:

Как видим, есть модули not found, что не дает понимания, каких модулей в итоге не хватает. Изучая вопрос сборки JRE, я так и не смог добиться успешного вывода всех модулей из JAR, поэтому решил, что если даже после использования jdeps может броситься ClassNotFoundException, то почему бы самостоятельно не перебрать недостающие модули, запуская приложение на собранном JRE и анализируя stack trace? Тем более это не отнимает много времени, да и вообще, я уверен, что для других проектов не придется пересобирать новый JRE каждый день и даже каждый месяц. Это нужно будет сделать только в случае добавления новых зависимостей, да и то если они используют какой-то другой модуль, которого нет в сборке.

Итак, абсолютно для любого приложения требуется модуль java.base. Поэтому берем его и запускаем команду:

jlink --no-header-files --no-man-pages --compress=2 --strip-java-debug-attributes --add-modules java.base --output slim-jre

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

Пробуем запустить приложение на собранном JRE:

Бросился ClassNotFoundException: java.beans.PropertyChangeEvent. Идем сюда, в строку поиска вводим java.beans.PropertyChangeEvent и смотрим на имя модуля:

Добавляем его к команде jlink:

jlink --no-header-files --no-man-pages --compress=2 --strip-java-debug-attributes --add-modules java.base,java.desktop --output slim-jre

В этот раз бросается ClassNotFoundException: javax.naming.NamingException.

Снова идем в поиск и ищем javax.naming.NamingException, добавляем его модуль java.naming. Повторяем манипуляции до тех пор, пока приложение не запустится.

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

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

java.base,java.desktop,java.naming,java.management,java.security.jgss,java.instrument

Можем запустить приложение на собранном JRE и убедиться, что оно успешно работает, как и раньше:

Docker-образ с кастомным JRE

Берем за основу Dockerfile, который используется в openjdk, и модифицируем его таким образом, чтобы вместо установленного JDK использовался кастомный JRE. Ниже представлен уже готовый к использованию файл:

FROM debian:buster-slim

RUN set -eux; \
  apt-get update; \
  apt-get install -y --no-install-recommends \
# utilities for keeping Debian and OpenJDK CA certificates in sync
    ca-certificates p11-kit \
  ; \
  rm -rf /var/lib/apt/lists/*

# Default to UTF-8 file.encoding
ENV LANG C.UTF-8

ENV JAVA_HOME /usr/java/openjdk-13
ENV PATH $JAVA_HOME/bin:$PATH

# backwards compatibility shim
RUN { echo '#/bin/sh'; echo 'echo "$JAVA_HOME"'; } > /usr/local/bin/docker-java-home && chmod +x /usr/local/bin/docker-java-home && [ "$JAVA_HOME" = "$(docker-java-home)" ]

# https://jdk.java.net/
# > Java Development Kit builds, from Oracle
ENV JAVA_VERSION 13.0.2
ENV JAVA_URL https://download.java.net/java/GA/jdk13.0.2/d4173c853231432d94f001e99d882ca7/8/GPL/openjdk-13.0.2_linux-x64_bin.tar.gz
ENV JAVA_SHA256 acc7a6aabced44e62ec3b83e3b5959df2b1aa6b3d610d58ee45f0c21a7821a71

RUN set -eux; \
  \
  savedAptMark="$(apt-mark showmanual)"; \
  apt-get update; \
  apt-get install -y --no-install-recommends \
    wget \
  ; \
  rm -rf /var/lib/apt/lists/*; \
  \
  wget -O openjdk.tgz "$JAVA_URL"; \
  echo "$JAVA_SHA256 */openjdk.tgz" | sha256sum -c -; \
  \
  mkdir -p "$JAVA_HOME"; \
  tar --extract \
    --file openjdk.tgz \
    --directory "$JAVA_HOME" \
    --strip-components 1 \
    --no-same-owner \
  ; \
  rm openjdk.tgz; \
  \
  jlink --no-header-files \
          --no-man-pages \
          --compress=2 \
          --strip-java-debug-attributes \
          --add-modules java.base,java.desktop,java.naming,java.management,java.security.jgss,java.instrument \
          --output /usr/java/slim-jre \
    ; \
    rm -r $JAVA_HOME; \
    mv /usr/java/slim-jre $JAVA_HOME; \
  \
  apt-mark auto '.*' > /dev/null; \
  [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; \
  apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
  \
# update "cacerts" bundle to use Debian's CA certificates (and make sure it stays up-to-date with changes to Debian's store)
# see https://github.com/docker-library/openjdk/issues/327
#     http://rabexc.org/posts/certificates-not-working-java#comment-4099504075
#     https://salsa.debian.org/java-team/ca-certificates-java/blob/3e51a84e9104823319abeb31f880580e46f45a98/debian/jks-keystore.hook.in
#     https://git.alpinelinux.org/aports/tree/community/java-cacerts/APKBUILD?id=761af65f38b4570093461e6546dcf6b179d2b624#n29
  { \
    echo '#!/usr/bin/env bash'; \
    echo 'set -Eeuo pipefail'; \
    echo 'if ! [ -d "$JAVA_HOME" ]; then echo >&2 "error: missing JAVA_HOME environment variable"; exit 1; fi'; \
# 8-jdk uses "$JAVA_HOME/jre/lib/security/cacerts" and 8-jre and 11+ uses "$JAVA_HOME/lib/security/cacerts" directly (no "jre" directory)
    echo 'cacertsFile=; for f in "$JAVA_HOME/lib/security/cacerts" "$JAVA_HOME/jre/lib/security/cacerts"; do if [ -e "$f" ]; then cacertsFile="$f"; break; fi; done'; \
    echo 'if [ -z "$cacertsFile" ] || ! [ -f "$cacertsFile" ]; then echo >&2 "error: failed to find cacerts file in $JAVA_HOME"; exit 1; fi'; \
    echo 'trust extract --overwrite --format=java-cacerts --filter=ca-anchors --purpose=server-auth "$cacertsFile"'; \
  } > /etc/ca-certificates/update.d/docker-openjdk; \
  chmod +x /etc/ca-certificates/update.d/docker-openjdk; \
  /etc/ca-certificates/update.d/docker-openjdk; \
  \
# https://github.com/docker-library/openjdk/issues/331#issuecomment-498834472
  find "$JAVA_HOME/lib" -name '*.so' -exec dirname '{}' ';' | sort -u > /etc/ld.so.conf.d/docker-openjdk.conf; \
  ldconfig; \
  \
# https://github.com/docker-library/openjdk/issues/212#issuecomment-420979840
# https://openjdk.java.net/jeps/341
  java -Xshare:dump; \
  \
# basic smoke test
#	javac --version; \
  java --version

# "jshell" is an interactive REPL for Java (see https://en.wikipedia.org/wiki/JShell)
CMD ["jshell"]

В строке 47 я добавил создание кастомного JRE, в строках 54 и 55 удалил установленный JDK и вместо него поместил собранный.

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

Теперь самое интересное. Собираем образ и смотрим на его размер:

Получилось 141 MB вместо 409 MB, что не может не радовать.

Теперь можно отправить его, например, в Docker Hub или любое другое хранилище и использовать как основу для образа приложения.

Модифицируем Dockerfile-приложения

FROM rostyslavm/slim-jre:1.0.0
RUN mkdir -p /jar
COPY ./target/slim-docker-image.jar /jar/app.jar
ENTRYPOINT ["java","-jar","/jar/app.jar"]

В строке 1 я использую образ, который отправил в Docker Hub.

После сборки образа, используя вышеуказанный файл, получаем slim-вариацию, которая весит 159 MB вместо 427 MB:

Создаем контейнер и убеждаемся, что приложение успешно работает:

источник