1. Роли образов: runtime и development
Очень естественная мысль после всех улучшений звучит так: «Мы наконец-то сделали хороший runtime-образ. Он clean, multi-stage, всё как в лучших домах… Значит, будем использовать его и для разработки, и для отладки». На практике именно в этот момент в проекте появляется тихий хаос: образ начинает обрастать настройками “на всякий случай”, а потом вы уже не помните, что из этого — необходимость, а что — случайный компромисс.
Buildpacks, которые мы уже видели, никуда не делись: если нужен просто готовый runtime-образ, это всё ещё нормальный путь. Но у debug-сценария другая боль: нужно добавить удобство для диагностики и при этом не засорить обычный контейнерный запуск. Именно здесь уже нужен управляемый Dockerfile path, где clean runtime и development path разведены явно.
Здесь важно поймать одну простую идею: у разработки и у обычного запуска разные цели. Runtime-образ (наш «default») должен быть предсказуемым, спокойным и максимально похожим на «нормальный запуск сервиса». Development-образ — это инструмент, который помогает вам разбираться, что происходит, когда что-то пошло не так. Он может быть «толще», «шумнее» и даже чуть менее красивым — потому что красота здесь не на первом месте.
Чтобы не свалиться в религию («всё должно быть минимальным» против «всё должно быть удобным»), полезно сравнить два режима как две разные роли одного и того же сервиса:
| Критерий | Runtime-образ (обычный запуск) | Development-образ (debug-oriented) |
|---|---|---|
| Главная цель | Предсказуемо запускать сервис | Удобно диагностировать и отлаживать |
| Размер и «лишнее» | Лучше меньше и чище | Допустимо больше, если помогает |
| Порты | Обычно только HTTP | HTTP + отдельный debug-порт |
| JVM-параметры | Минимум, только нужные | Может включать JDWP и диагностические опции |
| Риск «случайно утащить в прод» | Минимальный | Повышенный, поэтому должен быть отделён |
| Что запускаем | app.jar | app.jar |
Важно не спутать роли: development-образ — это не «улучшенная версия runtime-образа». Это другой инструмент. Как дрель и шуруповёрт: оба крутят, но если вы попытаетесь сверлить шуруповёртом «на авось», будет очень обидно за шурупы и за вашу самооценку.
2. Runtime-образ: предсказуемый запуск
Runtime-образ — это ваш основной контейнерный способ запуска. Даже если вы пока запускаете всё локально и никакого продакшена не нюхали, именно этот образ должен оставаться максимально спокойным: он предназначен для ситуации «я хочу поднять сервис и быть уверенным, что он стартует одинаково у меня, у соседа по команде и в CI». Чем меньше сюрпризов в runtime-образе, тем проще потом диагностировать реальные проблемы.
В хорошем runtime-образе обычно нет ничего «про отладку». Не потому что отладка — зло, а потому что debug-настройки влияют на поведение процесса. Они открывают дополнительные порты, иногда меняют тайминги, а ещё — создают иллюзию, что «так и должно быть». Самое неприятное — когда команда привыкает к «debug always on», а потом внезапно удивляется, что в другом окружении сервис ведёт себя иначе.
Практически runtime-образ — это продолжение идеи «стабильный способ запуска + изменяемые аргументы». То есть сам механизм старта живёт в ENTRYPOINT/CMD, а изменяемые штуки (порт, профиль, параметры приложения) должны передаваться при запуске контейнера, а не вшиваться в образ. И вот здесь тонкая граница: «передать порт/профиль» — это нормальная runtime-конфигурация; «включить JDWP и открыть debug-порт» — это уже режим «я отлаживаю», и он заслуживает отдельного пути.
Ещё одна практическая причина держать runtime-образ чистым — безопасность и дисциплина. Debug-порт (классически это 5005) — это входная точка для отладчика. В учебной локальной жизни он почти всегда живёт на localhost, и ничего страшного не происходит. Но если такой образ случайно уедет туда, где порты доступны другим, вы буквально оставляете «дверь в JVM» открытой. А в мире инфраструктуры открытые двери обычно не просто скрипят — в них потом кто-то заходит.
3. Development-образ: диагностика
Development-образ — это тот случай, когда мы честно говорим: «Да, сейчас мне важнее удобство, чем минимальность». Это нормально. Отладка в контейнере часто нужна именно потому, что проблема проявляется в контейнерной среде: другой файловый путь, другое сетевое окружение, другая память, другой порядок старта или просто «у меня работает, а в контейнере — нет». И в такие моменты хочется не философии, а возможности поставить breakpoint и увидеть, что реально происходит.
Под development-образом здесь удобно понимать debug-oriented image: образ, в котором осознанно включены настройки для удалённой отладки JVM. Тут важно не испугаться слова «remote»: в Docker это «remote» даже тогда, когда всё на одной машине. JVM внутри контейнера — это отдельный процесс в отдельной изоляции, и IDE подключается к нему по сети, как к «удалённому» процессу.
С технической точки зрения development-режим чаще всего отличается двумя вещами. Во‑первых, базовым образом: для отладки удобнее взять JDK-based образ, потому что там гарантированно есть всё, что нужно для работы отладчика, и меньше шанс упереться в странности «в минимальном runtime чего-то не хватает». Во‑вторых, командой запуска JVM: к обычному -jar app.jar добавляются параметры, которые включают JDWP-агент и заставляют JVM слушать debug-порт.
Самое важное, что удерживает development-образ «в рамках инженерии» — это правило: код и jar остаются теми же. Мы не делаем отдельный «debug build» приложения, не добавляем в Java-код ветки вида if (DEBUG) ..., и не создаём «вторую реальность» проекта. Мы меняем только способ контейнерного запуска и окружение, в котором удобнее разбирать проблему.
4. Роли stages: builder, dev, runtime
Когда это оформляется в Dockerfile, роли обычно расходятся очень просто:
- builder собирает bootJar.
- runtime запускает этот jar как обычный сервис.
- development запускает тот же jar в более удобном для диагностики режиме.
Пока важна только эта граница: сборка артефакта одна, а вариантов запуска может быть несколько. Тогда debug-настройки не расползаются по основному образу и не превращаются в «новую норму».
5. Мини-пример: как runtime незаметно превращают в debug
Чистый runtime path у нас уже есть: обычный ENTRYPOINT ["java"] и CMD ["-jar", "app.jar"]. Здесь полезнее посмотреть на то место, где этот path чаще всего ломают.
FROM eclipse-temurin:25-jdk AS runtime
WORKDIR /app
COPY app.jar app.jar
# В этом образе уже "светим" и HTTP, и debug-порт — это явный сигнал,
# что runtime перестал быть чистым.
EXPOSE 8080 5005
ENTRYPOINT ["java"]
# Анти-паттерн: JDWP включён всегда, то есть контейнер всегда слушает порт отладчика.
CMD ["-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "-jar", "app.jar"]
Выглядит «почти так же», но смысл уже другой. Такой образ теперь всегда слушает debug-порт, всегда запускается в debug-режиме, и это становится вашим новым «нормальным». Проблема не в том, что JDWP «сломает всё» (часто он и правда не ломает), а в том, что вы теряете чистый baseline. Любая диагностика превращается в гадание: сервис ведёт себя так из-за кода, из-за контейнерной среды или из-за того, что вы постоянно запускаете JVM с дополнительными агентами?
И есть ещё один человеческий фактор: если debug включён «по умолчанию», вы перестаёте различать режимы. А когда режимы не различаются, в проекте появляется «хрупкая привычка». Угадайте, кто обычно ломается первым при хрупких привычках? Правильно: тот, кто пришёл в команду вчера и просто запустил сервис «как в README».
6. Типичные ошибки при работе с образами
Даже когда сама идея «два образа под две задачи» становится понятной, самые неприятные ошибки обычно происходят не из-за Docker, а из-за спешки и желания «сделать как-нибудь, лишь бы дебажилось». Ирония в том, что это желание как раз убивает предсказуемость — а она нужна и для отладки тоже.
Ошибка №1: считать development-образ «более правильным» и сделать его единственным.
У новичка легко рождается ощущение, что если в образе есть debug-порт и «всё удобнее», то это и есть правильный образ. На самом деле это просто другой режим. Runtime-образ нужен ровно затем, чтобы у вас всегда оставался чистый контрольный запуск без «диагностических костылей».
Ошибка №2: путать builder stage и development stage.
Builder stage собирает jar и не обязан быть удобным для запуска. Development stage запускает jar и не обязан содержать исходники или Gradle. Когда эти роли смешиваются, вы получаете жирный образ, который и собирает, и запускает, и делает это каждый раз заново. Он вроде бы «работает», но становится медленным, большим и нечитаемым.
Ошибка №3: включить debug-настройки прямо в runtime-образ «чтобы не забывать».
Желание «не забыть включить debug» понятно, но решается не вшиванием JDWP в основной CMD, а отдельным development path. Иначе вы гарантированно забудете обратное: что debug вообще включён. Это уже не смешно, особенно когда отладочный порт остаётся открытым там, где вы этого не ожидали.
Ошибка №4: менять код приложения ради debug-режима вместо изменения контейнерного запуска.
Иногда разработчик добавляет в Java-код ветки «если DEBUG — то логируй всё подряд» или даже поднимает второй порт прямо из приложения. Это плохой путь: вы учитесь не управлять упаковкой и запуском, а зашиваете инфраструктурные режимы внутрь бизнес-кода. Здесь важно держать границу: jar один, код один, меняется только контейнерный способ запуска.
Ошибка №5: «разработческий образ» начинают использовать как замену принципу same image, different runtime config.
Development-образ не должен превращаться в способ «плодить образы под каждое значение». Порт, профиль, параметры приложения — это по-прежнему ответственность runtime-конфигурации при запуске контейнера. Development-образ нужен только потому, что вы меняете не функциональные параметры сервиса, а сам режим работы JVM для отладки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ