1. Цель: один Dockerfile, два образа
Роли stages уже разведены, JDWP понятен, --target тоже. Теперь осталось собрать всё это в один Dockerfile, который не плодит параллельные версии одного и того же сервиса.
Нам нужен один файл, который описывает один и тот же сервис, но даёт два понятных результата: runtime для обычного запуска и development для диагностики. Оба варианта запускают один и тот же jar; меняется только контейнерное окружение и способ старта JVM.
Схема stages: builder → development → runtime
С multi-stage Dockerfile иногда есть ощущение, что это «просто несколько FROM подряд». Технически да. Но полезнее читать файл как историю: сначала собираем артефакт, потом делаем dev-path, потом финальный runtime. И ещё одна важная деталь: Docker по умолчанию соберёт последний stage, поэтому runtime удобно держать в конце.
Для наглядности — схема:
flowchart TD
A["Исходники + Gradle wrapper
в build context"] --> B["stage: builder
./gradlew bootJar"]
B --> C["stage: development
JDK + JDWP + port 5005"]
B --> D["stage: runtime
чистый запуск без JDWP"]
C --> E["docker build --target development -t ...:dev ."]
D --> F["docker build -t ...:runtime . (default)"]
Обратите внимание на ключевую мысль: и development, и runtime копируют один и тот же jar из builder. Мы не делаем «два разных jar’а» и не запускаем «в деве одно, в рантайме другое». Иначе у вас будет два приложения под одним названием — а это уже не Docker, это почти философия.
2. Builder stage: один bootJar на оба режима
Builder stage остаётся единственным местом, где собирается bootJar. Это и удерживает dev/runtime от drift: оба следующих stage только копируют готовый артефакт, а не собирают что-то своё.
В текущем baseline builder делает только bootJar, поэтому COPY --from=builder /workspace/build/libs/*.jar app.jar остаётся однозначным. Если ваш проект складывает в build/libs несколько jar-файлов, pattern нужно сузить до одного конкретного артефакта.
Development stage: JDK + JDWP и порт 5005
Development stage нужен только для другого режима JVM: JDK-окружение, JDWP и debug-порт. Само приложение при этом не становится “особой dev-версией” — запускается тот же jar из builder stage.
Runtime stage: clean default
Runtime stage берёт тот же jar, но запускает его без JDWP и без dev-инструментов. Именно его удобно держать последним stage, чтобы обычный docker build . давал спокойный default-образ.
Полный Dockerfile: один файл, три роли
Ниже — тот самый Dockerfile, который склеивает весь workflow без Dockerfile.dev и без второй сборки jar.
# ===== 1) builder: собираем bootJar =====
FROM gradle:9.4.0-jdk25 AS builder
WORKDIR /workspace
# Упрощённо: копируем весь репозиторий в build context
COPY . .
# На некоторых ОС права на gradlew могут быть потеряны — чиним явно
RUN chmod +x gradlew
# Собираем jar один раз, чтобы им пользовались оба следующих stage
RUN ./gradlew bootJar --no-daemon
# ===== 2) development: jar + JDWP =====
FROM eclipse-temurin:25-jdk AS development
WORKDIR /app
# Важно: в dev и runtime копируем один и тот же артефакт
COPY --from=builder /workspace/build/libs/*.jar app.jar
# В dev публикуем и HTTP, и debug порт
EXPOSE 8080 5005
ENTRYPOINT ["java"]
# JDWP включён только в dev-ветке
CMD ["-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "-jar", "app.jar"]
# ===== 3) runtime: чистый запуск (default) =====
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app
# Runtime берёт тот же jar и не добавляет debug-настроек
COPY --from=builder /workspace/build/libs/*.jar app.jar
# В runtime нужен только HTTP
EXPOSE 8080
ENTRYPOINT ["java"]
CMD ["-jar", "app.jar"]
Если вы сейчас читаете и думаете: «Погодите, тут же два раза один и тот же COPY --from=builder ...», то да — и это нормально. Мы явно показываем, что оба пути используют один артефакт. И это лучше, чем попытка “схитрить” и где-то “унаследоваться”, но в итоге запутать начинающего человека (и себя через неделю).
3. Сборка образов: runtime и development
Теперь самое приятное: Dockerfile один, а итогов два. Собирать runtime-образ можно как default, потому что runtime стоит последним stage.
# Собираем runtime по умолчанию (последний stage в Dockerfile)
docker build -t docker-java-catalog-service:runtime .
Если вы хотите dev-образ, вы явно выбираете target:
# Явно собираем development stage (с JDWP и debug-портом)
docker build --target development -t docker-java-catalog-service:dev .
Теперь роли видны прямо в тегах: :runtime и :dev. Это дешевле и понятнее, чем один туманный :latest, из которого потом приходится угадывать, что именно вы только что запустили.
Если хочется дополнительной ясности, вы можете собирать runtime тоже через --target runtime:
# Можно явно указать target runtime, но это необязательно
docker build --target runtime -t docker-java-catalog-service:runtime .
Но методически приятнее, когда default build даёт ожидаемый “обычный” результат, а dev-режим включается явно.
4. Запуск: обычный и debug-режим
Запуск runtime-режима — это привычный сценарий: публикуем HTTP-порт и проверяем API. Здесь никаких сюрпризов быть не должно.
# Запуск runtime: публикуем только HTTP-порт
docker run --rm -p 8080:8080 docker-java-catalog-service:runtime
Быстрая проверка, что сервис отвечает:
# Проверяем, что HTTP действительно доступен с хоста
curl http://localhost:8080/api/catalog/items
Если всё ок, вы получите JSON-ответ (его конкретный вид зависит от starter repo, но сам факт ответа важнее). Если вы видите ответ — значит и container networking, и приложение, и публикация портов живы.
Теперь dev-режим. Тут добавляется публикация debug-порта 5005:
# Запуск development: публикуем и HTTP, и JDWP debug порт
docker run --rm -p 8080:8080 -p 5005:5005 docker-java-catalog-service:dev
Лайфхак для спокойствия новичка: откройте логи контейнера и найдите строку, похожую на сообщение о том, что JDWP слушает порт. JVM обычно пишет что-то в духе “Listening for transport dt_socket at address: 5005”. Если вы это увидели — вы уже на правильном пути, даже не подключая IDE.
И снова можно проверить HTTP, потому что приложение всё равно должно стартовать и отвечать:
# Даже в dev-режиме приложение должно отвечать по HTTP
curl http://localhost:8080/api/catalog/items
Если HTTP работает, а debug-порт опубликован, то вы действительно получили debug-oriented контейнер, а не “сломанный сервис с непонятной магией”.
Важно не спутать этот split с обычной конфигурацией запуска. Отдельный dev-образ нужен только потому, что меняется режим JVM. Профиль приложения, server.port, env vars и остальные runtime-параметры по-прежнему должны приходить при запуске контейнера, а не через новый образ под каждый случай.
5. Типичные ошибки при сборке и запуске
Ошибка №1: «Давайте просто включим JDWP в runtime, чего мелочиться».
Это очень соблазнительно: одна строчка в CMD, и «всё дебажится всегда». Проблема в том, что вы превращаете debug-режим в default и теряете дисциплину “чистого” образа. В реальной команде это почти гарантированно приведёт к тому, что кто-то случайно запустит сервис с открытым debug-портом там, где он не нужен. Лечится просто: JDWP живёт только в development stage, а runtime остаётся скучным и предсказуемым.
Ошибка №2: development и runtime собирают разные jar’ы, потому что “так быстрее” или “так привычнее”.
Если в одном stage вы собираете jar так, а в другом иначе, вы создаёте два варианта приложения под одним именем. Потом начинается классика: «В dev всё работает, а в runtime почему-то нет», и вы тратите часы, хотя проблема не в коде, а в расхождении артефактов. Правильная модель — один builder, один bootJar, и оба stage делают COPY --from=builder ... app.jar.
Ошибка №3: забыли -p 5005:5005 и удивляются, что отладчик не подключается.
JDWP-параметры в JVM включают сервер внутри контейнера, но контейнер — это отдельная сеть. Пока вы не опубликовали порт, с хоста к нему не добраться. Это похоже на ситуацию “у меня дома есть дверь, но я её не открыл наружу”. Исправление простое: для dev-запуска всегда публикуем и 8080, и 5005.
Ошибка №4: используют address=localhost:5005 (или вообще не думают про address).
В контейнере localhost — это контейнер, а не ваша машина. Если JVM слушает debug-порт только на loopback-интерфейсе внутри контейнера, вы снаружи не подключитесь даже при публикации порта. Для учебного dev-сценария базовое правило звучит так: address=*:5005, чтобы JVM слушала порт на сетевом интерфейсе контейнера.
Ошибка №5: случайно сделали development stage последним, и теперь docker build . собирает “не тот” образ.
Это тонкая и очень раздражающая ошибка, потому что выглядит как “Docker ведёт себя странно”. На деле Docker ведёт себя максимально честно: он собирает последний stage как итоговый. Если вы поменяли порядок stages, вы поменяли default поведение. Поэтому держите runtime последним, а development собирайте через --target development.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ