JavaRush /Курсы /Docker for Spring /JDWP remote debug в контейнере

JDWP remote debug в контейнере

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

1. Remote debug в контейнере: смысл remote

Когда вы впервые слышите remote debugging, мозг рисует картинку: где-то далеко сервер, к нему летит интернет, и вы героически подключаете отладчик через космос. В Docker всё проще и одновременно хитрее: контейнер живёт локально, но с точки зрения процессов и сети он — отдельная «капсула». Поэтому IDE подключается к JVM по сетевому порту, как будто JVM не рядом, а «где-то там».

Что меняется по сравнению с обычным локальным запуском ./gradlew bootRun? В обычном режиме IDE сама стартует JVM и держит её «за руку»: процесс, stdout, точки останова — всё рядом. В контейнере JVM стартует внутри контейнера как отдельный процесс, а вы подключаетесь к нему по протоколу отладки, который JVM умеет слушать на порту. В результате у нас появляются две независимые вещи: HTTP-порт приложения (для запросов к API) и debug-порт JVM (для вашего отладчика). И если перепутать эти два порта — вы будете очень уверенно дебажить… но не то.

Чтобы это не было абстракцией, можно представить схему так:

flowchart TD
  %% IDE подключается к debug-порту на хосте, а Docker прокидывает трафик в контейнер
  IDE["IDE (Debugger)"] -->|TCP :5005| HOST["Host OS"]
  HOST -->|docker port mapping| CONTAINER["Container network namespace"]
  CONTAINER -->|JDWP| JVM["JVM process (Spring Boot)"]

  %% Пользовательские запросы идут отдельно по HTTP-порту приложения
  USER["curl / Postman"] -->|HTTP :8080| HOST
  HOST -->|docker port mapping| CONTAINER
  CONTAINER -->|HTTP| APP["Spring Boot app"]

IDE и curl ходят «в одну коробку» (ваш компьютер), но по разным дверям: 8080 — для пользователей и тестов API, 5005 — для отладчика.

2. JDWP: базовая идея и назначение

Если вы когда-нибудь дебажили через System.out.println("я здесь"), поздравляю: вы знакомы с «лог-ориентированной археологией». Иногда она спасает, но как метод отладки она напоминает попытку починить телевизор, стуча по нему учебником по физике. JDWP существует, чтобы можно было дебажить нормально: ставить breakpoints, смотреть переменные, шагать по коду, ловить исключения в момент возникновения, а не по следам в логах.

JDWP (Java Debug Wire Protocol) — это протокол, по которому JVM общается с отладчиком по сети. В реальной жизни это означает простую вещь: JVM можно запустить с «агентом отладки», который начинает слушать порт (например, 5005) и ждать подключения IDE. IDE подключается, договаривается с JVM, и дальше вы получаете привычный опыт отладки, только JVM находится не в вашем «локальном» процессе IDE, а в контейнере.

Важно понять одну вещь: JDWP — это не «фича Spring Boot». Spring Boot тут вообще ни при чём, он просто приложение внутри JVM. JDWP — это часть механики JVM. Поэтому debug-порт никак не связан с server.port, с вашими контроллерами и конфигурацией Boot. Это отдельный сетевой вход в JVM, и относиться к нему нужно как к отдельной двери, которую вы открываете только когда надо.

3. Debug-порт и HTTP-порт: не смешиваем протоколы

Когда у вас есть контейнер с сервисом, вы привыкли: «ну там порт 8080, я его пробрасываю». И вот здесь начинается типичная ловушка новичка: «А давайте и отладку тоже на 8080!». Нет, давайте не будем. Вы хотите, чтобы HTTP-сервер принимал HTTP, а отладчик говорил с JVM по JDWP. Это разные протоколы и разные ожидания.

С практической точки зрения это выглядит так: у Container-Ready Catalog Service есть, условно, HTTP-порт 8080 (на нём вы проверяете /api/catalog/items), и есть debug-порт 5005 (на него подключается IDE). При запуске контейнера вы публикуете оба порта отдельно.

Пример команды:

# 8080 — HTTP для API
# 5005 — JDWP для IDE
docker run --rm \
  -p 8080:8080 \
  -p 5005:5005 \
  docker-java-catalog-service:dev

Обратите внимание: -p 8080:8080 делает сервис доступным по HTTP, а -p 5005:5005 делает доступным debug-порт JVM. Если вы забыли второй -p, приложение может отлично работать по HTTP, но IDE будет смотреть на вас с немым укором: «я не могу подключиться».

Для закрепления можно держать короткую «табличку смыслов»:

Что это Порт Кто подключается Зачем
HTTP-порт приложения 8080 curl, Postman, браузер Проверять API, дергать endpoints
Debug-порт JVM 5005 IDE debugger Ставить breakpoints, шагать по коду

И да: цифры не магические. 5005 — просто традиционное значение, которое встречается почти в каждом примере. Можно выбрать другое, но 5005 удобно именно тем, что у вас меньше шансов случайно спутать его с HTTP.

4. -agentlib:jdwp=...: параметры без боли

Самая «страшная» часть темы выглядит обычно вот так:

-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005

На первый взгляд кажется, что это пароль от вай-фая соседа, но на самом деле там всего несколько параметров. Логика простая: мы просим JVM включить JDWP-агент и описываем, как именно он должен работать.

Минимальная команда запуска Java для debug-режима чаще всего выглядит так:

java \
  -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 \
  -jar app.jar

Разберём параметры в человеческом виде.

Параметр Типичное значение О чём говорит JVM
transport dt_socket «Общаемся через TCP-сокет (порт).»
server y «JVM — сервер: слушаю порт и жду IDE.»
suspend n «Не жду IDE, стартую приложение сразу.»
address *:5005 «Слушаю порт 5005 на всех интерфейсах контейнера.»

Режим server=y

В учебном dev-сценарии удобнее, когда JVM «сидит и ждёт входящего звонка», а IDE «звонит ей». Это и есть server=y. Обратный режим (когда JVM сама куда-то коннектится) существует, но он нам сейчас не нужен и только добавит путаницы.

Режимы suspend=n и suspend=y

Параметр suspend отвечает на вопрос: «Должна ли JVM остановиться на старте и ждать подключения отладчика?»

Если suspend=n, JVM запускает приложение как обычно, просто параллельно слушает debug-порт. Это самый комфортный вариант: контейнер поднимается, сервис отвечает, вы подключаетесь отладчиком когда хотите.

Если suspend=y, JVM запускается, открывает debug-порт… и останавливается до подключения IDE. Это полезно, когда вы хотите поймать самые ранние моменты старта, например инициализацию бинов или чтение конфигурации. Но новичку этот режим часто кажется «сломался контейнер, ничего не происходит», потому что HTTP-сервер ещё не стартовал и логи могут выглядеть «подозрительно спокойно».

Для такого dev-образа чаще всего разумный default — suspend=n. А suspend=y включается осознанно, под конкретную диагностическую ситуацию.

address=*:5005 внутри контейнера

Вот здесь ломается интуиция «localhost». В контейнере localhost — это сам контейнер, а не ваш хост-компьютер. Если вы скажете JVM слушать debug-порт только на localhost, то иногда вы сами себе перекрываете доступ извне контейнера.

Поэтому популярный и понятный вариант — address=*:5005, то есть «слушаю на всех интерфейсах внутри контейнера». Тогда Docker-проброс порта (-p 5005:5005) сможет нормально доставить трафик до JVM.

5. JDWP в development stage Dockerfile

Теперь, когда параметры не выглядят как руническая надпись, мы можем аккуратно встроить их в development-stage. Ключевая идея остаётся прежней: debug-настройки должны жить в development-пути, а runtime-путь должен оставаться чистым. Иначе вы однажды случайно выкатите в «обычный запуск» образ, который слушает debug-порт, и это будет очень «весело» (спойлер: не вам).

Мини-фрагмент Dockerfile для development stage может выглядеть так:

FROM eclipse-temurin:25-jdk AS development
WORKDIR /app

# Берём собранный JAR из build-стейджа
COPY --from=builder /workspace/build/libs/*.jar app.jar

# Документируем порты: HTTP приложения и debug-порт JDWP
EXPOSE 8080 5005

# Запускаем без shell-прослойки (exec-form)
ENTRYPOINT ["java"]

# В dev-стейдже добавляем JDWP-агент, чтобы IDE могла подключиться
CMD ["-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005", "-jar", "app.jar"]

Здесь важны несколько моментов.

Во-первых, мы продолжаем держать exec-form для ENTRYPOINT/CMD. Это не просто стиль — это дисциплина запуска контейнерного процесса без лишней shell-прослойки.

Во-вторых, EXPOSE 5005 — это, по сути, документация. Он не публикует порт автоматически, но делает Dockerfile понятнее: «да, этот образ рассчитан на debug-подключение».

В-третьих, CMD содержит debug-параметр только в development stage. В runtime stage (который остаётся default) этот параметр отсутствует, и это нормально.

Теперь development stage уже наполнен всем нужным. Остался build-time вопрос: как получить именно этот stage как результат docker build, не делая debug default.

6. Запуск debug-контейнера: что реально нужно

После правок Dockerfile вам нужно две вещи: собрать именно development-образ и запустить контейнер с публикацией двух портов. Здесь фиксируем второй кусок — как выглядит сам запуск, когда image уже содержит -agentlib:jdwp=....

docker run --rm \
  -p 8080:8080 \
  -p 5005:5005 \
  docker-java-catalog-service:dev

Здесь --rm просто убирает контейнер после остановки, чтобы у вас не рос «кладбищенский парк» из контейнеров с именами вроде stoic_hypatia (Docker очень романтичен в этом вопросе).

Пара тонких моментов, которые часто путают:

EXPOSE 5005 не заменяет -p 5005:5005. Первый — подсказка и документация, второй — реальный проброс порта наружу.

Debug-параметры JVM сами по себе тоже не «открывают порт наружу». Они только заставляют JVM слушать порт внутри контейнера. А чтобы вы могли подключиться с хоста, порт нужно опубликовать через Docker.

7. Подключение отладчика: общий алгоритм

В большинстве IDE логика одинакова, даже если кнопки и названия разные. У вас есть запущенный контейнер, который слушает debug-порт, и вам нужно подключить debugger как «к удалённой JVM».

Смысловые шаги такие: вы создаёте конфигурацию Attach to remote JVM, указываете host и port, после чего нажимаете «подключиться». В локальном Docker-сценарии host обычно будет localhost, потому что вы пробросили порт на хост-машину, а порт — 5005.

То есть с точки зрения IDE вы подключаетесь не «в контейнер», а в «локальный порт на своём компьютере», а Docker дальше уже перенаправляет трафик внутрь контейнера. Это важно психологически: если вы пробросили -p 5005:5005, то IDE подключается к localhost:5005, а не к «IP контейнера» (контейнерные IP меняются, и мы не хотим жить в этом хаосе).

Если у вас стоит suspend=y, то подключение отладчика станет обязательным условием, чтобы приложение вообще начало стартовать. В режиме suspend=n вы можете подключаться когда угодно, в том числе уже после того, как сервис начал отвечать по HTTP.

8. Breakpoint в Catalog Service: где смотреть смысл

Чтобы отладка была не «ради галочки», полезно заранее выбрать место в коде, где реально происходит что-то осмысленное. В нашем учебном сервисе хорошая цель — сервисный метод, который обрабатывает запрос контроллера, например получение списка элементов каталога.

Пример условного (упрощённого) метода в сервисе каталога может выглядеть так:

package com.example.catalog.catalog.service;

import org.springframework.stereotype.Service;

@Service
public class CatalogItemService {

    public String debugMarker(long id) {
        // В реальном проекте тут обычно будет логика: репозиторий, DTO, проверки и т.д.
        // Для обучения нам важно место, куда удобно поставить breakpoint и посмотреть переменные
        return "Looking for item id=" + id; // сюда удобно поставить breakpoint
    }
}

Да, пример нарочно простоват: нам важно не «написать весь сервис заново», а показать, что при запросе к API вы можете остановиться в нужной точке и посмотреть переменные. В реальном проекте у вас будет метод вроде getById, который обращается к репозиторию (in-memory или JPA — в зависимости от режима проекта), строит DTO, проверяет статусы и т.д. Именно там breakpoint даёт максимальную пользу: вы видите реальные аргументы, реальные ветвления и реальные данные.

Дальше сценарий выглядит как в кино (только без попкорна): вы ставите breakpoint, отправляете HTTP-запрос (через Postman или .http файл), поток исполнения доходит до breakpoint, IDE останавливает выполнение, и вы шаг за шагом понимаете, почему «ожидали одно, получили другое».

9. Debug-режим: только для development

Когда вы впервые успешно подключились к JVM в контейнере и поймали breakpoint — есть соблазн оставить debug включенным навсегда. Это примерно как оставить открытой дверь в квартиру, потому что «так быстрее заходить». Вроде удобно, но потом возникают вопросы.

Во-первых, debug-агент — это дополнительная поверхность доступа. В учебном проекте на локальной машине это не страшно, но как инженерная привычка это плохой default.

Во-вторых, debug влияет на поведение: breakpoints останавливают потоки, тайминги меняются, и иногда ошибки «исчезают» или «появляются» только из-за того, что вы вмешались в темп выполнения. Это нормальная особенность отладки, но она превращается в проблему, если debug становится штатным режимом запуска.

В-третьих, development stage — это инструмент под конкретную задачу. Runtime stage должен оставаться простым, предсказуемым и максимально похожим на «обычный запуск». Иначе вы постепенно перестаёте понимать, что является baseline, а что — режимом «для ковыряния».

Практический вывод простой: JDWP — только в development path, а не в основном runtime образе.

10. Типичные ошибки при JDWP-отладке в контейнере

Ошибка №1: забыли опубликовать debug-порт, но ждёте, что IDE подключится.
Это самая частая история: вы честно добавили -agentlib:jdwp=..., контейнер запустился, API работает, а IDE пишет Connection refused. В таком случае проблема обычно не в Java и не в Spring, а в том, что вы не сделали -p 5005:5005. JVM слушает порт внутри контейнера, но снаружи этот порт никто не видит.

Ошибка №2: перепутали HTTP-порт и debug-порт.
Иногда пытаются подключить debugger к 8080, потому что «ну сервис же на 8080». А иногда, наоборот, пытаются дергать /api/catalog/items на 5005. Оба варианта обречены: 8080 — это HTTP, 5005 — это JDWP. Они не взаимозаменяемы, как не взаимозаменимы «телефонный звонок» и «посылка по почте».

Ошибка №3: поставили suspend=y и решили, что контейнер «завис на старте».
С suspend=y JVM честно ждёт подключения отладчика, и это выглядит как «тишина» или «приложение не поднимается». На самом деле оно просто в режиме «жду, когда вы придёте и начнёте дебажить». Если вы не планировали ловить самый ранний старт, ставьте suspend=n — и живите спокойно.

Ошибка №4: слушают debug-порт на localhost внутри контейнера.
Если вы используете адрес вроде address=localhost:5005, вы рискуете сделать порт недоступным извне контейнера, потому что localhost в контейнере — это «контейнер сам», а не ваш компьютер. В учебном baseline проще и понятнее использовать address=*:5005, чтобы JVM слушала на всех интерфейсах контейнера, а Docker уже решит, как пробросить порт наружу.

Ошибка №5: случайно утащили JDWP-параметры в runtime stage.
Если вы добавили debug-параметры в общий CMD «на всякий случай», вы перестаёте различать development и runtime режимы. Потом вы запускаете «обычный образ», а он слушает debug-порт, и вы даже не помните почему. Лекарство простое: держите JDWP только в development stage и используйте разные теги образов, чтобы роль была видна прямо в имени.

1
Задача
Docker for Spring, 10 уровень, 2 лекция
Недоступна
JDWP с `suspend=n`
JDWP с `suspend=n`
1
Задача
Docker for Spring, 10 уровень, 2 лекция
Недоступна
JDWP с `suspend=y`
JDWP с `suspend=y`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ