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, куди підключу базові залежності:

Збирати додаток буде 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