1. Debug в контейнере — нормальный режим
Когда вы запускаете Spring Boot локально через IDE, отладка кажется естественной: поставил breakpoint, нажал Debug — и всё. В контейнере новичок часто попадает в странную психологическую яму: «контейнер — это как будто “не мой компьютер”, значит дебажить там нельзя». Можно, ещё как можно, просто нужно помнить, что у контейнера есть сеть, порты и вполне обычная JVM внутри.
В нашем курсе debug-режим не должен превращаться в отдельную архитектуру. Мы не создаём отдельное приложение “app-debug” и не плодим второй Compose-стек. Мы хотим ровно одно: тот же сервис app, тот же набор зависимостей и тот же конфигурационный подход, но с двумя точечными отличиями. Во‑первых, контейнер должен открыть дополнительный порт для подключения отладчика. Во‑вторых, JVM должна стартовать с включённым JDWP (Java Debug Wire Protocol), чтобы IDE могла подключиться.
Именно поэтому debug-режим — идеальный кандидат для compose.dev.yaml: это development-специфичное поведение, которое должно включаться явно (самой командой запуска), а не “жить внутри” базового compose.yaml как тихая мина. Если вы когда-нибудь ловили ситуацию, когда “у меня в проекте всё время открыт какой-то порт 5005, я уже не помню зачем” — поздравляю, вы видели анти‑паттерн в дикой природе.
Для ориентира зафиксируем простую мысль в виде таблички — она пригодится, когда вы начнёте путаться, “где какая настройка должна лежать”:
| Что настраиваем | Normal mode (compose.yaml) | Debug mode (compose.dev.yaml) |
|---|---|---|
| Состав стека (postgres/redis/rabbitmq) | да | нет (не меняем состав) |
| HTTP-порт приложения | да | обычно нет (остаётся как есть) |
| Debug-порт JVM (например, 5005) | нет | да |
| Выбор stage Dockerfile (build.target) | обычно нет | да (если есть development stage) |
| JVM debug параметры | нет | да (через env var) |
2. Remote debug JVM: IDE подключается по сети
Remote debug звучит грозно ровно до тех пор, пока вы не представите его как обычный сетевой сервис. JVM в debug‑режиме начинает слушать TCP‑порт и ждёт, когда IDE подключится. IDE подключается, договаривается с JVM, и дальше вы ставите breakpoint так же, как будто приложение запущено локально. Магии ноль, просто сеть.
Чтобы мозг перестал сопротивляться, полезно представить себе маленькую схему:
flowchart TD
IDE["IDE (Debugger)"] -->|"JDWP TCP :5005"| Host["Host (ваш компьютер)"]
Host -->|"ports: 5005:5005"| Docker["Docker / Compose"]
Docker --> App["container: app"]
App --> JVM["JVM + Spring Boot"]
Заметьте, что у нас здесь есть два слоя портов. Один порт слушает JVM внутри контейнера (внутренний 5005), а второй — опубликованный порт на хост-машине (внешний 5005). Если вы включили JDWP, но не сделали ports: "5005:5005", IDE будет честно подключаться к localhost:5005… и честно получать “Connection refused”.
Есть ещё один классический подвох, который отлично связывается с темой портов из ранних дней курса. JVM может слушать только localhost внутри контейнера, и тогда проброс порта не поможет: порт будет опубликован, но внутри контейнера он привязан к loopback-интерфейсу. Поэтому в параметрах JDWP мы почти всегда хотим биндинг на “все интерфейсы”. В современном синтаксисе это выглядит как address=*:5005. С точки зрения новичка это тот же смысл, что и у Spring Boot, которому мы часто говорим “слушай на 0.0.0.0”.
Чтобы было проще “увидеть руками”, где вы будете ставить breakpoint, покажу маленький условный фрагмент кода (это не новый слой архитектуры, просто ориентир). В реальном проекте у вас есть endpoint GET /api/catalog/items, и breakpoint вы можете поставить, например, в контроллере:
package com.example.catalog.catalog.web;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // REST-контроллер: здесь удобнее всего ставить breakpoint'ы по входящим запросам
public class CatalogItemsController {
@GetMapping("/api/catalog/items") // endpoint, который вы дергаете из браузера/HTTP-клиента для проверки отладки
public String getItems() {
// Если IDE подключена по JDWP, выполнение остановится на breakpoint на следующей строке
return "ok"; // поставьте breakpoint здесь, когда подключите debugger
}
}
Идея такая: контейнер стартует, IDE подключается к порту 5005, вы дергаете endpoint — и исполнение “останавливается” на breakpoint’е.
3. build.target и stage development
Если бы мы жили в мире “один Dockerfile — один образ — один режим”, мы бы добавляли debug‑параметры прямо в Dockerfile и страдали. Но мы уже делали важный шаг раньше: в Dockerfile есть несколько stages, и среди них есть development, который специально предназначен для локального developer‑workflow.
Сейчас нам нужно аккуратно связать это с Compose. У Compose есть настройка build.target: она говорит “собери образ не до финального stage, а до конкретного”. И это очень удобная точка: в normal‑режиме вы используете финальный runtime‑path, а в debug‑режиме — development.
Небольшой фрагмент Dockerfile (упрощённый, только чтобы показать идею именования stages):
# Runtime-стадия: минимальная, только для запуска приложения
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app
# Важно: копируем готовый артефакт (jar), а не исходники
COPY build/libs/catalog-service.jar app.jar
# Точка входа для контейнера в normal-mode
ENTRYPOINT ["java","-jar","app.jar"]
# Development-стадия: может быть "тяжелее" (JDK), но удобнее для локальной разработки/отладки
FROM eclipse-temurin:25-jdk AS development
WORKDIR /app
COPY build/libs/catalog-service.jar app.jar
# Важно: сам артефакт тот же, меняются только runtime-параметры (например, JDWP через env var)
ENTRYPOINT ["java","-jar","app.jar"]
Смысл не в том, что development stage обязательно должен быть таким же. Смысл в том, что у него есть имя (development), и теперь Compose может сказать: “хочу собрать именно его”.
В базовом compose.yaml мы не указываем target вообще. Так проще и честнее: базовый файл должен быть читаемым и максимально близким к “обычному запуску”.
# compose.yaml
services:
app:
build:
context: . # Контекст сборки: весь проект (Dockerfile и исходники рядом)
ports:
- "8080:8080" # HTTP: слева порт хоста, справа порт контейнера
А вот во втором файле, compose.dev.yaml, мы добавляем только dev-отличие: выбор stage.
# compose.dev.yaml
services:
app:
build:
target: development # В debug/dev-режиме собираем именно development-стадию
Это уже полезно даже без отладчика: у вас появляется явный “режим разработки”, не через второй Dockerfile, не через хаос в командах, а через компактный override‑файл. И самое главное — вы не ломаете исходную модель стека: сервис остаётся app, а не превращается в app-debug, app-debug-2, app-debug-final-final2.
4. Порты для debug-режима
Очень легко (особенно в первые разы) “смешать всё со всем”: добавить debug-порт, случайно переопределить HTTP ports, потом удивиться, что API перестало открываться, и подумать, что виноват Docker. На практике проще держать железное правило: в debug‑режиме мы стараемся не ломать то, что уже работает, и добавляем только то, чего не хватает.
HTTP-порт приложения (8080) — это часть нормального runtime пути, поэтому он живёт в compose.yaml. И он остаётся там, потому что API нам нужно в любом режиме — и normal, и debug.
Debug-порт (5005) в normal запуске не нужен, поэтому он не должен жить в базовом файле. Его место — compose.dev.yaml.
# compose.dev.yaml
services:
app:
ports:
- "5005:5005" # JDWP: host:5005 -> container:5005 (IDE подключается к localhost:5005)
Тут есть ещё один маленький практический момент. Если вы запускаете несколько Java-сервисов одновременно (или у вас на машине уже есть кто-то, кто слушает 5005), Compose честно скажет: “порт занят” и контейнер не стартанёт. Это не повод паниковать. Это просто означает, что на host-машине порт 5005 уже используется, и вам нужно либо остановить конфликтующий процесс, либо выбрать другой порт снаружи, например "5006:5005".
Важно заметить, что "5006:5005" означает: снаружи подключаемся к 5006, а внутри контейнера JVM всё равно слушает 5005. Новички часто пытаются “синхронно” поменять везде на 5006 и получают путаницу. В этом месте помогает мысль “слева host, справа контейнер”.
5. JDWP через JAVA_TOOL_OPTIONS
Теперь мы сделали две вещи: научили Compose собирать development stage и опубликовали debug-порт. Но пока JVM не слушает этот порт — он просто открыт в пустоту. Нам нужно сказать JVM: “включи отладчик”.
Самый удобный и “контейнерно-дружелюбный” способ — прокинуть опцию через переменную окружения JAVA_TOOL_OPTIONS. JVM автоматически читает её при старте и добавляет аргументы. Это очень хорошо ложится на принцип “один и тот же артефакт, разные runtime‑параметры” и позволяет не портить Dockerfile ради dev‑настроек.
Выглядит это так:
# compose.dev.yaml
services:
app:
environment:
# JDWP: включаем debug-агент JVM (IDE сможет подключиться по TCP)
JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
Давайте разберём эту строку без ощущения, что вы произнесли заклинание из древнего манускрипта “JVM для избранных”.
Параметр -agentlib:jdwp=... включает стандартный debug-агент. Дальше идёт набор настроек через запятые. transport=dt_socket говорит, что будем дебажить через TCP‑сокет (это классический вариант). server=y означает “JVM будет сервером и будет ждать подключение”. suspend=n означает “не останавливайся на старте, запускай приложение сразу”. Это нормальный default для повседневной разработки, потому что иначе приложение будет висеть и ждать IDE, а вы будете смотреть на “почему не стартует Compose”.
А вот address=*:5005 — очень важная часть именно для контейнера. Она означает “слушай порт на всех интерфейсах”. Если вы поставите address=localhost:5005 (или какой-то вариант, который фактически ограничит биндинг), Docker будет честно публиковать порт, а IDE будет честно не подключаться. И вы получите прекрасный урок: “в контейнерном мире localhost — это часто не то, что вы думаете”.
Иногда вам нужно дебажить старт приложения, например проблему подключения к базе или миграции. В таком случае suspend=n может быть неудобным: вы не успеете поставить breakpoint, потому что всё уже произошло. Тогда вы временно переключаете на suspend=y, и JVM будет ждать подключения IDE:
# compose.dev.yaml (временный вариант для отладки старта)
services:
app:
environment:
# Важно: при suspend=y приложение "ждёт" IDE и не продолжает запуск
JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5005"
Только не забывайте, что при suspend=y приложение “не поднимется”, пока вы не подключите debugger. Для человека, который впервые это видит, выглядит как «контейнер завис». На деле он просто дисциплинированно ждёт вас, как кот у закрытой двери ванной.
Если объединить всё, то compose.dev.yaml обычно превращается в маленький, но очень содержательный файл: он не копирует весь сервис, а добавляет ровно то, что нужно для отладки.
# compose.dev.yaml
services:
app:
build:
target: development # Берём development stage из Dockerfile
ports:
- "5005:5005" # Публикуем debug-порт (host -> container)
environment:
# Включаем JDWP и слушаем на всех интерфейсах контейнера
JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
Обратите внимание: мы не трогаем SPRING_PROFILES_ACTIVE, datasource URL и остальные параметры. Всё это уже правильно настроено в compose.yaml. Debug‑режим — это не “другая конфигурация сервиса”, это “тот же сервис, но с возможностью подключить IDE”.
6. Проверка и запуск debug-режима
После всех настроек наступает момент истины. И здесь идеально работает привычка из прошлой лекции: прежде чем запускать, сначала смотрим, что Compose реально понял.
Команда выглядит так:
# Проверяем итоговую конфигурацию после merge двух файлов
docker compose -f compose.yaml -f compose.dev.yaml config
Идеальный результат — когда вы глазами находите в выводе три вещи: build.target: development, опубликованный порт 5005:5005 и переменную JAVA_TOOL_OPTIONS внутри app. Если в вашей голове есть хоть одно “кажется, я это добавлял” — значит, стоит смотреть config, а не гадать.
Запуск debug‑режима — это тот же up, только с двумя файлами. Обратите внимание, что команда сама по себе уже документирует, в каком режиме вы работаете: это именно то, чего мы добиваемся в daily workflow.
# Запуск: базовый compose.yaml + dev override (сборка + поднятие контейнеров)
docker compose -f compose.yaml -f compose.dev.yaml up --build
Когда контейнер стартанёт, в логах приложения вы часто увидите строку от debug-агента (она может отличаться, но смысл будет тот же). Если вы видите что-то вроде “Listening … 5005”, это отличный знак: JVM действительно слушает debug-порт.
Дальше самый простой “IDE-agnostic” смысл такой: создаёте в своей IDE конфигурацию удалённой отладки (Remote JVM Debug), указываете host=localhost, port=5005 и подключаетесь. В этот момент breakpoint’ы начинают работать так же, как при локальном Debug‑запуске.
7. Типичные ошибки при включении debug-режима через Compose
Ошибка №1: debug включён в JAVA_TOOL_OPTIONS, но порт 5005 не опубликован.
Очень распространённая картина: вы добавили JAVA_TOOL_OPTIONS, видите в логах, что агент активен, но IDE подключиться не может. Причина банальная: контейнер слушает порт внутри себя, но host-машина про него не знает. В Compose это лечится ровно одной строкой в compose.dev.yaml: ports: - "5005:5005".
Ошибка №2: порт опубликован, но JVM слушает не там (или не на том интерфейсе).
Если вы используете некорректный address в JDWP, можно получить ситуацию “порт проброшен, а подключение всё равно не идёт”. Для контейнерного сценария почти всегда нужен address=*:5005, чтобы JVM слушала на всех интерфейсах. Варианты “по умолчанию” или “localhost” часто приводят к ощущению мистики, хотя проблема чисто сетевого биндинга.
Ошибка №3: debug-параметры положили в compose.yaml, и они стали частью normal mode.
Сначала кажется удобным: один файл, всё рядом. Потом начинается жизнь: у коллеги внезапно не стартует проект, потому что порт 5005 занят; кто-то случайно запушил debug-настройки в общий репозиторий; кто-то не понимает, почему сервис “с дыркой” наружу. Правильная дисциплина — держать debug в compose.dev.yaml, чтобы он включался только когда вы этого хотите.
Ошибка №4: сделали отдельный сервис app-debug, и теперь два сервиса разъезжаются.
Это тихий убийца поддерживаемости. Как только появляются два сервиса, начинаются расхождения: в одном забыли env var, в другом — volume, в третьем — healthcheck, и всё это перестаёт быть системой. Гораздо проще и взрослее — переопределять существующий app через override‑файл. Тогда wiring остаётся единым, и вы меняете только “линзу режима”.
Ошибка №5: поставили suspend=y и решили, что контейнер завис.
При suspend=y JVM честно ждёт debugger и не продолжает запуск. Если вы запускаете Compose и видите, что приложение “не выходит на ready”, а healthcheck ругается, первым делом вспомните: вы точно не включили suspend=y? Этот режим полезен, но его нужно включать осознанно и понимать последствия.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ