1. Builder stage: «строительная площадка»
Если смотреть на multi-stage Dockerfile глазами начинающего разработчика, легко подумать: “Ну окей, мы просто добавили второй FROM, и всё стало модным”. На практике multi-stage — это не про моду, а про разделение ответственности. Builder stage — это та часть Dockerfile, где мы разрешаем себе быть грязными, потому что наша задача не запускать сервис, а собрать артефакт. Как на стройке: там пыль, инструменты, упаковки от материалов — это нормально, пока в финальную квартиру всё это не переехало.
Главная мысль builder stage очень простая и даже немного скучная (а значит, правильная): в builder stage живёт всё, что нужно, чтобы получить runnable Spring Boot jar, и больше ничего. Мы не настраиваем там порт, не думаем о healthcheck, не проектируем runtime-поведение контейнера. Builder stage отвечает на один вопрос: “Как внутри Docker-сборки сделать так, чтобы на выходе появился bootJar нашего Container-Ready Catalog Service?”
Важный психологический момент: раньше мы часто строили образ по схеме “собери jar на своей машине → скопируй jar в образ”. Это быстрый учебный старт, но он завязан на окружение разработчика. Builder stage позволяет сдвинуть сборку внутрь Docker: если у вас есть Docker, вы можете собрать jar в предсказуемой среде, даже если на хосте Gradle не установлен (а IDE сегодня решила обновиться и «всё сломать» — бывает, не осуждаем).
2. Содержимое builder stage в Gradle/Spring Boot
Когда мы говорим “в builder stage должны жить Gradle и исходники”, это звучит очевидно, но на практике новички часто путаются: копируют либо слишком мало (и сборка падает), либо слишком много (и снова ломают кэш, тащат мусор и секреты). Здесь полезно держать очень простую классификацию: builder stage должен уметь запустить Gradle Wrapper и увидеть проект целиком так, чтобы команда bootJar отработала.
В проекте docker-java-catalog-service типичный набор файлов, необходимых для сборки, выглядит так: Gradle wrapper (gradlew и папка gradle/), build scripts (build.gradle.kts, settings.gradle.kts, иногда gradle.properties), и, конечно, исходники (src/). Ещё туда может входить что-то вроде src/main/resources/ (оно обычно внутри src, но я отдельно подчёркиваю: ресурсы — часть приложения, без них jar может собраться, но потом странно стартовать).
А вот что builder stage не должен тащить из build context как обязательную часть “по умолчанию”: build/ из вашей локальной машины, .idea/, .git/, локальные .env файлы и любые артефакты, которые не нужны для компиляции. Если вы это копируете — вы фактически возвращаетесь к состоянию “Dockerfile собирает не проект, а вашу конкретную рабочую директорию со всеми её тараканами”.
Для наглядности — небольшая таблица. Она не заменяет понимание, но хорошо ловит момент, когда рука тянется сделать COPY . . “потому что так проще”.
| Сущность проекта | Нужна в builder stage? | Почему |
|---|---|---|
| gradlew, gradle/ | да | Это ваш воспроизводимый способ запустить Gradle без “а у меня другой Gradle”. |
| build.gradle.kts, settings.gradle.kts | да | Без них Gradle не понимает, что собирать и какие зависимости тянуть. |
| src/ | да | Это ваш код и ресурсы, без них bootJar либо не соберётся, либо получится пустышка. |
| .dockerignore | не копируем внутрь, но обязателен в репозитории | Он влияет на build context: что вообще уходит в Docker build. |
| build/ (локальная папка) | нет | Это output вашей машины, он не должен быть источником истины для контейнерной сборки. |
| .git/, .idea/ | нет | Это не часть сборки артефакта. Для Docker это только лишний вес и кэш-боль. |
3. Каркас builder stage
В этой точке хочется написать “ну ладно, давайте Dockerfile”, но давайте чуть медленнее: builder stage почти всегда начинается с FROM от образа, который умеет компилировать Java. Для компиляции нужен JDK, но мы не делаем сегодня отдельную лекцию про выбор базового образа, поэтому используем нейтральное имя java-build-image. В репозитории курса будет зафиксирован конкретный tested baseline, но это отдельная тема.
Вот минимальный каркас builder stage, который уже даёт нам правильную структуру мыслей:
FROM java-build-image AS builder
# Рабочая директория для сборки внутри контейнера
WORKDIR /build
# Сначала копируем wrapper и build-скрипты — так лучше работает кэш слоёв
COPY gradlew build.gradle.kts settings.gradle.kts ./
# Копируем каталог Gradle Wrapper
COPY gradle gradle
# Копируем исходники приложения
COPY src src
Здесь пока нет RUN, потому что мы только “собрали сцену”: положили в контейнер всё, что нужно для сборки. Обратите внимание: мы не копируем весь проект целиком, а берём только нужные части. Это продолжение дисциплины из дня про кэш: чем точнее вы копируете, тем меньше “случайных” причин разрушить кэш.
Если вам хочется в этот момент написать COPY . . — это нормальное желание. Оно примерно из той же категории, что “давайте сделаем один огромный класс, так быстрее”. Да, быстрее… до первого рефакторинга. Мы учимся делать Dockerfile, который не стыдно показать коллеге.
4. COPY и кэш в builder stage
Очень частая ошибка в голове: “Кэш — это про runtime образ, а builder stage — это временное, там не важно”. Важно. Builder stage хоть и не попадёт в финальный runtime image, но он влияет на две вещи: скорость сборки и нервную систему человека, который эту сборку запускает (то есть вашу).
Docker кэширует слои по шагам, и если вы сначала копируете весь src, а потом запускаете Gradle, то любая правка в одном Java-файле инвалидирует слой COPY src src, а значит инвалидирует и последующий слой RUN ./gradlew .... Это ожидаемо — код поменялся, нужна компиляция. Но вот скачивание зависимостей заново каждый раз — уже неприятно. Поэтому часто builder stage строят так, чтобы сначала “закэшировать” зависимостную часть, а потом уже добавлять исходники.
Ниже — один из самых понятных вариантов для учебного проекта. Сначала мы копируем wrapper и build scripts, затем выполняем лёгкую команду, которая прогревает dependency resolution (в простом проекте это обычно работает), и только потом копируем исходники.
FROM java-build-image AS builder
WORKDIR /build
# Копируем только то, что влияет на зависимости и конфигурацию сборки
COPY gradlew build.gradle.kts settings.gradle.kts ./
COPY gradle gradle
# Прогреваем резолвинг зависимостей, чтобы ускорить последующие сборки
RUN ./gradlew --no-daemon dependencies
А уже после этого добавляем код и собираем bootJar:
# Теперь добавляем исходники: изменения тут будут инвалидировать слои ниже
COPY src src
# Собираем runnable jar (Spring Boot)
RUN ./gradlew --no-daemon bootJar
Здесь есть тонкость: задача dependencies не “собирает приложение”, она лишь прогоняет конфигурацию Gradle и резолвит зависимости. В большинстве учебных и многих реальных проектов это даёт заметную пользу: при правке кода Gradle уже имеет кэш зависимостей внутри слоя образа, и повторная сборка не начинает с “скачай половину интернета заново”.
Если в вашем проекте dependencies по каким-то причинам падает (например, сборка зависит от дополнительных директорий или buildSrc), не нужно героически страдать. В таком случае можно упростить и запускать только bootJar после копирования src. Builder stage всё равно будет полезен как граница build/runtime — просто кэш будет чуть менее идеальным. Мы идём от простого и устойчивого к более оптимальному, а не наоборот.
5. Gradle Wrapper в builder stage
Когда Gradle запускается локально, мы редко думаем о таких деталях, как “имеет ли файл gradlew право быть исполняемым”. В Docker-сборке эти вещи внезапно становятся реальными. И это нормально: контейнер — более честная среда, он не делает вид, что понимает ваши намерения, он понимает только права доступа и команды.
Самый каноничный запуск сборки внутри builder stage для нашего Spring Boot сервиса выглядит так:
# Собираем runnable jar внутри builder stage
RUN ./gradlew --no-daemon bootJar
Флаг --no-daemon здесь не обязателен “чтобы работало”, но он делает сборку более предсказуемой внутри Docker. Gradle daemon полезен, когда вы много раз запускаете Gradle в одной и той же долгоживущей среде. Docker build — это серия коротких шагов, и daemon там не всегда даёт пользу, а иногда просто добавляет шума. Поэтому в учебном baseline я люблю --no-daemon: меньше магии, больше повторяемости.
Теперь про “почему внезапно не запускается gradlew”. На Linux это обычно не проблема, но на Windows и в некоторых сценариях с git-настройками файл может прийти без executable bit или с CRLF. Поэтому типичный “страховочный” шаг, который делает Dockerfile более переносимым между машинами, выглядит так:
# Копируем Gradle Wrapper
COPY gradlew ./
# На всякий случай добавляем право на исполнение (часто спасает при переносе между ОС)
RUN chmod +x gradlew
Да, это ещё одна строчка. Зато вы экономите часы на поиске загадки “почему у меня в контейнере Permission denied, а у соседа работает”. Dockerfile не должен быть хрупким.
6. Итоговый jar и app.jar
После ./gradlew bootJar у нас появляется jar где-то в build/libs/. И вот здесь у новичков случается классическая “мелочь, которая ломает всё”: имя файла обычно содержит версию и/или суффикс SNAPSHOT. Сегодня это docker-java-catalog-service-0.0.1-SNAPSHOT.jar, завтра вы поменяли версию — и внезапно следующий шаг Dockerfile, который “копирует конкретное имя”, перестал работать.
Builder stage должен выдавать предсказуемый результат. Поэтому хорошая практика — после сборки привести jar к понятному стабильному имени, например app.jar. Это не про красоту. Это про то, чтобы следующий этап (runtime stage) мог забрать артефакт без угадывания.
Вот как это обычно выглядит:
# Собираем runnable jar
RUN ./gradlew --no-daemon bootJar
# Нормализуем имя артефакта, чтобы следующий stage не зависел от версии/SNAPSHOT
RUN cp build/libs/*.jar app.jar
Здесь *.jar — сознательное упрощение только для случая, где после bootJar у вас в build/libs лежит один runnable jar. Если проект одновременно производит ещё и обычный plain.jar, этот шаг надо сузить до нужного файла или отключить plain-jar, иначе wildcard перестаёт быть честным.
Это намеренно простое решение. Мы не пытаемся вытащить “точное имя” через bash-магии, не пишем сложные find с регулярками. Мы просто говорим: “в папке build/libs после bootJar лежит наш runnable jar, скопируй его в app.jar”.
Если вам не нравится *.jar, можно сделать более “строгий” вариант и копировать по шаблону имени проекта, но тогда вы опять завязываетесь на договорённость об имени. Для учебного проекта это не всегда нужно.
Иногда студенты спрашивают: “А почему мы не оставляем jar как есть и не копируем build/libs/... дальше?” Можно, но тогда следующий шаг (перенос в runtime stage) становится менее читаемым: вы будете вынуждены помнить точное имя или снова использовать wildcard. Когда вы нормализуете jar в app.jar, вы делаете Dockerfile самодокументируемым: артефакт сборки — это app.jar, точка.
7. Границы builder stage
Builder stage — место, где допустимы временные файлы и кеши. Gradle создаст свою папку .gradle, скачает зависимости в ~/.gradle, создаст промежуточные результаты компиляции. Это нормально, потому что builder stage не является финальным образом, который мы будем запускать в контейнере.
Но тут важно не перепутать “допустимо” с “надо так делать”. Builder stage не должен превращаться в «второй runtime». Мы не добавляем туда ENTRYPOINT, не запускаем приложение после сборки “просто проверить”, не начинаем настраивать переменные окружения для Spring профилей. Это всё относится к runtime stage и к запуску контейнера, а не к сборке.
Ещё один типичный соблазн — “а давайте после сборки подчистим rm -rf половину папок”. Это чаще всего бессмысленно именно в builder stage. Почему? Потому что builder stage и так не попадёт в финальный runtime image. Если вы хотите уменьшить финальный образ — вы уменьшаете то, что копируете в runtime stage (а мы это сделаем в следующей лекции). Чистить builder stage ради размера финального образа — это как выносить мусор из строительного вагончика, чтобы квартира стала чище. Квартира станет чище не от этого, а от того, что вы не переносите вагончик в гостиную.
8. Типичные ошибки в builder stage
Ошибка №1: builder stage пытается “и собрать, и запустить”.
Это выглядит так: вы добавляете ENTRYPOINT прямо в builder stage или запускаете java -jar в конце первой стадии. В итоге вы снова смешали build-time и runtime, только теперь у вас два FROM, но логика всё равно размазана. Правильная картина проще: builder stage заканчивается на появлении app.jar и на этом “уходит со сцены”.
Ошибка №2: вы забыли Gradle wrapper и надеетесь на gradle “как-нибудь”.
В контейнере “как-нибудь” обычно означает “никак”. Если вы пишете RUN gradle bootJar, то Gradle должен быть установлен в образе. Это либо усложняет builder stage лишней установкой, либо ломает воспроизводимость. В учебном и большинстве рабочих Java-проектов лучший baseline — ./gradlew ..., потому что wrapper фиксирует версию Gradle и поведение сборки.
Ошибка №3: gradlew не исполняется (Permission denied) или падает с ^M.
Эта ошибка особенно часто выстреливает у студентов на Windows. Симптомы обычно такие: Docker build падает на RUN ./gradlew ... с сообщением, что файл не запускается. Лечится это не шаманством, а дисциплиной: добавить RUN chmod +x gradlew и следить за line endings (CRLF в shell-скриптах — частый источник /bin/sh^M: bad interpreter). Dockerfile должен быть переносимым между машинами, иначе вся идея “воспроизводимости” превращается в мем.
Ошибка №4: вы копируете весь проект (COPY . .) и тащите в builder stage локальный build/.
Сборка может даже пройти, но вы получаете два неприятных эффекта. Во‑первых, кэш будет ломаться слишком часто, потому что “изменилось что-то где-то” — и Docker честно пересобирает слой. Во‑вторых, вы рискуете утащить то, что не должно участвовать в сборке: локальные файлы, временные данные, иногда даже секреты (например, если кто-то положил .env рядом “на минутку”). Builder stage должен видеть проект, а не ваш домашний беспорядок на рабочем столе.
Ошибка №5: вы не знаете, где лежит итоговый jar.
Это выглядит забавно: сборка прошла, но дальше вы пытаетесь копировать файл, которого нет, потому что путь или имя не совпали. Самый спокойный способ избежать этого — нормализовать выход builder stage: после bootJar скопировать результат в app.jar в известном месте. Тогда следующая стадия не “угадывает”, а работает по договорённости.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ