1. Шаблон — узгоджений набір правил
Коли говорять про «фінальний шаблон», новачки часто уявляють магічний Dockerfile на 200 рядків, який «переміг контейнеризацію назавжди». У реальності добрий шаблон — це скромніше й корисніше: набір рішень, які не суперечать одне одному. Якщо ви одночасно хочете non-root, але пишете в каталог без прав — у вас не шаблон, а квест. Якщо хочете same image, different config, але кожен профіль збираєте в окремий образ — це теж не шаблон, а колекція альтернативних всесвітів.
Щоб не потонути в деталях, зручно тримати в голові просту схему: ми пакуємо застосунок в образ, потім запускаємо його в різних режимах, а Compose — це «скріпка», яка збирає локальне середовище навколо сервісу.
flowchart TD
A[Проєкт Gradle] --> B[Dockerfile: етап builder]
B --> C[Dockerfile: етап runtime]
C --> D[(Docker-образ)]
D --> E[Окремий контейнер]
D --> F[Compose: app + postgres + redis + rabbitmq]
F --> G[Конфігурація runtime: змінні середовища / профілі / монтування]
У цьому й полягає ідея: шаблон має бути коротким, однозначним і повторюваним. Він не зобов’язаний бути найменшим в інтернеті. Він зобов’язаний бути таким, щоб через місяць ви самі могли його прочитати й не спитати у дзеркала: «Хто це написав і чому він мене ненавидить?».
2. Набір файлів шаблону в корені репозиторію
Фінальний шаблон — це не лише Dockerfile. Якщо ви залишите один Dockerfile, але не зафіксуєте .dockerignore, Compose і команди запуску, на практиці все знову скотиться до «ну в мене ж працює». Тому ми подумки збираємо «скелет репозиторію»: кілька кореневих файлів, які формують контракт запуску. Їх мало — і це спеціально, тому що шаблон, що розрісся до десяти Dockerfile і семи compose-файлів, перестає бути навчальним і переносним.
Ось як зазвичай виглядає «здоровий мінімум» (показую як орієнтир, а не як догму):
docker-java-catalog-service/
├── Dockerfile
├── .dockerignore
├── compose.yaml
├── compose.dev.yaml
├── .env.example
├── README.md
├── scripts/
│ └── ...
└── src/
└── ...
Сенс цього набору простий: Dockerfile відповідає за як упакувати, compose.yaml — за як підняти середовище, compose.dev.yaml — за як увімкнути dev/debug без зайвих змін у базі, а .dockerignore і .env.example захищають вас від випадковостей і секретів. Так, це нудно. Саме тому це працює.
.env.example тут не декоративний: він перелічує хоча б SPRING_DATASOURCE_PASSWORD і SPRING_RABBITMQ_PASSWORD. Якщо секрети зручніше подавати через файл, у репозиторії залишають лише example-файл, а робоче значення живе зовні й не потрапляє ні в git, ні в build context.
3. Фінальний Dockerfile: “мало рядків, багато сенсу”
Коли ви доводите Dockerfile до стану шаблону, його хочеться читати як коротку історію. Спочатку ми збираємо артефакт (builder stage), потім запускаємо його в чистому runtime-середовищі (runtime stage), причому запускаємо як non-root і з підготовленими каталогами для запису. І дуже важливо: Dockerfile не має знати про ваші паролі, «локальні шляхи Васі» і «в мене порт 8081, бо 8080 зайнятий».
Щоб Dockerfile не розповзався, фіксуємо одну базову схему: builder → runtime, одна перевірена лінійка образів eclipse-temurin:25-jdk-jammy / eclipse-temurin:25-jre-jammy, один jar /app/application.jar, а ручне збирання базового образу вважаємо лише у вигляді docker build --target runtime -t catalog-service:local .. Якщо в тому ж Dockerfile живуть development або layered targets, вони лишаються опціональними й збираються тільки явним --target.
Builder stage: Gradle тільки для збирання
Builder stage — це місце, де доречні вихідники та інструмент збирання. Фінальний образ не має знати, що таке gradlew, інакше він починає виглядати як «контейнер із вашою домашкою», а не runtime для сервісу.
Невеликий фрагмент етапу builder (Java 25):
FROM eclipse-temurin:25-jdk-jammy AS builder
WORKDIR /build
# Копіюємо wrapper і скрипти збирання окремо — це допомагає Docker-кешу
COPY gradlew .
COPY gradle gradle
COPY build.gradle.kts settings.gradle.kts ./
# Вихідники — шар, що змінюється найчастіше, тому копіюємо їх пізніше
COPY src src
# Збираємо jar без Gradle daemon всередині контейнера
RUN ./gradlew bootJar --no-daemon
Зверніть увагу на порядок. Ми спочатку копіюємо «стабільні» речі (wrapper, gradle/, build scripts), і лише потім src/. Так Docker-кеш допомагає, а не сміється з вас. Так, це той самий прийом із кешем, лише тепер це вже не теорія, а частина фінального шаблону.
Runtime stage: non-root, writable path і healthcheck в одному порядку
Runtime stage — це місце, де має бути максимально мало зайвого. Тут ми ставимо мінімальний інструмент для healthcheck, створюємо користувача, готуємо каталоги, копіюємо jar і вмикаємо non-root. Важливо не переплутати: усе, що потребує прав root, іде до USER appuser.
FROM eclipse-temurin:25-jre-jammy AS runtime
ARG UID=10001
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
RUN adduser --disabled-password --gecos "" \
--home "/nonexistent" --shell "/sbin/nologin" \
--no-create-home --uid "${UID}" appuser \
&& mkdir -p /app/data/exports \
&& chown -R appuser:appuser /app
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /build/build/libs/*.jar /app/application.jar
USER appuser
ENTRYPOINT ["java", "-jar", "/app/application.jar"]
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s \
CMD curl -fsS http://localhost:8080/actuator/health | grep '"status":"UP"' || exit 1
Порядок тут важливий: спочатку ставимо мінімальний інструмент для healthcheck, потім створюємо користувача і каталог для запису, потім копіюємо jar, і лише після цього переходимо на non-root. Якщо змінити порядок, Dockerfile швидко почне падати через права, а шаблон знову стане залежати від випадковості.
Так, curl трохи збільшує runtime stage, але тут це усвідомлений компроміс: локальний шаблон отримує чесну перевірку на рівні Docker. Якщо сервіс слухає інший порт, healthcheck має змінюватися разом із ним, а не жити власним життям.
Layered target через jarmode=tools
Layered path тут лишається опціональною оптимізацією. Він корисний, коли ви свідомо хочете пришвидшити rebuild/pull, але не підміняє базову схему runtime.
Ключовий момент — виділення шарів через jarmode=tools:
FROM runtime AS extractor
RUN java -Djarmode=tools -jar /app/application.jar extract \
--layers --destination /app/extracted
Сам runtime-layered уже збирається з extracted-шарів і запускається як окремий target. Збирати його треба явно (docker build --target runtime-layered ...), інакше Dockerfile знову почне виглядати як набір рівноправних варіантів замість одного зрозумілого базового варіанта.
bootBuildImage: альтернативний шлях збирання
Buildpacks і bootBuildImage — добрий керований шлях, але тут це сусідня доросла альтернатива, а не новий стандарт шаблону. Ручний базовий варіант як і раніше лишається builder → runtime; buildpacks просто дають другий спосіб отримати образ без ручного опису кожного кроку пакування.
Мінімальний «сигнал», який зазвичай живе в README шаблону:
./gradlew bootBuildImage
docker image ls | grep catalog
У доброму шаблоні обидва шляхи співіснують: Dockerfile — як максимально прозорий «інженерний baseline», buildpacks — як швидкий «керований baseline». Головне — не намагатися змішати їх у кашу, де частина команди збирає одне, частина — інше, а потім ви пів дня зʼясовуєте, чому в Петі так, а в Маші інакше.
4. compose.yaml: базове локальне середовище
Compose-файл у фінальному шаблоні — це «паспорт локального середовища». Він має бути достатньо повним, щоб підняти робочий стенд, і достатньо зрозумілим, щоб людина без DevOps-бекґраунду могла його прочитати. В ідеалі compose.yaml піднімає ваш реальний стек (app + postgres + redis + rabbitmq), тому що саме там спливають справжні проблеми: readiness, імена сервісів, томи, порти, конфігурація.
Усі чутливі значення нижче приходять у runtime: з локального .env, переліченого в .env.example, або через зовнішній файл конфігурації із секретами. У compose.yaml залишаються лише посилання на них.
PostgreSQL: volume + healthcheck = менше хаосу
Фрагмент, який майже завжди виглядає подібно:
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: catalog
POSTGRES_USER: postgres
# База і застосунок отримують одне й те саме значення з локального .env
POSTGRES_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:?вкажіть це в .env}
volumes:
- catalog_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d catalog"]
interval: 5s
timeout: 3s
retries: 10
Тут важливо не «що саме написати», а що ви цим виражаєте. Volume фіксує стан БД між перезапусками, healthcheck дає чесний сигнал готовності, а ім’я сервісу postgres стає тим самим «хостом», за яким застосунок під’єднується до БД всередині Compose.
Redis і RabbitMQ: такі самі залежності, просто інші симптоми
Redis зазвичай простіший, особливо в сценарії кешування. Мінімальний фрагмент:
services:
redis:
image: redis:8.6.0
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
RabbitMQ у курсі — це залежність, а не окремий світ. Тому базовий варіант — зрозумілі порти плюс healthcheck:
services:
rabbitmq:
image: rabbitmq:4.2-management
environment:
RABBITMQ_DEFAULT_USER: catalog
RABBITMQ_DEFAULT_PASS: ${SPRING_RABBITMQ_PASSWORD:?вкажіть це в .env}
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 5s
timeout: 5s
retries: 20
App service: один image, різні режими через env vars
Найважливіше місце в compose.yaml — app. Тут ми перевіряємо, що правило same image, different runtime config справді працює: ми не перебудовуємо образ під postgres, cache, messaging, ми просто задаємо середовище.
Короткий фрагмент, де видно головне: профілі, імена сервісів і каталог експорту.
services:
app:
build:
context: .
target: runtime # Явно збираємо канонічний runtime target
environment:
# Один і той самий image, різні режими — лише через env vars
SPRING_PROFILES_ACTIVE: postgres,cache,messaging
# Усередині Compose замість localhost використовуємо імʼя сервісу
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/catalog
SPRING_DATASOURCE_USERNAME: postgres
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:?вкажіть це в .env}
SPRING_DATA_REDIS_HOST: redis
SPRING_RABBITMQ_HOST: rabbitmq
SPRING_RABBITMQ_USERNAME: catalog
SPRING_RABBITMQ_PASSWORD: ${SPRING_RABBITMQ_PASSWORD:?вкажіть це в .env}
# Каталог, у який контейнер може писати (далі змонтуємо його на хості)
APP_EXPORT_DIR: /app/data/exports
І фрагмент, який пов’язує каталог для запису всередині контейнера з каталогом на хості:
services:
app:
volumes:
- ./data/exports:/app/data/exports
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
У цьому фрагменті багато «дорослих» правил курсу одразу. Ми використовуємо service names замість localhost. Ми не вгадуємо готовність через sleep. Ми не пишемо всередину контейнера «куди завгодно», а виводимо експорт назовні. І ми не ховаємо важливе в «магії» — усе видно в YAML.
Якщо вам ближчий шлях із зовнішнім файлом секретів, змінюється лише спосіб подачі SPRING_DATASOURCE_PASSWORD і SPRING_RABBITMQ_PASSWORD; сам compose.yaml усе одно не починає зберігати жорстко закодовані облікові дані.
5. compose.dev.yaml: dev/debug як override
Найчастіша помилка «майже фінального» шаблону — коли debug-порт, dev-таргет і експериментальні env vars живуть у базовому compose.yaml. У результаті ви запускаєте «звичайний стенд», а він раптом слухає debug на 5005, пише логи на TRACE і поводиться як лабораторний щур. Щоб цього не було, compose.dev.yaml має бути вузьким: лише overrides, лише для розробки.
Якщо вам потрібен окремий development target для локальної розробки, він існує лише як override-шар. Базовий compose.yaml і звичайне manual build усе одно живуть на runtime.
Наприклад, перемикаємо build.target на development і відкриваємо debug-порт:
services:
app:
build:
target: development
ports:
- "5005:5005"
А якщо потрібно увімкнути remote debug через змінну середовища, робимо це тут же, а не в базовому файлі:
services:
app:
environment:
JAVA_TOOL_OPTIONS: "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
Сенс — у дисципліні. compose.yaml має бути «як у проді, але локально», а compose.dev.yaml — «як зручно розробнику, але під контролем». Тоді ви не навчаєте команду двом різним способам жити. Ви навчаєте одному й даєте акуратний dev-режим поверх нього.
6. Мінічеклист живості шаблону
Фінальний шаблон цінний не тим, що файли існують, а тим, що за ними можна швидко перевірити життєздатність стенду. Тут зручно мислити не списком «що зробити», а списком сигналів, які ви хочете побачити. Нижче — приклад того, як такі сигнали зазвичай фіксуються в README (і як ви самі перевіряєте проєкт без гадання).
| Що ви перевіряєте | Команда/дія | Що вважається «нормою» |
|---|---|---|
| Dockerfile хоча б валідний | docker buildx build --check --target runtime . | Немає помилок синтаксису й очевидних проблем зі збиранням |
| Image збирається | docker build --target runtime -t catalog-service:local . | Збирання проходить без «випадкових» залежностей від хоста |
| Процес не root | + |
UID не 0, ви бачите appuser (або свого користувача) |
| Сервіс відповідає на HTTP | запит до /actuator/health | {"status":"UP"} і адекватні логи запуску |
| Compose піднімає стек | docker compose up --build | PostgreSQL/Redis/RabbitMQ стають healthy, app стартує після них |
| Експорт записує туди, куди треба | виклик /api/catalog/exports і перевірка каталогу на хості | Файл з’являється в ./data/exports і не зникає після restart |
| Debug не «просочився» в базу | запуск без dev-файлу | Немає порту 5005, немає debug-агента, поведінка production-like |
І ось тут зʼявляється головна перевага шаблону: коли у вас є ці сигнали, ви вмієте відрізнити «зламався код» від «зламалося середовище». А це, якщо чесно, половина дорослої розробки.
7. Типові помилки під час збирання фінального шаблону
Помилка №1: “Фінальний шаблон”, який досі запускається від root.
Іноді здається: ну ми ж уже все зробили, просто залишимо root, щоб не возитися з правами. Проблема в тому, що це не дрібниця. Root приховує реальні помилки файлової моделі та створює хибне відчуття безпеки. В іншому середовищі, з іншими mountʼами, усе це вилізе, і ви знову почнете «лагодити Docker магією».
Помилка №2: non-root увімкнули, а каталог для запису забули підготувати.
Це класика жанру: USER appuser є, а mkdir/chown немає. У результаті export падає з AccessDeniedException, і людина починає підозрювати Spring, JPA, ретроградний Меркурій — кого завгодно, крім прав на каталог. Якщо процес не може писати в /app/data/exports, він не «майже працює». Він не працює.
Помилка №3: APP_EXPORT_DIR вказує на одне, а mount — на інше.
Це дуже «людська» помилка: в YAML написали /app/exports, у конфігу /app/data/exports, а потім дивуємося, що файли «зникли». У контейнерах шлях — це контракт. Якщо контракт порушено, Docker не зобов’язаний вгадувати, що ви мали на увазі.
Помилка №4: Усередині Compose усе ще використовується localhost.
Ззовні це виглядає невинно: «Ну база ж на моїй машині». А всередині Compose localhost — це контейнер застосунку, а не контейнер бази. Це призводить до нескінченних «Connection refused» і спроб «підправити порт», хоча порт тут ні до чого. Правильний хост — це ім’я сервісу, тобто postgres, redis, rabbitmq.
Помилка №5: Секрети «на хвилинку» потрапили в Dockerfile/репозиторій.
Зазвичай це починається з фрази «та я просто перевірю». Потім це комітиться, пушиться, потрапляє в history образу, і вже жоден git revert не робить ситуацію нормальною. У шаблоні секрети мають жити зовні: env vars, зовнішні файли, *_FILE-підходи (якщо ви їх використовуєте). Dockerfile має залишатися чистим, як сумління розробника в ідеальному світі (тобто хоча б прагнути до цього).
Помилка №6: в одному Dockerfile залишили кілька targets і почали збирати «що вийде за замовчуванням».
Якщо поруч живуть runtime, development і runtime-layered, manual build без --target швидко перетворюється на лотерею. У повторно використовуваному шаблоні це лікується простим правилом: звичайне збирання завжди бере runtime, а інші targets викликаються за іменем.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ