JavaRush /Курси /Docker for Spring /Перший Docker image...

Перший Docker image і запуск контейнера

Docker for Spring
Рівень 3 , Лекція 3
Відкрита

1. Ланцюжок Dockerfileimagecontainer

Ось і той момент, коли в багатьох нарешті складається цілісна картина Docker. До цього ми розбирали концепції, писали файл і знайомилися з термінами. А тепер робимо те, заради чого все й починалося: запускаємо сервіс у контейнері. Щоб далі не плутатися, тримаємо в голові простий ланцюжок: Dockerfile — це інструкція, image — результат збирання, container — запущений процес із image. Якщо переплутати ці ролі, дуже швидко з’являються класичні фрази на кшталт «я запустив image» і «у мене зник container, але я нічого не робив».

Дуже зручно уявляти це як конвеєр:

flowchart TD
    %% Зліва направо: від Dockerfile до образу, від образу до контейнера і далі до HTTP на хості
    A["Dockerfile + контекст збирання
(зокрема build/libs/*.jar)"] -->|docker build| B["Image
docker-java-catalog-service:day3"] B -->|docker run| C["Container
catalog-service"] C -->|публікація порту -p| D["HTTP доступний на хості
http://localhost:8080"]

У цій лекції йдемо рівно за стрілками: спочатку docker build, потім docker run, далі швидко перевіряємо, чи «контейнер живий», через docker ps. HTTP-перевірку та логи поки не змішуємо з першим запуском: спершу потрібно побачити, що образ збирається, контейнер стартує і процес узагалі працює.

2. Збирання образу: docker build -t ... .

Команда docker build — це момент, коли Docker бере ваш Dockerfile і контекст збирання, виконує інструкції згори вниз і створює новий image. Тут важливо зрозуміти дві речі. По-перше, Docker не «вгадує», який саме JAR вам потрібен, — він просто виконує той COPY, який ви написали. По-друге, збирання майже ніколи не ламається «випадково»: зазвичай Docker не знаходить файл у контексті або ви запускаєте build не з того каталогу.

Найпростіший сценарій виглядає так, якщо ви стоїте в корені репозиторію:

# 1) Спочатку збираємо jar локально, щоб він з’явився в build/libs
./gradlew bootJar

# 2) Потім збираємо Docker image з Dockerfile у поточній директорії (build context = .)
docker build -t docker-java-catalog-service:day3 .
# (Docker читає Dockerfile і контекст із поточної директорії)

Якщо ви на Windows і у вас немає bash, команда збирання може виглядати як gradlew.bat bootJar. Але суть не змінюється: спочатку JAR, потім Docker.

Тепер розберемо команду docker build по кісточках, але без перетворення на довідник прапорців:

docker build — запускає збирання image.
-t docker-java-catalog-service:day3 — задає тег, тобто зручну для читання назву образу.
. — це build context, поточна директорія. Саме відносно неї Docker шукатиме файли для COPY.

Тег образу і latest

Тег в образу — це як ярлик на банці. Можна, звісно, підписати «щось смачне», але через тиждень у холодильнику це перетворюється на квест. У Docker тег зазвичай має вигляд repository:tag.

У нашому випадку:

docker-java-catalog-service — назва репозиторію, тобто родини образів у локальному Docker.
day3 — тег, який ми вибрали, щоб явно показати: «це версія, зібрана на день 3».

Можна було б не писати :day3, і тоді Docker за замовчуванням використав би latest. Але для навчання і для голови початківця latest — майже завжди джерело плутанини. Сьогодні ви зібрали одне, завтра інше, а тег у них один і той самий. Тому краще відразу звикнути давати тег явно, навіть якщо він суто навчальний.

Читання виводу docker build

Коли ви запускаєте docker build, Docker починає показувати кроки. У сучасних версіях Docker, особливо з BuildKit, вивід виглядає «стрілочками» і стадіями. Він може бути приблизно таким:

# Тут найважливіше бачити крок COPY: саме на ньому найчастіше і падає збирання
[+] Building 2.8s (8/8) FINISHED
 => [internal] load build definition from Dockerfile
 => [internal] load .dockerignore
 => [1/3] FROM eclipse-temurin:25-jdk
 => [2/3] WORKDIR /app
 => [3/3] COPY build/libs/docker-java-catalog-service-0.0.1-SNAPSHOT.jar app.jar
 => exporting to image
 => => naming to docker.io/library/docker-java-catalog-service:day3

Зараз вам не потрібно розуміти кожен рядок. Достатньо навчитися бачити головне: Docker справді знайшов Dockerfile, прочитав .dockerignore, завантажив базовий образ із FROM, якщо його ще не було локально, виконав COPY JAR-файлу і наприкінці присвоїв образу назву.

Найкорисніша думка тут така: якщо збирання впало на кроці COPY, це майже ніколи не «проблема Docker». Зазвичай це означає, що JAR не зібрано, шлях написано неправильно або ви збираєте не з кореня проєкту.

3. Перевіряємо image: docker image ls

Після збирання хочеться не вірити на слово, а переконатися: образ з’явився, у нього очікувані назва і тег, і ми запускаємо саме його, а не якусь стару версію, зібрану «минулого тижня, але я точно пам’ятаю, що все було добре». Docker дає для цього дуже просту команду — список образів. Тут новачки часто плутають список images і список containers, тому проговоримо прямо: зараз ми перевіряємо образи, а не запущені процеси.

Перевірка виглядає так:

# Показати всі локальні образи (це саме image, не container)
docker image ls
# або короткий варіант:
docker images

А щоб не дивитися на весь зоопарк локальних образів, зручно відфільтрувати вивід за назвою:

# Фільтр за назвою репозиторію, щоб швидше знайти потрібний тег
docker image ls docker-java-catalog-service
# REPOSITORY                 TAG    IMAGE ID       CREATED          SIZE
# docker-java-catalog-service day3   1a2b3c4d5e6f   10 seconds ago   400MB

Числа у вас будуть іншими — і це нормально. Що важливо помітити в таблиці:

Стовпець Що означає простою мовою
REPOSITORY «Родина» образу, його назва
TAG Мітка версії, у нашому випадку day3
IMAGE ID Технічний ідентифікатор образу
CREATED Коли образ зібрали
SIZE Розмір образу

Якщо ви бачите docker-java-catalog-service:day3, значить збирання не існувало лише у вашій уяві. Образ справді є.

І так, розмір може виявитися чималим. Не лякайтеся: на цьому етапі ми не оптимізуємо образ і не влаштовуємо «полювання за мегабайтами». Наша ціль сьогодні — перший робочий запуск. До схуднення образів і інженерії шарів дійдемо пізніше, але не зараз, щоб не намагатися одночасно вчитися плавати й складати іспит з океанології.

4. Запуск контейнера: docker run

Найчастіший емоційний момент у новачка звучить так: «Я зібрав образ, чому застосунок не працює?» Тому що образ — це як запакована валіза. Валіза сама по собі нікуди не їде. Потрібна людина. У світі Docker цією «людиною» стає контейнер, тобто запущений процес, створений із image. І запускається він командою docker run.

Ми хочемо зробити одразу три речі: створити контейнер, дати йому зрозумілу назву та опублікувати порт назовні, щоб на localhost з’явився наш API. Команда буде такою:

# Запускаємо контейнер із конкретного image:tag, задаємо назву і публікуємо порт назовні
docker run --name catalog-service -p 8080:8080 docker-java-catalog-service:day3

Розберімо її спокійно:

docker run створює контейнер з образу і відразу запускає його.
--name catalog-service задає назву контейнера. Завдяки цьому можна писати docker logs catalog-service і docker stop catalog-service, не згадуючи випадковий ID на кшталт a8f3c9....
-p 8080:8080 публікує порт.
docker-java-catalog-service:day3 — це образ, який ми запускаємо.

--name для контейнера

Без --name Docker згенерує контейнеру назву на кшталт focused_mendeleev — так, Docker іноді вважає себе поетом. Це мило, але в реальному житті незручно. Ви ж не називаєте змінні в Java a1, a2 і a3, якщо можна назвати catalogService. Тут та сама логіка.

Є і ще один практичний момент: якщо ви вдруге спробуєте запустити контейнер із тим самим --name, Docker не «перезапише старий». Він чесно скаже, що назву вже зайнято. І це нормально, бо контейнери — окремі сутності.

Якщо так сталося, ви робите простий дорослий цикл:

# Спочатку зупиняємо контейнер (якщо він запущений), потім видаляємо, щоб звільнити назву
docker stop catalog-service
docker rm catalog-service

І після цього можна запускати знову. Це не «прибирання сміття», а звичайний lifecycle: зупинили — видалили — запустили новий.

Запуск у «приєднаному» режимі (attach)

Команда docker run за замовчуванням запускає контейнер так, що ви «підʼєднані» до його виводу. Тобто логи застосунку підуть прямо в поточний термінал. Для початківця це навіть зручно: одразу видно, чи стартує Spring Boot і чи не впав він миттєво.

Є нюанс: поки контейнер працює, команда не «закінчиться». Термінал буде зайнятий. Це не зависання, а чесна робота процесу. Якщо вам потрібно продовжувати вводити команди в тому самому вікні, зазвичай використовують запуск у фоновому режимі — detach через -d:

# -d (detach) = запустити у фоновому режимі й одразу повернути керування терміналу
docker run -d --name catalog-service -p 8080:8080 docker-java-catalog-service:day3
# 3f2a1c...  (Docker поверне id контейнера)

Я не буду нав’язувати один варіант як «єдино правильний». Для навчального шляху зручні обидва. Просто пам’ятайте: якщо ви запустили без -d, для наступних команд знадобиться другий термінал або зупинка контейнера. Якщо з -d — термінал відразу повернеться, і ви зможете спокійно далі перевіряти сервіс.

5. Публікація порту: -p host:container

Порти в Docker — це місце, де навіть упевнена в собі людина починає сумніватися в реальності. Бо застосунок «ніби стартував», а браузер відповідає Connection refused. І тут важливо пам’ятати: у контейнера є власний внутрішній світ. Якщо не зробити міст назовні, сервіс буде доступний лише всередині контейнерного всесвіту, а з вашої машини ви до нього просто не дотягнетеся.

Команда -p 8080:8080 читається так: «візьми порт 8080 на моїй машині, тобто на host, і направ його на порт 8080 всередині контейнера, тобто в container».

Ось проста схема:

Ваш ноутбук (хост)          Контейнер (контейнер)
http://localhost:8080  --->  :8080  --->  Spring Boot (server.port=8080)

Якщо порт 8080 на вашій машині вже зайнятий — наприклад, іншим застосунком або старим забутим сервісом, — можна зробити цілком нормальний трюк: взяти інший host-порт, але залишити контейнерний порт тим, на якому справді слухає Spring Boot.

Наприклад:

# Ліворуч (8081) — порт на хості, праворуч (8080) — порт усередині контейнера
docker run -d --name catalog-service -p 8081:8080 docker-java-catalog-service:day3

Тепер застосунок усередині контейнера все ще слухає 8080, але зовні для вас він доступний за адресою http://localhost:8081.

Це один із тих моментів, коли важливо не намагатися «лікувати код», якщо у вас просто зайнятий порт. Код тут ні до чого. Це лише дріт зовні всередину, і його можна вставити в інший роз’єм.

6. Перевірка «контейнер живий»: docker ps і docker ps -a

Після docker run у новачка часто виникає бажання одразу відкрити браузер і радіти життю. Це нормально, але є корисний проміжний крок: переконатися, що контейнер узагалі не завершився за секунду. Контейнер живе рівно стільки, скільки живе основний процес, а у нашому випадку це java -jar app.jar. Якщо Java-процес упав, контейнер «помер» — і це не загадка, а прямий наслідок моделі.

Перевірка робиться так:

# Показує лише ті контейнери, які зараз реально працюють
docker ps

Якщо контейнер запущений, ви побачите його у списку, а в колонці STATUS буде щось на кшталт Up 15 seconds, а в PORTS — зрозумілий рядок мапінгу:

0.0.0.0:8080->8080/tcp

Якщо ж docker ps нічого не показує або не показує ваш контейнер, це не означає «Docker зламався». Це означає лише одне: контейнер зараз не працює. Тоді дивимося розширений список:

# Показує і запущені, і зупинені контейнери (тобто «що взагалі було створено»)
docker ps -a

Там ви можете побачити статус на кшталт Exited (1) або Exited (137) і час, коли контейнер завершився. Тут корисно читати exit code буквально: замість розмитого «воно не працює» у вас з’являється конкретний факт — «контейнер завершився з таким-то кодом».

А наступний логічний крок, якщо контейнер завершився, — це логи. Тут нам поки достатньо зафіксувати маршрут: docker ps — це швидкий пульс, docker ps -a показує факт завершення і код, а логи пояснюють причину. Є пульс — продовжуємо. Немає пульсу — дивимося ps -a, потім логи.

7. Типові помилки під час збирання і першого запуску

Помилка №1: збирання запускається не з кореня проєкту.
Це виглядає дуже буденно: ви стоїте в якійсь папці, вводите docker build ... ., і Docker чесно бере «крапку» як контекст… але це не той контекст. У підсумку він або не знаходить Dockerfile, або не знаходить JAR у build/libs. Лікується це не магією, а нудною дисципліною: перед docker build переконайтеся, що ви стоїте в каталозі, де лежать Dockerfile і .dockerignore.

Помилка №2: JAR не зібрано, а Dockerfile намагається його скопіювати.
Найкласичніший сценарій: у Dockerfile написано COPY build/libs/... app.jar, а в build/libs порожньо або лежить старий файл з іншою назвою. Docker видає помилку на кроці COPY, і це правильно — він не зобов’язаний «здогадуватися», що ви забули bootJar. Ліки прості: спершу ./gradlew bootJar, потім ls build/libs, і лише потім docker build.

Помилка №3: ви виправили Dockerfile, але забули пересобрати image.
Це дуже людська помилка, тому що мозок говорить «ну я ж змінив файл». Але Docker не телепат: контейнер запускається з образу, а образ оновлюється лише після docker build. Тому правило просте: змінили Dockerfile — пересобрали image — тільки потім запускаємо контейнер.

Помилка №4: порт 8080 зайнятий, і Docker свариться під час запуску.
Тут зазвичай з’являється повідомлення в стилі «port is already allocated». Це не проблема Spring Boot і не проблема JAR. Це просто означає, що на вашій машині вже хтось слухає 8080. Ви або звільняєте порт, зупиняючи старий процес, або вибираєте інший host-порт, наприклад -p 8081:8080. Головне — не намагатися «лагодити застосунок», коли проблема в тому, у який роз’єм ви вставили дріт.

Помилка №5: контейнер не видно в docker ps, бо він миттєво завершився.
Це дуже часте відчуття: «я запустив, воно зникло». Насправді контейнер не зобов’язаний жити вічно. Якщо всередині впав Java-процес, контейнер завершився. У такій ситуації спершу дивимося docker ps -a, щоб побачити сам факт завершення і exit code. І лише потім ідемо в логи — так картина перестає бути містикою.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ