Загрузка
Все статьи

Docker + Kotlin: оптимизация Dockerfile для микросервисов — от 800 МБ до 50 МБ

Базовый Dockerfile для Spring Boot на Kotlin — 800 МБ. После оптимизации — 50 МБ. Разбираю multi-stage builds, GraalVM native image, Alpine vs distroless, jlink для кастомной JRE и реальные метрики по времени сборки, cold start и memory footprint.

Когда мы начали деплоить 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 ГБ в месяц. Стоило потратить месяц на оптимизацию.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Docker Compose для локальной разработки Kotlin-микросервисов — от 10 минут до 30 секунд старта

Как мы оптимизировали локальное окружение из 10+ микросервисов с помощью Docker Compose, multi-stage Dockerfile и правильных health checks.

15 июня 2026 10 мин

Terraform vs Pulumi — Infrastructure as Code для Kotlin-команды

Сравниваем Terraform на HCL и Pulumi на Kotlin для управления Kubernetes-инфраструктурой — с реальными примерами кода, болью миграции и нашим итоговым выбором.

14 июня 2026 11 мин

Nginx + PHP-FPM: тюнинг под высокую нагрузку — от 500 RPS до 5000 RPS

Сайт на PHP + Nginx выдерживал 500 RPS, а при 1000 начинал отдавать 502. Проблема была не в PHP-коде (он был оптимизирован), а в конфигурации Nginx и PHP-FPM: неправильный worker_connections, отсутствие keepalive к upstream, слишком маленький pm.max_children. Рассказываю пошагово, как мы увеличили пропускную способность в 10 раз.

23 мая 2026 11 мин