1. Ланцюжок Dockerfile → image → container
Ось і той момент, коли в багатьох нарешті складається цілісна картина 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. І лише потім ідемо в логи — так картина перестає бути містикою.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ