Когда мы начали деплоить Kotlin-микросервисы в Kubernetes, первая боль — размер образов. Spring Boot + OpenJDK + зависимости Gradle = 800 МБ. Pull на каждый узел кластера, медленный деплой, высокая стоимость хранения в registry. Я потратил месяц на оптимизацию и сократил средний размер до 50 МБ. Вот как.
Это не теория — каждая оптимизация проверена на продакшене с реальными метриками.
Базовый Dockerfile: 800 МБ
С чего начинается большинство проектов. Простой Dockerfile, который «просто работает»:
# ❌ Базовый Dockerfile — 800 МБ
FROM eclipse-temurin:21-jdk-jammy
WORKDIR /app
# Копируем ВСЁ (включая .gradle, build/, test-классы)
COPY . .
# Собираем в контейнере (без кэша, без Gradle daemon)
RUN ./gradlew clean build -x test
# Запускаем fat JAR
CMD ["java", "-jar", "build/libs/my-service-1.0.0.jar"]
Почему это плохо? Слой COPY . . делает кэш Docker недействительным при любом изменении файла. Директория .gradle (~200 МБ) попадает в образ. Gradle скачивает зависимости каждый раз (нет локального кэша). eclipse-temurin:21-jdk — это полный JDK (~450 МБ), хотя для запуска нужна только JRE. Fat JAR содержит все зависимости (~150 МБ).
Итог: 800 МБ, сборка 5 минут, cold start — 3 секунды.
Шаг 1: Multi-stage build + кэширование слоёв Gradle
Первая оптимизация — многоэтапная сборка. Разделяем стадию сборки и стадию выполнения. На стадии сборки компилируем приложение, на стадии выполнения — только запускаем.
# ✅ Многоэтапный Dockerfile — ~180 МБ
# === СТАДИЯ 1: Сборка ===
FROM eclipse-temurin:21-jdk-jammy AS build
WORKDIR /build
# 1. Копируем ТОЛЬКО gradle-файлы (максимальный кэш)
COPY gradle/ gradle/
COPY gradlew build.gradle.kts settings.gradle.kts ./
COPY buildSrc/ buildSrc/
# 2. Скачиваем зависимости (кэшируется отдельно от исходников)
RUN --mount=type=cache,target=/root/.gradle \
./gradlew dependencies --no-daemon || true
# 3. Копируем исходники и собираем
COPY src/ src/
RUN --mount=type=cache,target=/root/.gradle \
./gradlew build -x test --no-daemon
# === СТАДИЯ 2: Выполнение ===
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY --from=build /build/build/libs/*.jar app.jar
EXPOSE 8080
CMD ["java", "-XX:+UseContainerSupport", "-jar", "app.jar"]
Ключевые улучшения: --mount=type=cache,target=/root/.gradle — сохраняет кэш Gradle между сборками (Docker BuildKit). JDK заменён на JRE (экономия ~150 МБ). Исходники копируются отдельно от gradle-файлов — если меняется только код, зависимости не скачиваются заново.
Итог: 180 МБ, инкрементальная сборка ~2 минуты (при изменении только исходников).
Шаг 2: jlink — кастомная JRE
Стандартная JRE содержит модули, которые вашему приложению не нужны. jlink создаёт кастомную JRE только с необходимыми модулями:
# === СТАДИЯ 2: Кастомная JRE через jlink ===
FROM eclipse-temurin:21-jdk-jammy AS jre
RUN jlink \
--add-modules java.base,java.sql,java.naming,java.desktop,java.net.http \
--strip-debug \
--no-man-pages \
--compress=2 \
--output /custom-jre
# === СТАДИЯ 3: Выполнение с кастомной JRE ===
FROM ubuntu:22.04
COPY --from=jre /custom-jre /opt/jre
COPY --from=build /build/build/libs/*.jar app.jar
ENV JAVA_HOME=/opt/jre
ENV PATH="$JAVA_HOME/bin:$PATH"
EXPOSE 8080
CMD ["java", "-XX:+UseContainerSupport", "-jar", "app.jar"]
Кастомная JRE через jlink весит ~40 МБ вместо ~150 МБ стандартной JRE. Опция --compress=2 сжимает модули ZIP-компрессией. --strip-debug удаляет отладочную информацию.
Сложность: нужно знать, какие модули нужны вашему приложению. Для Spring Boot список побольше: java.base,java.sql,java.naming,java.desktop,java.net.http,java.xml,jdk.crypto.ec,jdk.localedata. Чтобы узнать точный список, запустите jdeps --print-module-deps build/libs/*.jar.
Итог: ~95 МБ (40 МБ JRE + 55 МБ fat JAR).
Шаг 3: Distroless — минимальный базовый образ
Ubuntu/Debian/Alpine в базовом образе содержат package manager, shell, системные утилиты — всё, что увеличивает поверхность атаки. Distroless от Google содержит только ваше приложение и его runtime-зависимости:
# === Distroless — ~65 МБ ===
FROM gcr.io/distroless/java21-debian12:nonroot
COPY --from=build /build/build/libs/*.jar app.jar
# В distroless встроен пользователь nonroot
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "app.jar"]
Distroless не содержит shell — вы не можете выполнить docker exec -it bash. Для отладки используйте образ gcr.io/distroless/java21-debian12:debug (с shell, но только для разработки). В production shell не нужен — это best practice с точки зрения безопасности.
Итог: ~65 МБ (20 МБ distroless + 45 МБ JAR приложения).
Alpine vs Distroless
Alpine Linux (~5 МБ) — популярный выбор для минимальных образов. Но с приложениями на glibc (Kotlin на JVM) Alpine требует gcompat или работы на musl libc, что может вызывать непредсказуемые ошибки (особенно с нативными библиотеками). Distroless использует glibc и безопаснее для JVM-приложений.
Базовый образ | Размер | libc | Shell | Безопасность
-------------------------|--------|--------|-------|-------------
ubuntu:22.04 | 77 МБ | glibc | Да | Низкая
eclipse-temurin:21-jre | 220 МБ | glibc | Да | Средняя
alpine:3.19 + JRE | 85 МБ | musl | Да | Средняя
distroless/java21 | 50 МБ | glibc | Нет | Высокая
jlink + distroless | 50 МБ | glibc | Нет | Высокая
Шаг 4: GraalVM Native Image
GraalVM компилирует Java/Kotlin-приложение в нативный бинарник (ELF на Linux, Mach-O на macOS). Нет JVM, нет JIT-компиляции, нет загрузчика классов. Старт — мгновенный, память — минимальная.
# === GraalVM Native Image — ~35 МБ ===
FROM ghcr.io/graalvm/native-image-community:21 AS native-build
WORKDIR /build
COPY --from=build /build/build/libs/*.jar app.jar
# Компиляция в native image (~5–10 минут)
RUN native-image \
-H:Name=app \
-H:+ReportExceptionStackTraces \
--no-fallback \
--enable-preview \
-cp app.jar \
com.example.ApplicationKt
# === Выполнение — минимальный образ ===
FROM gcr.io/distroless/debian12:nonroot
COPY --from=native-build /build/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
Результат впечатляет:
Метрика | JVM (distroless) | GraalVM Native
----------------------|-------------------|------------------
Размер образа | 65 МБ | 35 МБ
Cold start | 1,2 с | 12 мс
RSS (в простое) | 120 МБ | 25 МБ
RSS (под нагрузкой) | 250 МБ | 60 МБ
Пропускная способность| 15 000 rps | 18 000 rps
Показатель P99 | 3 мс | 1 мс
Пиковая пропускная | 22 000 rps | 24 000 rps
способность | |
GraalVM даёт мгновенный cold start (12 мс против 1200 мс) и минимальное потребление памяти (25 МБ в простое). Для AWS Lambda, Cloud Functions и горизонтального автомасштабирования подов в Kubernetes — это game changer.
Грабли GraalVM
GraalVM не бесплатен от проблем. Во-первых, AOT-компиляция несовместима с рефлексией. Если вы используете Moshi (рефлексия), Gson, Hibernate — придётся добавлять reflection-config.json с ручным перечислением рефлексируемых классов. Или использовать tracing agent GraalVM, который автоматически обнаруживает рефлексию в рантайме.
// build.gradle.kts
graalvmNative {
binaries {
named("main") {
buildArgs.add("--initialize-at-build-time")
buildArgs.add("-H:ReflectionConfigurationFiles=src/main/resources/reflect-config.json")
buildArgs.add("-H:ResourceConfigurationFiles=src/main/resources/resource-config.json")
}
}
}
// Запуск с tracing agent для автоматической генерации конфигурации
// java -agentlib:native-image-agent=config-output-dir=src/main/resources -jar app.jar
Во-вторых, сборка native image занимает 5–10 минут и требует значительного объёма RAM (~4–8 ГБ). В CI/CD это может быть проблемой. В-третьих, пропускная способность в установившемся режиме может быть ниже, чем у JVM с JIT-компиляцией (C2-компилятор оптимизирует горячие пути лучше, чем AOT). GraalVM выигрывает на холодном старте, но проигрывает на долго живущих сервисах со сложной логикой.
Ktor + GraalVM: идеальная пара
Ktor поддерживает GraalVM из коробки (ktor-server-netty). Небольшой footprint Ktor + нативная компиляция = микросервис на 30 МБ с cold start 10 мс. Мы используем этот стек для serverless-функций на AWS Lambda через Custom Runtime:
// build.gradle.kts — Ktor + GraalVM
plugins {
kotlin("jvm") version "2.0.0"
id("org.graalvm.buildtools.native") version "0.10.0"
application
}
application {
mainClass.set("com.example.ApplicationKt")
}
dependencies {
implementation("io.ktor:ktor-server-netty:2.3.7")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
implementation("org.jetbrains.exposed:exposed-core:0.46.0")
implementation("org.postgresql:postgresql:42.7.1")
}
// Метаданные для native image (автогенерация)
graalvmNative {
metadataRepository {
enabled.set(true)
}
}
Бинарник для простого REST API с подключением к PostgreSQL — 32 МБ. Cold start на AWS Lambda — 15 мс (init-фаза) + 2 мс (обработчик). Для сравнения, Lambda на JVM — 3 с init + 50 мс обработчик.
Итоговая сравнительная таблица
Подход | Размер | Время сборки | Cold start | RSS (в простое)
---------------------------|---------|--------------|------------|---------------
Базовый (JDK + fat JAR) | 800 МБ | 5 мин | 3,2 с | 250 МБ
Multi-stage + JRE | 180 МБ | 2 мин | 1,2 с | 180 МБ
jlink + distroless | 65 МБ | 2,5 мин | 1,0 с | 120 МБ
GraalVM Native | 35 МБ | 10 мин | 12 мс | 25 МБ
GraalVM (Ktor micro) | 32 МБ | 8 мин | 10 мс | 22 МБ
Мой рекомендованный стек: jlink + distroless для долго живущих микросервисов, GraalVM Native Image для serverless-функций и автомасштабируемых подов в Kubernetes. Многоэтапная сборка — обязательный минимум для любого проекта.
Мой вердикт
Оптимизация Docker-образов — не одноразовая задача, а процесс. Начните с многоэтапной сборки и JRE вместо JDK (это сократит образ в 4 раза). Добавьте .dockerignore и исключите .gradle, build/, .git из контекста сборки. Перейдите на distroless для production. И если cold start и потребление памяти критичны — исследуйте GraalVM.
Для нашего Kubernetes-кластера переход с 800 МБ на 50 МБ образов сократил время pull на каждом узле с 2 минут до 5 секунд. Общий деплой 20 сервисов ускорился с 40 минут до 8. Экономия на storage в registry — ~15 ГБ в месяц. Стоило потратить месяц на оптимизацию.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье