Slim Docker image, або Як зменшити вагу Java-додатки
Вітаю, дорогий читачу DOU! Мене звати Ростислав, я Java-розробник в DGN Games, де працюю вже третій рік. Це продуктова міжнародна компанія, де велика команда займається створенням онлайн-ігор. Тут я отримав величезний досвід як в підтримці і доопрацювання високонавантаженої системи, так і в побудові микросервисной архітектури програми з нуля з використанням сучасного Spring Boot стека (включаючи всіма улюблений Kubernetes).
У цій статті не буде інформації про складання кастомних ОС, порівняння існуючих ОС, версій Java, документації по роботі з Docker, так як мається на увазі, що ти вмієш написати свій Dockerfile і зібрати образ на його основі. Зате буде розповідь про те, як мені вдалося побудувати Docker-образ вагою всього ~100-200 МБ, що базується на 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 МБ. Крім часу, скорочується також і плата за тарифікацію віддаленого сховища: наприклад, 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
В результаті побачимо в консолі приблизно такі логи:
Проаналізувавши вміст, можемо зробити висновок: програма успішно виконує своє завдання.
А що виходить за розміром 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. Нижче представлений вже готовий до використання файл:td:first-child pre {text-align: right; padding: .5em;}td pre {word-break:keep all!important;white-space:pre!important;}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
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 in f "$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 test smoke # 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-додатки
1 2 3 4 |
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:
Створюємо контейнер і переконуємося, що програма успішно працює:
Спасибі за увагу! Буду радий отримати фідбек, це буде мотивувати мене писати на інші теми.
Якщо залишилися якісь питання можна писати в коментарях до статті і стукати мені в Telegram .
Опубліковано: 21/02/20 @ 11:00
Розділ Різне
Рекомендуємо:
Open source: що це, для чого і як розпочати
DOU Ревізор у Львові: «Офіс Elitex з видом на Оперу»
10 інструментів ефективної роботи, або Забудьте про багатозадачності
Чим незадоволені українські програмісти? Глас народу 2019
BA дайджест #7: 20 уроків від аналітика з 20-річним досвідом, Top skills for 2020