JavaRush /Курсы /Docker for Spring /Единый Dockerfile: dev

Единый Dockerfile: dev и runtime

Docker for Spring
10 уровень , 4 лекция
Открыта

1. Цель: один Dockerfile, два образа

Роли stages уже разведены, JDWP понятен, --target тоже. Теперь осталось собрать всё это в один Dockerfile, который не плодит параллельные версии одного и того же сервиса.

Нам нужен один файл, который описывает один и тот же сервис, но даёт два понятных результата: runtime для обычного запуска и development для диагностики. Оба варианта запускают один и тот же jar; меняется только контейнерное окружение и способ старта JVM.

Схема stages: builderdevelopmentruntime

С 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.

1
Задача
Docker for Spring, 10 уровень, 4 лекция
Недоступна
Единый корневой Dockerfile для `runtime` и `development`
Единый корневой Dockerfile для `runtime` и `development`
1
Задача
Docker for Spring, 10 уровень, 4 лекция
Недоступна
Bash-скрипты для двух режимов из одного Dockerfile
Bash-скрипты для двух режимов из одного Dockerfile
1
Опрос
Docker Debug, 10 уровень, 4 лекция
Недоступен
Docker Debug
Отладка контейнеров и образов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ