JavaRush /Курси /Docker for Spring /Повторно використовуваний шаблон: Dockerfile і Compose

Повторно використовуваний шаблон: Dockerfile і Compose

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

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 не розповзався, фіксуємо одну базову схему: builderruntime, одна перевірена лінійка образів 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 — добрий керований шлях, але тут це сусідня доросла альтернатива, а не новий стандарт шаблону. Ручний базовий варіант як і раніше лишається builderruntime; 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.yamlapp. Тут ми перевіряємо, що правило 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
docker run ...
+
docker exec ... id
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 викликаються за іменем.

1
Опитування
Безпека Docker, рівень 24, лекція 4
Недоступний
Безпека Docker
Права, секрети та монтування
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ