1. Debug у контейнері — це нормальний режим
Коли ви запускаєте Spring Boot локально через IDE, налагодження здається природним: поставили breakpoint, натиснули Debug — і все працює. У контейнері новачок часто потрапляє в дивну психологічну пастку: «контейнер — це ніби „не мій компʼютер“, отже налагоджувати там не можна». Насправді можна, і ще як. Просто потрібно памʼятати, що в контейнера є мережа, порти й цілком звичайна JVM усередині.
У нашому курсі debug-режим не має перетворюватися на окрему архітектуру. Ми не створюємо окремий застосунок «app-debug» і не розмножуємо ще один Compose-стек. Нам потрібно лише одне: той самий сервіс app, той самий набір залежностей і той самий підхід до конфігурації, але з двома точковими відмінностями. По-перше, контейнер має відкрити додатковий порт для підʼєднання налагоджувача. По-друге, JVM має стартувати з увімкненим JDWP (Java Debug Wire Protocol), щоб IDE могла підʼєднатися.
Саме тому debug-режим — ідеальний кандидат для compose.dev.yaml: це поведінка, потрібна саме для розробки, яку варто вмикати явно, окремою командою запуску, а не ховати в базовому compose.yaml як тиху міну. Якщо ви колись натрапляли на ситуацію, коли «у мене в проєкті весь час відкритий якийсь порт 5005, і я вже не памʼятаю навіщо» — вітаю, ви бачили анти-патерн у природному середовищі.
Для орієнтиру зафіксуємо просту думку у вигляді таблиці — вона стане в пригоді, коли ви почнете плутатися, де саме має лежати те чи інше налаштування:
| Що налаштовуємо | Звичайний режим (compose.yaml) | Debug-режим (compose.dev.yaml) |
|---|---|---|
| Склад стека (postgres/redis/rabbitmq) | так | ні (склад не змінюємо) |
| HTTP-порт застосунку | так | зазвичай ні (залишається як є) |
| Debug-порт JVM (наприклад, 5005) | ні | так |
| Вибір stage Dockerfile (build.target) | зазвичай ні | так (якщо є stage development) |
| Параметри debug для JVM | ні | так (через змінну середовища) |
2. Віддалене налагодження JVM: IDE підʼєднується через мережу
Віддалене налагодження звучить грізно лише доти, доки ви не уявите його як звичайний мережевий сервіс. JVM у debug-режимі починає слухати TCP-порт і чекає, поки підʼєднається IDE. IDE підʼєднується, домовляється з JVM, а далі ви ставите breakpoint так само, ніби застосунок було запущено локально. Жодної магії — лише мережа.
Щоб мозок перестав опиратися, корисно уявити собі маленьку схему:
flowchart TD
IDE["IDE (Налагоджувач)"] -->|"JDWP TCP :5005"| Host["Хост (ваш компʼютер)"]
Host -->|"ports: 5005:5005"| Docker["Docker / Compose"]
Docker --> App["контейнер: 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-контролер: тут найзручніше ставити точки зупинки на вхідних запитах
public class CatalogItemsController {
@GetMapping("/api/catalog/items") // endpoint, який ви викликаєте з браузера або HTTP-клієнта для перевірки налагодження
public String getItems() {
// Коли IDE підʼєднана через JDWP, виконання зупиниться на breakpoint у наступному рядку
return "ок"; // поставте breakpoint тут, коли підʼєднаєте налагоджувач
}
}
Ідея така: контейнер стартує, IDE підʼєднується до порту 5005, ви викликаєте endpoint — і виконання «зупиняється» на breakpointʼі.
3. build.target і stage development
Якби ми жили у світі «один Dockerfile — один образ — один режим», довелося б додавати debug-параметри прямо в Dockerfile і потім із цим мучитися. Але ми вже зробили важливий крок раніше: у Dockerfile є кілька stage, і серед них є development, який спеціально призначений для локального робочого процесу розробки.
Тепер нам потрібно акуратно повʼязати це з Compose. У Compose є налаштування build.target: воно каже «зібрати образ не до фінального stage, а до конкретного». І це дуже зручна точка: у звичайному режимі ви використовуєте фінальний runtime-шлях, а в debug-режимі — development.
Невеликий фрагмент Dockerfile — спрощений, лише щоб показати ідею іменування stage:
# Runtime-стадія: мінімальна, лише для запуску застосунку
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app
# Важливо: копіюємо готовий артефакт (jar), а не вихідний код
COPY build/libs/catalog-service.jar app.jar
# Точка входу для контейнера у звичайному режимі
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 через змінну середовища)
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) у звичайному запуску не потрібен, тому він не має жити в базовому файлі. Його місце — compose.dev.yaml.
# compose.dev.yaml
services:
app:
ports:
- "5005:5005" # JDWP: host:5005 -> container:5005 (IDE підʼєднується до localhost:5005)
Тут є ще один невеликий практичний момент. Якщо ви запускаєте кілька Java-сервісів одночасно або на вашій машині вже є хтось, хто слухає 5005, Compose чесно скаже: «порт зайнято», і контейнер не стартує. Це не привід панікувати. Це просто означає, що на хості порт 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 означає: не зупиняйся на старті, запускай застосунок одразу. Це нормальне значення за замовчуванням для повсякденної розробки, тому що інакше застосунок зависне в очікуванні 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, тільки з двома файлами. Зверніть увагу, що сама команда вже документує, у якому режимі ви працюєте. Саме цього ми й домагаємося в щоденному робочому процесі.
# Запуск: базовий compose.yaml + dev override (збірка і підняття контейнерів)
docker compose -f compose.yaml -f compose.dev.yaml up --build
Коли контейнер стартує, у логах застосунку ви часто побачите рядок від debug-агента. Він може відрізнятися, але сенс буде той самий. Якщо ви бачите щось на кшталт “Listening … 5005”, це чудовий знак: JVM справді слухає debug-порт.
Далі все дуже просто й не залежить від конкретної IDE: створюєте у своїй IDE конфігурацію віддаленого налагодження (Remote JVM Debug), вказуєте host=localhost, port=5005 і підʼєднуєтеся. У цей момент breakpointʼи починають працювати так само, як під час локального Debug-запуску.
7. Типові помилки під час увімкнення debug-режиму через Compose
Помилка № 1: debug увімкнено в JAVA_TOOL_OPTIONS, але порт 5005 не опубліковано.
Дуже поширена картина: ви додали JAVA_TOOL_OPTIONS, бачите в логах, що агент активний, але IDE підʼєднатися не може. Причина проста: контейнер слухає порт усередині себе, але хост-машина про нього не знає. У Compose це лікується рівно одним рядком у compose.dev.yaml: ports: - "5005:5005".
Помилка № 2: порт опубліковано, але JVM слухає не там або не на тому інтерфейсі.
Якщо ви використовуєте некоректний address у JDWP, можна отримати ситуацію: порт проброшено, а підʼєднання все одно не працює. Для контейнерного сценарію майже завжди потрібен address=*:5005, щоб JVM слухала на всіх інтерфейсах. Варіанти «за замовчуванням» або «localhost» часто створюють відчуття містики, хоча проблема тут суто в мережевій привʼязці.
Помилка № 3: debug-параметри поклали в compose.yaml, і вони стали частиною звичайного режиму.
Спочатку це здається зручним: один файл, усе поруч. Потім починається життя: у колеги раптом не стартує проєкт, бо порт 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? Цей режим корисний, але його потрібно вмикати свідомо й розуміти наслідки.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ