1. Один образ — багато запусків
Образ у нас уже є. Далі пазл складається так: режим не повинен жити в Dockerfile, а підсумкове значення властивості визначається за пріоритетом джерел. Значення приходять через env vars, -D і --..., а імена змінних середовища треба перекладати без вгадувань. Тепер усе це треба зібрати в один робочий сценарій: взяти один image і запустити його в двох runtime-сценаріях, не змінюючи сам артефакт.
У наших командах перемикачем режиму буде spring.profiles.active. Тут profiles слугують уже наявним проєктним перемикачем standalone/postgres. Нам не потрібно зараз розкручувати всю профільну механіку; достатньо побачити важливішу річ: навіть такий Spring-механізм залишається частиною runtime-конфігурації, а не приводом збирати новий image.
Уявімо, що в нас є образ навчального сервісу:
docker-java-catalog-service:local (або будь-який ваш тег — суть не в назві).
І в сервісу є два режими:
- standalone — сервіс працює «сам по собі», без PostgreSQL, використовуючи in-memory сховище.
- postgres — сервіс працює з PostgreSQL (через JPA/Flyway), тобто очікує доступний datasource.
Тримайте в голові таку схему:
flowchart TD
Image["Docker-образ: docker-java-catalog-service:local<br/>(незмінний)"]
Image -->|запуск №1<br/>SPRING_PROFILES_ACTIVE=standalone| C1["Контейнер: catalog-standalone"]
Image -->|запуск №2<br/>SPRING_PROFILES_ACTIVE=postgres<br/>+ параметри datasource| C2["Контейнер: catalog-postgres"]
C1 --> A1["API відповідає в standalone-режимі"]
C2 --> A2["API відповідає в postgres-режимі (якщо БД доступна)"]
2. Міні-віконце правди: runtime endpoint
Якщо в попередніх експериментах ви вже тимчасово виводили окремі властивості, тепер час зібрати все в один стабільний ендпоїнт /api/runtime. Він і стане головною перевіркою ефективної конфігурації для команд нижче.
Коли запускаєте контейнер, дуже легко повірити самій команді docker run: «я ж написав SPRING_PROFILES_ACTIVE=postgres, отже так і є». На практиці все чесніше й жорсткіше: значення могли бути перекриті, профіль міг не активуватися, а порт міг прийти з іншого джерела. Тому замість розсипу одноразових ендпоїнтів зберемо один короткий runtime-probe, який показує effective config цілком.
package com.example.catalog.ops.web;
import java.util.Arrays;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class RuntimeController {
private final Environment env;
RuntimeController(Environment env) {
this.env = env;
}
@GetMapping("/api/runtime")
String runtime() {
String[] activeProfiles = env.getActiveProfiles();
String profiles = activeProfiles.length == 0
? "<none>"
: String.join(",", activeProfiles);
String effectiveMode = Arrays.asList(activeProfiles).contains("postgres")
? "postgres"
: "standalone";
String port = env.getProperty("server.port", "8080");
return "mode=" + effectiveMode + ", profiles=" + profiles + ", port=" + port;
}
}
Тут є дві різні речі, і їх важливо не змішувати. profiles показує, що Spring реально активував. mode — це вже інтерпретація на рівні проєкту: якщо увімкнено postgres, вважаємо режим postgres, інакше залишаємося в standalone. Тому mode=standalone у відповіді не означає, що Spring сам магічно активував профіль standalone за замовчуванням. Це лише короткий спосіб сказати: ми стартували без postgres-режиму.
Цього одного ендпоїнта достатньо для всіх команд нижче. Відповіді читатимемо приблизно так: mode=postgres, profiles=postgres, port=8081.
3. Профіль і порт: дві осі
Дуже людська помилка новачка — думати так: «standalone означає порт 8080, postgres означає 8081». Поки ви один і поки у вас один ноутбук, це навіть може «працювати». Але щойно ви запускаєте два контейнери поруч, додаєте тести або просто хочете швидко змінити порт, цей зв’язок починає заважати. Порт і профіль — різні властивості, і їх вигідно тримати незалежними.
У наших командах запуску профіль — це перемикач проєкту, а порт — окрема вісь запуску.
Давайте зафіксуємо це у вигляді маленької таблиці. Вона проста, але дисциплінує мозок краще, ніж тисяча розумних слів.
| Що змінюємо | Приклад властивості | Навіщо взагалі це змінювати | Чи можна змінювати без повторного збирання image |
|---|---|---|---|
| Режим проєкту (standalone/postgres) | spring.profiles.active | Увімкнути або вимкнути інфраструктурний режим проєкту | Так |
| Порт сервісу | server.port | Запустити поруч кілька екземплярів і прибрати конфлікт портів | Так |
| Адреса БД | spring.datasource.url | Підключитися до іншої БД або хоста | Так |
| Логін/пароль до БД | spring.datasource .username/password | Підключитися до конкретної БД | Так |
І ось тут — ключова думка лекції. Якщо змінюється лише одна клітинка в цій таблиці, повторне збирання образу марне. Образ — це код. Конфігурація — це умови життя коду.
Ще одна корисна перевірка здорового глузду звучить так: якщо ви можете описати зміну словами «мені потрібно запустити той самий сервіс, але…» — на іншому порті, з іншим профілем, з іншою адресою БД, — це майже завжди runtime-конфігурація. Якщо ж ви кажете: «мені потрібно, щоб сервіс працював інакше, бо я переписав код або залежності», — це вже про збирання.
4. Запуск image у standalone-режимі
Зараз буде момент, де Docker нарешті перестає бути філософією і стає чимось, що можна перевірити curl’ом. standalone-режим цінний тим, що дає змогу запускати сервіс без зовнішніх залежностей. Це «режим без виправдань»: якщо він не стартує, проблема точно не в PostgreSQL, не в мережі й не в service names. Скоріше за все, проблема в конфігурації запуску або в тому, що ви запускаєте не те.
Тут SPRING_PROFILES_ACTIVE — просто ще один runtime-ключ. Він приходить тими ж env vars, що й SERVER_PORT, і не потребує окремого image.
Варіант через env vars
Припустімо, ми хочемо:
- щоб контейнер слухав на 8080;
- публікувати цей порт назовні;
- увімкнути профіль standalone.
Команда може виглядати так:
# --rm: видалити контейнер після зупинки
# --name: щоб не плутатися, що саме зараз запущено
# -p: переадресація порту (host:container)
# -e: задаємо runtime-конфігурацію (профіль/порт) без повторного збирання image
docker run --rm \
--name catalog-standalone \
-p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=standalone \
-e SERVER_PORT=8080 \
docker-java-catalog-service:local
Тепер перевіряємо наше віконце правди:
# Перевіряємо ефективні значення профілю та порту, які справді піднялися після старту
curl http://localhost:8080/api/runtime
# mode=standalone, profiles=standalone, port=8080
Якщо ви побачили mode=standalone, profiles=standalone, port=8080, значить із профілем і портом усе чесно. Ми не збирали image повторно. Ми не чіпали Dockerfile. Ми просто передали інші значення під час запуску контейнера.
Варіант через application arguments
Оскільки Dockerfile у нас, за каноном курсу, запускає застосунок через ENTRYPOINT в exec-form, ви можете дописати аргументи після імені image — і вони потраплять як application args (--key=value).
Наприклад, так:
# Аргументи після імені image потраплять у Spring Boot як application args (--key=value)
docker run --rm \
--name catalog-standalone \
-p 8080:8080 \
docker-java-catalog-service:local \
--spring.profiles.active=standalone \
--server.port=8080
І знову перевіряємо:
curl http://localhost:8080/api/runtime
# mode=standalone, profiles=standalone, port=8080
Вибір між env vars і application args — це не релігія. Це питання зручності та домовленості в команді. У світі Docker/Compose env vars часто зручніші як базовий варіант, особливо коли параметрів багато. Але для разового запуску аргументи теж чудово працюють.
5. Запуск image у postgres-режимі
postgres-режим за змістом повинен підключитися до PostgreSQL. Де саме живе ця PostgreSQL — зараз неважливо. Вона може бути на вашій машині, на іншому сервері або в сусідньому контейнері. Наше завдання скромніше: показати, що для перемикання режиму не потрібен новий image.
Щоб postgres-режим був можливий, нам потрібно передати щонайменше:
— активний профіль postgres,
— порт, щоб не конфліктувати зі standalone-контейнером, якщо ми піднімаємо обидва,
— налаштування datasource: URL, username, password.
Покажемо команду запуску через env vars — це більш типовий для контейнерів спосіб:
# Тут задаємо профіль і datasource через env vars — це саме runtime-конфігурація.
# Важливо: <DB_HOST> має бути доступний З КОНТЕЙНЕРА, а не «як вам звично з хоста».
docker run --rm \
--name catalog-postgres \
-p 8081:8081 \
-e SPRING_PROFILES_ACTIVE=postgres \
-e SERVER_PORT=8081 \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://<DB_HOST>:5432/catalog \
-e SPRING_DATASOURCE_USERNAME=catalog \
-e SPRING_DATASOURCE_PASSWORD=catalog \
docker-java-catalog-service:local
Зверніть увагу: <DB_HOST> — це не «localhost» автоматично, а конкретний хост, доступний із контейнера. Сьогодні ми свідомо не йдемо в мережеву модель multi-container світу, тому пишемо це як параметр, що приходить ззовні.
Якщо база доступна і параметри коректні, перевірка буде такою ж простою:
curl http://localhost:8081/api/runtime
# mode=postgres, profiles=postgres, port=8081
Якщо база не доступна, застосунок, найімовірніше, впаде на старті, і це нормально для postgres-режиму. Важливо, що ви все одно бачите принцип: ви не робили окремий образ «під Postgres». Ви запускали той самий image з іншим набором вхідних значень.
Тут є хороший ментальний тест: уявіть, що ви опублікували image в registry, а його використовує колега. У нього може бути інша БД, інший пароль, інший порт. Якщо йому доведеться повторно збирати ваш образ заради пароля, це не «він поганий», а «образ спроєктовано неправильно». Пароль і URL мають приходити під час запуску, а не жити в Dockerfile.
6. Два контейнери з одного image
Найнаочніша демонстрація «same image, different runtime config» — це запуск двох контейнерів одночасно з одного й того самого образу. Це момент, коли мозок перестає сумніватися і починає вірити: один артефакт справді може жити в різних режимах. У звичайній розробці це часто використовують для тестів, міграцій, експериментів і просто для акуратного розділення того, «як сервіс живе тут і зараз».
Уявімо дві команди поруч:
# Перший контейнер: standalone (свій профіль і свій порт)
docker run --rm --name catalog-standalone -p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=standalone -e SERVER_PORT=8080 \
docker-java-catalog-service:local
# Другий контейнер: postgres (той самий image, але інший профіль/порт і налаштування datasource)
docker run --rm --name catalog-postgres -p 8081:8081 \
-e SPRING_PROFILES_ACTIVE=postgres -e SERVER_PORT=8081 \
-e SPRING_DATASOURCE_URL=jdbc:postgresql://<DB_HOST>:5432/catalog \
-e SPRING_DATASOURCE_USERNAME=catalog \
-e SPRING_DATASOURCE_PASSWORD=catalog \
docker-java-catalog-service:local
Зверніть увагу: ім’я image однакове (docker-java-catalog-service:local). Відрізняються лише параметри запуску контейнера. Різні імена контейнерів (catalog-standalone, catalog-postgres) допомагають не заплутатися, а різні host-порти (8080 і 8081) прибирають конфлікт.
Перевірка також симетрична:
curl http://localhost:8080/api/runtime
# mode=standalone, profiles=standalone, port=8080
curl http://localhost:8081/api/runtime
# mode=postgres, profiles=postgres, port=8081
Якщо ви хоч раз ловили себе на думці «мені треба повторно зібрати образ, бо я змінюю порт, профіль або хост БД», то ця вправа досить швидко вибиває цю звичку. Повторне збирання потрібне, коли змінився код. Усе інше — це вхідні значення запуску.
7. Типові помилки під час запуску одного image в різних режимах
Помилка № 1: робити окремі images під режими або кодувати режим в імені image.
catalog-service-postgres, catalog-service-standalone, catalog-postgres:1.0 — усе це спочатку виглядає «зручно», а потім швидко перетворюється на зоопарк тегів і Dockerfile. Ви вже не впевнені, що різні режими запускають один і той самий код, а будь-яка дрібна правка починає вимагати два збирання замість одного. Режим повинен жити в параметрах запуску контейнера, а ім’я image має говорити лише про те, що це за сервіс.
Помилка № 2: запікати профіль у Dockerfile через ENV і потім лікувати це повторним збиранням.
ENV SPRING_PROFILES_ACTIVE=postgres усередині Dockerfile виглядає безневинно, поки вам не знадобиться standalone. Після цього ви або повторно збираєте образ, або плодите ще один Dockerfile, або починаєте перевизначати все одразу й втрачаєте прозорість. Якщо змінюється лише режим, значить проблема не в збиранні. Отже, потрібно змінити runtime-конфігурацію.
Помилка № 3: змішувати канали для одного й того самого ключа або плутати -D… і --…
У контейнерному запуску це трапляється особливо легко: навколо вже є Docker CLI, ENTRYPOINT, JVM і Spring Boot. -Dserver.port=9090 — це system property JVM, а --server.port=9090 — application argument Spring Boot. Якщо до цього ще додати SERVER_PORT=8080, отримати «а чому перемогло не те?» дуже просто. На один ключ краще вибирати один головний канал, доки у вас немає усвідомленої причини робити складніше.
Помилка № 4: думати, що localhost у SPRING_DATASOURCE_URL означає вашу хост-машину.
Всередині контейнера localhost указує на сам контейнер. Тому jdbc:postgresql://localhost:5432/... означає «Postgres має бути всередині цього ж контейнера», а не «десь поруч на вашому ноутбуці». Іноді це випадково працює, але як базовий підхід це майже завжди помилка мислення.
Помилка № 5: пов’язувати профіль і порт як єдиний «режим».
Щойно ви вирішуєте «postgres завжди на 8081», ви самі собі закладаєте міну. Порт — це узгодження на межі мережі, профіль — це увімкнення або вимкнення інфраструктурної конфігурації. Вони не зобов’язані збігатися ні сьогодні, ні завтра. Життя стає помітно простішим, коли ви тримаєте їх незалежними і передаєте окремо.
Помилка № 6: втрачати межу image vs container і дебажити «не той шар».
Образ — це рецепт і артефакт. Контейнер — конкретний запуск із конкретними параметрами. Якщо два контейнери з одного image поводяться по-різному, насамперед потрібно дивитися не в Dockerfile, а в effective config поточного запуску. Для цього і потрібна коротка ручка на кшталт /api/runtime: вона швидко відповідає, який container зараз живе і з якими параметрами.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ