1. Що таке читабельний Dockerfile
«Читабельний Dockerfile» звучить як фраза зі світу дорослих людей, які ставлять коми в README і не називають змінні x2final_final2. Але сенс тут дуже практичний: Dockerfile має бути таким, щоб ви через місяць відкрили його і зрозуміли, що відбувається, не викликаючи шамана з контейнерами і не перечитуючи весь інтернет.
Читабельність тут — не краса заради краси. Це спосіб тримати під контролем три речі: де ми беремо Java-середовище, куди кладемо артефакт (bootJar) і як запускаємо процес усередині контейнера. Щойно Dockerfile стає з розряду «ніби працює, але незрозуміло чому», ви втрачаєте головну перевагу інженерного підходу: можливість передбачувано змінювати поведінку запуску, не ламаючи все довкола.
Наївний Dockerfile уже розвʼязував одне завдання: взяти Java runtime, покласти jar в образ і якось його запустити. Але окремо ми вже побачили, що цього замало: build-time і runtime треба розвести, ENTRYPOINT має запускати Java напряму, а параметри, що змінюються, не можна запікати в нове перезбирання щоразу. Тепер лишилося зібрати все це в один стійкий варіант Dockerfile, щоб оптимізація шарів і кеш потім працювали поверх стабільного запуску, а не замість нього.
2. Каркас Dockerfile: п’ять блоків
Наївний варіант зазвичай виглядає дуже коротко: базовий образ, COPY jar, java -jar. Для першого запуску цього достатньо. Читабельний варіант відрізняється не кількістю рядків, а тим, що кожен рядок фіксує окрему домовленість: де живе застосунок, як він потрапляє в образ, які значення за замовчуванням у ньому вже задані та який процес Docker вважає головним.
Підсумковий Dockerfile дня
# 1) База: середовище виконання Java 25 (тільки для запуску, без збірки)
FROM eclipse-temurin:25-jre-jammy
# 2) Робоча директорія застосунку всередині контейнера
WORKDIR /opt/app
# 3) Кладемо в образ готовий артефакт збірки (Spring Boot bootJar)
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
# 4) Значення за замовчуванням для запуску (можна перевизначити через docker run -e ...)
ENV SPRING_PROFILES_ACTIVE=standalone
ENV SERVER_PORT=8080
# 5) Документуємо порт, який слухає застосунок усередині контейнера
EXPOSE 8080
# 6) Запускаємо Java як головний процес контейнера (exec-form)
ENTRYPOINT ["java", "-jar", "app.jar"]
Це той варіант, де всі домовленості нарешті зібрані в одному місці. Тут ми свідомо не додаємо ще й CMD з тим самим портом або профілем: команда запуску вже стабільна через ENTRYPOINT, а значення за замовчуванням живуть у ENV і можуть бути перевизначені під час конкретного запуску.
Згори вниз цей Dockerfile читається так:
flowchart TD
A["FROM (база: середовище виконання Java)"] --> B["WORKDIR (робоча папка)"]
B --> C["ARG + COPY (перенесення bootJar)"]
C --> D["ENV + EXPOSE (значення за замовчуванням і документація порту)"]
D --> E["ENTRYPOINT (запуск java у exec-form)"]
| Блок Dockerfile | За що відповідає | Що в нашому Dockerfile |
|---|---|---|
| FROM | Яка Java і яке базове середовище потраплять у контейнер | образ середовища виконання з Java 25 |
| WORKDIR | Де живе застосунок усередині образу | /opt/app |
| ARG + COPY | Як bootJar потрапляє в образ під стабільною назвою | build/libs/*.jar → app.jar |
| ENV + EXPOSE | Які значення за замовчуванням бачить контейнер і який внутрішній порт очікується | SPRING_PROFILES_ACTIVE, SERVER_PORT, EXPOSE 8080 |
| ENTRYPOINT | Як запускається головний процес контейнера | |
CMD тримаємо в голові як окремий патерн для значень за замовчуванням аргументів застосунку. Але в цьому Dockerfile він свідомо не потрібен: ті самі стартові значення вже живуть у ENV, і друге джерело значень за замовчуванням тільки заплутає.
База і робоча папка
FROM і WORKDIR тут потрібні не заради зайвої теорії, а щоб Dockerfile одразу читався згори вниз без хаосу шляхів. Ми відразу бачимо, на якій Java працює сервіс і де всередині образу знаходиться його «дім».
Завдяки цьому app.jar не бовтається по випадкових каталогах, а всі наступні інструкції працюють відносно однієї зрозумілої директорії. Річ маленька, але саме на таких дрібницях файл перестає бути крихким.
Артефакт збірки
Ми свідомо копіюємо не весь проєкт, а результат bootJar. ARG JAR_FILE залишає збірці невеликий люфт, якщо імʼя jar потрібно вказати явно, а COPY ${JAR_FILE} app.jar фіксує всередині образу стабільне імʼя файла.
Це зручно і для читання, і для запуску: ENTRYPOINT більше не залежить від версійованої назви артефакта з build/libs/, а Dockerfile не перетворюється на список випадкових файлів, що потрапили в образ «про всяк випадок».
Значення за замовчуванням і порт
ENV задає розумні значення за замовчуванням: standalone-профіль і внутрішній порт 8080. Це не «навіки прибиті» значення, а стартові домовленості, які можна перебити через docker run -e або аргументи застосунку.
EXPOSE поруч потрібен не для публікації порту назовні, а щоб одразу було видно, який порт сервіс очікує всередині контейнера. Зовнішній доступ і далі налаштовується тільки через docker run -p.
Запуск процесу
ENTRYPOINT ["java", "-jar", "app.jar"] фіксує найважливіше: Docker запускає Java напряму, без shell-посередника, і саме JVM стає головним процесом контейнера. Завдяки цьому stop/restart поводяться передбачувано, а Spring отримує нормальний шанс завершитися акуратно.
CMD пам’ятаємо як робочий патерн для аргументів застосунку за замовчуванням, коли їх справді зручно тримати окремо. Але в цьому Dockerfile ми свідомо не використовуємо його для порту і профілю: дублювати одні й ті самі значення і в ENV, і в CMD — це тільки множити плутанину.
3. Збирання і запуск без правки Dockerfile
Тепер корисно перевірити сам принцип на практиці: один і той самий образ запускається в кількох режимах, а Dockerfile при цьому лишається незмінним.
Спершу, як і раніше, збираємо jar і образ:
# Збираємо виконуваний jar (Spring Boot bootJar)
./gradlew bootJar
# Збираємо Docker-образ із поточного каталогу (Docker build context = .)
docker build -t docker-java-catalog-service .
Базовий запуск «як є», зі значеннями за замовчуванням із Dockerfile:
docker run --rm -p 8080:8080 docker-java-catalog-service
Якщо все добре, у логах ви побачите ознаки старту та порт. Логи у Spring Boot бувають довгими, але вам зараз важливо побачити смислові шматочки: рядок про активний профіль і рядок про те, що сервер піднявся на потрібному порту. Формулювання може відрізнятися, але ідея одна.
Тепер запускаємо той самий образ на іншому порту через змінні середовища:
docker run --rm -p 9090:9090 \
-e SERVER_PORT=9090 \
-e SPRING_PROFILES_ACTIVE=standalone \
docker-java-catalog-service
Тут важливо не переплутати два світи. -p 9090:9090 — це про мережу Docker (host:container). SERVER_PORT=9090 — це про те, на якому порту сам застосунок усередині контейнера слухатиме HTTP. Якщо змінили одне й забули інше, отримаєте класичне «контейнер живий, але сервіс не відкривається».
Той самий ефект можна отримати через аргументи застосунку: при exec-form ENTRYPOINT усе, що ви допишете після імені образу, потрапить у Spring Boot як app args.
docker run --rm -p 9090:9090 \
docker-java-catalog-service \
--server.port=9090 \
--spring.profiles.active=standalone
А ось окремий канал для JVM-опцій — JAVA_TOOL_OPTIONS. Його сенс у тому, що це спосіб додати параметри JVM, не перетворюючи ENTRYPOINT на shell-form і не ліплячи туди «зшитий рядок».
Наприклад, обмежимо heap — чисто як демонстрацію каналу, без фанатизму:
docker run --rm -p 8080:8080 \
-e JAVA_TOOL_OPTIONS="-Xms256m -Xmx256m" \
docker-java-catalog-service
Зверніть увагу на приємну річ: Dockerfile не змінюється. Ми не пересбираємо образ заради «інший порт» або «менше памʼяті». Ми просто запускаємо один і той самий артефакт у різних режимах, і саме для цього взагалі й замислювалася нормальна модель ENTRYPOINT + зовнішня конфігурація.
Щоб не тримати все в голові, можна користуватися короткою пам’яткою:
| Що ви хочете змінити | Найпростіший канал сьогодні | Приклад |
|---|---|---|
| Порт застосунку | змінна середовища або аргумент застосунку | -e SERVER_PORT=9090 або --server.port=9090 |
| Активний профіль | змінна середовища або аргумент застосунку | -e SPRING_PROFILES_ACTIVE=standalone або --spring.profiles.active=standalone |
| JVM-параметри | JAVA_TOOL_OPTIONS | -e JAVA_TOOL_OPTIONS="-Xmx256m" |
Ця таблиця потрібна не для зубріння, а щоб швидко відрізняти канал Docker від каналу JVM і каналу Spring Boot.
4. Типові помилки під час роботи з Dockerfile
Помилка № 1: Dockerfile не бачить jar, хоча він точно є.
Найчастіше причина банальна: jar не потрапив у контекст збирання. Іноді студенти занадто агресивно налаштували .dockerignore і виключили build/ цілком, а потім дивуються, що COPY build/libs/*.jar не працює. Лікується це не містикою, а перевіркою: де лежить jar, що реально надсилається в контекст збирання і які правила ігнорування спрацювали.
Помилка № 2: EXPOSE 8080 написали, але сервіс не відкривається.
EXPOSE не публікує порт назовні. Він лише документує очікуваний порт усередині контейнера. Зовнішній доступ з’являється тільки через docker run -p hostPort:containerPort. Якщо переплутати ці ролі, можна безкінечно змінювати Dockerfile, хоча проблема в команді запуску.
Помилка № 3: перехід на shell-form заради «красивого рядка».
Іноді хочеться написати ENTRYPOINT java -jar app.jar, бо це виглядає «як у терміналі». Але це shell-form, і він тягне за собою оболонку як проміжний процес. У результаті ви погіршуєте передавання сигналів Java-процесу і отримуєте менш стабільну поведінку під час зупинки контейнера. Для Java-сервісу базовий варіант має бути нудним і правильним: ENTRYPOINT ["java", "-jar", "app.jar"].
Помилка № 4: плутаються ARG і ENV, і все починає здаватися непередбачуваним.
ARG живе лише під час збирання. Якщо ви спробуєте через ARG «задати порт» або «увімкнути профіль», отримаєте сюрприз: контейнеру це не дістанеться. Для runtime-параметрів потрібен ENV як значення за замовчуванням образу або параметри docker run як перевизначення. Щойно ви розділяєте build-time і runtime в голові, половина плутанини зникає.
Помилка № 5: неузгодженість порту в трьох місцях (і потім боляче).
Студент змінює -p 9090:8080, потім ставить SERVER_PORT=9090, потім додає --server.port=7070, а зверху ще хоче покласти той самий порт у CMD. Тут допомагає просте правило: в одному запуску намагайтеся задавати параметр одним каналом і тримати відповідність між портом застосунку та зіставленням портів контейнера. Інакше ви діагностуватимете не Docker, а власну комбінацію параметрів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ