1. Multi-stage и монолитный COPY app.jar
Multi-stage уже отделил сборку от runtime, и это был важный шаг. Но после этого быстро всплывает следующая боль: финальный runtime-слой всё ещё часто собирается слишком грубо, как один большой app.jar.
Когда у вас впервые получается поднять Spring Boot сервис в контейнере, появляется очень приятное ощущение: “Я победил”. И это правда — победа заслуженная. Но дальше приходит вторая, менее романтичная часть работы: вы начинаете править код каждый день, и вдруг выясняется, что сборка образа и его доставка по команде ведут себя так, будто вы каждый раз перевозите квартиру целиком, даже если поменяли одну кружку.
Давайте посмотрим на тот самый “простой, но рабочий” baseline, который часто остаётся в репозитории надолго:
# Минимальный рантайм-образ: только JRE и один артефакт приложения
FROM eclipse-temurin:25-jre
WORKDIR /app
# Один большой слой: любое изменение в JAR делает слой новым
COPY build/libs/docker-java-catalog-service.jar app.jar
# Стартуем приложение через executable jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Он понятный, короткий и выглядит как “идеально”. Но именно здесь прячется проблема: один COPY = один крупный Docker-слой, а внутри этого jar у Spring Boot лежит не только ваш код.
Мы уже улучшили упаковку через multi-stage, и это правильный шаг. Например, вот очень узнаваемый вариант, который отделяет сборку от runtime:
# Stage 1: сборка (внутри есть JDK и Gradle Wrapper)
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /workspace
# Копируем исходники и файлы сборки
COPY . .
# Собираем runnable JAR (bootJar)
RUN ./gradlew bootJar
# Stage 2: запуск (внутри только JRE, без инструментов сборки)
FROM eclipse-temurin:25-jre
WORKDIR /app
# Забираем из builder только готовый артефакт
COPY --from=builder /workspace/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Это действительно лучше: в финальном образе нет Gradle, нет исходников, нет “строительного мусора”. Но проблема монолитного артефакта остаётся: финальный слой всё равно содержит “всё приложение целиком” одним файлом.
2. jar и Docker cache
Docker cache работает очень прямолинейно и даже немного по-детски честно. Он смотрит на инструкцию (COPY, RUN и так далее) и на входные данные этой инструкции. Если входные данные изменились, Docker говорит: “Окей, слой другой, кэш не подходит” — и пересобирает слой заново. Это отлично, пока входные данные действительно отражают “то, что меняется”.
С COPY app.jar есть один коварный момент. Внутри jar лежит множество разных вещей, а Docker не понимает структуру внутри jar — он видит просто бинарный файл. Вы поменяли одну строчку в контроллере, пересобрали bootJar, и… весь jar другой. Для Docker это выглядит так, будто “изменилось вообще всё”.
Иногда студент говорит: “Ну и что? Я же всё равно менял код”. И это логично звучит… пока не вспомнить, что внутри этого jar сидят десятки мегабайт зависимостей. С точки зрения инженерии контейнеров это примерно как запаковать ваш код, Spring, Jackson, Hibernate, Flyway и ещё половину интернета в один zip-архив, а потом удивляться, что при изменении одного файла архив пересоздаётся целиком.
Чтобы почувствовать это на пальцах, достаточно сравнить хэш jar до и после мелкой правки:
# Смотрим хэш "до"
sha256sum build/libs/docker-java-catalog-service.jar
# 9c2e... build/libs/docker-java-catalog-service.jar
# Вы поменяли одну строку в Java-коде, пересобрали bootJar
# Смотрим хэш "после" — он уже другой
sha256sum build/libs/docker-java-catalog-service.jar
# 3a91... build/libs/docker-java-catalog-service.jar
Для Docker это новый файл, значит слой COPY app.jar считается новым. И всё: кэш на этом месте заканчивается, а дальше — новая сборка слоя, новый push/pull этого слоя, новые мегабайты по сети.
3. Состав Spring Boot bootJar
Чтобы перестать воспринимать jar как “один файл”, полезно на минуту вспомнить, что такое Spring Boot executable jar. Это не просто “ваши .class файлы в архиве”. Это контейнер, внутри которого лежит и ваш код, и зависимости, и механизм запуска. Он удобен именно потому, что вы можете сказать java -jar — и приложение стартует без плясок с classpath.
Одна маленькая команда показывает структуру в общих чертах (и да, это нормально делать не в Docker, а локально — сейчас мы просто изучаем артефакт):
# Смотрим, какие папки и файлы лежат внутри executable jar
jar tf build/libs/docker-java-catalog-service.jar | head -n 8
# META-INF/
# META-INF/MANIFEST.MF
# org/springframework/boot/loader/
# BOOT-INF/
# BOOT-INF/classes/
# BOOT-INF/lib/
# ...
Нам здесь важно не запоминать пути наизусть, а увидеть главное: внутри есть несколько логически разных частей. И вот где рождается идея layered image: разные части меняются с разной частотой.
Для “человеческого” понимания удобно разложить это в таблицу:
| Что внутри bootJar | Пример “что это” | Как часто меняется | Почему это важно для Docker |
|---|---|---|---|
| Зависимости библиотек | Spring, Jackson, драйвер PostgreSQL и т.д. | обычно редко (когда вы обновляете версии) | это тяжёлая часть, которую хочется переиспользовать |
| Загрузчик Spring Boot | служебные классы, чтобы java -jar работал | почти никогда (меняется при обновлении Boot) | стабильная основа, тоже хочется кэшировать |
| Ваш код и ресурсы | контроллеры, сервисы, application.yml | часто (каждый день) | это то, что реально должно меняться между сборками |
Пока нам не нужны конкретные названия слоёв. Здесь важнее увидеть сам принцип: одни части почти неизменны, другие постоянно в движении. А COPY app.jar заставляет Docker относиться ко всему как к одному куску.
4. Правка строки и доставка 80 МБ
Пока вы работаете один и всё крутится на вашей машине, проблема может казаться “теоретической”. Но как только появляется командная работа или CI (а иногда достаточно просто второго ноутбука), картина меняется. Вы начинаете не только собирать образ, но и регулярно его перетаскивать: в локальный registry, между окружениями, или хотя бы просто пересобирать так часто, что хочется, чтобы это было быстрее.
Представим типичный Boot-сервис: зависимости занимают 60–120 МБ, а ваш собственный код — несколько мегабайт. Цифры зависят от набора стартеров, но пропорция обычно примерно такая: “библиотеки тяжёлые, код лёгкий”. В Container-Ready Catalog Service это особенно правдоподобно, потому что даже “учебный” сервис тащит настоящие инфраструктурные библиотеки (JPA, Flyway, Actuator и т.п.), а не только один контроллер “Hello”.
Если мы упаковываем всё одним jar и копируем его одной инструкцией, то при любой правке кода вы фактически:
- собираете новый jar,
- создаёте новый Docker-слой с этим jar,
- при публикации образа отправляете в registry новый слой (который включает и код, и зависимости),
- коллега/CI скачивает этот слой заново.
И здесь появляется неприятное чувство: “Я изменил лог-сообщение, почему мы опять таскаем пол-интернета?”. Это не истерика — это нормальная инженерная реакция.
Чтобы увидеть, почему так происходит, достаточно одной мысли: Docker не может “частично переиспользовать zip-архив”. Он либо переиспользует слой целиком, либо нет. Поэтому наша цель — сделать так, чтобы в финальном образе были разные слои для разных частей приложения, а не один “слой-чемодан”.
5. Идея layered image: разложить и копировать частями
Теперь важный момент: layered image — это не “какая-то магия вместо Docker-слоёв”. Docker-слои остаются Docker-слоями. Фишка Spring Boot в том, что он помогает подготовить содержимое так, чтобы вы могли создать Docker-слои более точечно, отражая реальную структуру приложения.
Формулировка, которую стоит держать в голове, звучит так: Spring Boot умеет описать слои внутри jar, а Docker умеет превратить эти части в слои образа.
Если говорить совсем приземлённо, layered path выглядит как два шага:
- мы “раскладываем” jar на директории (условно: “зависимости отдельно”, “код отдельно”),
- в Dockerfile делаем несколько COPY, чтобы каждая директория стала отдельным слоем.
В Dockerfile это выглядит примерно так (пока без деталей, откуда взялся каталог layers/ — сейчас важна сама идея):
# Слой зависимостей: меняется редко, поэтому Docker сможет хорошо кэшировать
COPY --from=extract /build/layers/dependencies/ ./
# Слой приложения: меняется часто, поэтому будет пересобираться чаще
COPY --from=extract /build/layers/application/ ./
Здесь важна не конкретная папка, а идея. Теперь у Docker появляется шанс сказать: “О, dependencies не изменились, я оставлю старый слой. А вот application изменился — его обновим”.
Обратите внимание на тонкую, но принципиальную мысль. Layered path не заменяет multi-stage. Он его дополняет. Multi-stage отделяет “мир сборки” от “мира запуска”. Layered image, в свою очередь, делает “мир запуска” менее монолитным: не один большой слой “всё приложение”, а несколько слоёв по смыслу.
И да, Dockerfile станет длиннее. Но в этот момент длина — не проблема. Проблема — когда короткий Dockerfile заставляет вас регулярно платить временем и сетевым трафиком за то, что на самом деле не менялось.
6. Как это выглядит в нашем проекте
Чтобы это было не абстрактной философией, представим реальную жизнь Container-Ready Catalog Service. Сегодня вы правите endpoint, завтра добавляете логирование, послезавтра меняете формат ответа, потому что тестировщик попросил. Всё это — изменения в вашем коде. При этом зависимости (Spring Boot, Jackson, драйвер PostgreSQL) вы обновляете гораздо реже: обычно по задаче “обновить версию”, а не “каждый коммит”.
Вот типичный микроскопический change, который делает любой разработчик. Например, мы добавили одну строку лога в контроллер:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class CatalogController {
// Логгер — не влияет на идею слоёв, но показывает "маленькую" правку в коде
private static final Logger log = LoggerFactory.getLogger(CatalogController.class);
@GetMapping("/api/catalog/items")
String list() {
// Добавили одну строку — и JAR уже другой, а значит и Docker-слой с COPY app.jar другой
log.info("Listing catalog items");
return "ok"; // Ответ условный: здесь важна не бизнес-логика, а факт изменения артефакта
}
}
С точки зрения Java это вообще “пшик”: одна строка. Но bootJar после этого другой, значит app.jar другой, значит Docker-слой с COPY app.jar другой. И вот вы снова перетаскиваете слой, который содержит и Spring, и всё остальное.
Layered image — это способ сделать “дорогую” часть (зависимости и служебные элементы запуска) более стабильной на уровне слоёв образа, а “быструю” часть (ваш код) — изменяемой отдельно.
И особенно приятно то, что на этом этапе вам не нужно менять Java-код и не нужно “переписывать проект под Docker”. Мы просто улучшаем упаковку. Ровно поэтому эта тема находится в Docker-курсе, а не в курсе “как переписать приложение”.
7. Типичные ошибки при layered images
Ошибка №1: считать, что layered images полностью заменяют multi-stage сборку.
Multi-stage решает проблему “не тащить build toolchain в runtime”. Layered images решают проблему “не тащить всё приложение в одном слое”. Если вы сделаете layered extraction, но всё равно будете собирать и запускать в одном stage, вы вернёте часть старых проблем обратно (размер, мусор, читаемость).
Ошибка №2: оценивать Dockerfile по длине, а не по структуре изменений.
Короткий Dockerfile приятен для глаза, но Docker — не конкурс по минимализму. Он про воспроизводимость и предсказуемость. Если более длинный Dockerfile явно выражает “стабильное отдельно, изменяемое отдельно”, это полезная длина, а не “лишний шум”.
Ошибка №3: путать “слои Spring Boot” и “слои Docker”, как будто это одно и то же.
Spring Boot даёт логическое разбиение содержимого приложения. Docker создаёт физические слои образа. Это два разных уровня. Boot не “создаёт Docker-слои”, он помогает вам подготовить файлы так, чтобы вы могли создать Docker-слои более умно.
Ошибка №4: ожидать мгновенного чуда, не понимая, где именно будет выигрыш.
Layered images особенно хорошо проявляются, когда вы часто пересобираете и “доставляете” образы (push/pull, CI, командная разработка). На одной машине без registry вы тоже увидите эффект в Docker cache, но главный выигрыш часто ощущается именно в том, что не нужно каждый раз тянуть большой слой, где спрятаны зависимости.
Ошибка №5: пытаться “вручную” разложить jar на слои, не используя возможности Boot.
Технически можно распаковать jar как zip и копировать папки вручную, но это быстро превращается в хрупкий самодельный механизм: поменялась версия Boot — поменялась структура; добавились нюансы — сломались пути. Смысл Boot-layering как раз в том, чтобы разбиение было стандартным и поддерживаемым, а не “собранным на скотче и надежде”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ