Реальное приложение редко запускается само по себе. Ему нужна база данных, брокер сообщений, кэш. Docker Compose позволяет описать все эти сервисы в одном файле и запустить их вместе — одной командой.
Самое дорогое место в этом файле — не список сервисов, а условие запуска: по умолчанию Compose считает зависимость выполненной, как только контейнер запущен, а не когда база готова отвечать.
Запущенный контейнер и готовая база — разные события: короткая форма depends_on пускает app на 1-й секунде, и он падает на Connection refused. condition: service_healthy ждёт пробу pg_isready — база отвечает с 9-й секунды, а приложение стартует на 11-й: это цена интервала в 5 секунд.
Проблема: запускать всё вручную утомительно
Представьте типичное окружение для разработки: Spring Boot приложение, Postgres, Kafka, Zookeeper. Без Compose каждый участник команды вручную запускает каждый контейнер, передаёт правильные переменные окружения, пробрасывает порты, следит за порядком запуска. При смене машины или переключении между проектами всё повторяется заново.
Docker Compose решает именно эту проблему: конфигурация окружения лежит в файле в репозитории и воспроизводима на любой машине.
Короткая формула: один файл docker-compose.yml — одна команда docker compose up — готовое окружение.
Что такое Docker Compose
Docker Compose — инструмент для описания и запуска многоконтейнерных приложений. Вы описываете все нужные сервисы, их настройки, связи между ними и тома в одном YAML-файле. Compose читает этот файл и управляет жизненным циклом всех контейнеров: запуск, остановка, пересборка.
Раньше Compose был отдельной программой и звался docker-compose, через дефис. Эту версию (её называют V1) перестали поддерживать в июле 2023 года, и на новых машинах её просто нет. Сейчас Compose встроен в сам Docker как подкоманда, поэтому в статьях и в вашем терминале правильное написание — docker compose, через пробел. Если где-то встретите вариант с дефисом, знайте: текст старый.
Структура docker-compose.yml
Рассмотрим минимальный пример: Spring Boot приложение + Postgres.
services:
db:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: secret
ports:
- "127.0.0.1:5432:5432" # виден только с этой машины
volumes:
- postgres_data:/var/lib/postgresql/data # том для хранения данных между перезапусками
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
interval: 5s
timeout: 5s
retries: 10
app:
image: myapp:1.0 # или build: . — если собирать из Dockerfile
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/myapp
SPRING_DATASOURCE_USERNAME: myapp
SPRING_DATASOURCE_PASSWORD: secret
ports:
- "8080:8080"
depends_on:
db:
condition: service_healthy # ждём, пока Postgres не пройдёт healthcheck
volumes:
postgres_data:
Разберём ключевые блоки.
services
services — главный раздел файла. Каждый ключ внутри — имя сервиса (db, app). Это имя одновременно является именем хоста внутри сети Compose: из контейнера app можно обратиться к базе данных просто по имени db. Никаких IP-адресов прописывать не нужно — Compose создаёт внутреннюю DNS-запись автоматически.
image и build
image указывает, какой образ использовать. Если образ ещё нужно собрать из Dockerfile, используйте build: . вместо image (или вместе с ним для задания имени тега). Для приложения при разработке часто удобнее собирать образ налету:
app:
build:
context: .
dockerfile: Dockerfile
environment
environment задаёт переменные окружения внутри контейнера. Spring Boot читает переменные окружения и использует их как свойства конфигурации: SPRING_DATASOURCE_URL соответствует spring.datasource.url в application.properties.
Длинный список переменных удобно выносить в файл .env и ссылаться на него через env_file: .env.
ports
ports пробрасывает порт из контейнера на хост. Формат "хост:контейнер". Если вам не нужен доступ к Postgres с хоста (только из приложения внутри сети Compose), блок ports для базы можно убрать совсем — контейнеры всё равно видят друг друга по имени сервиса.
Если доступ всё-таки нужен — клиентом заглянуть в таблицы, — не пишите просто "5432:5432". Такая запись открывает порт на всех сетевых интерфейсах машины, и на сервере с внешним адресом ваша база окажется видна из сети. В примере выше поэтому стоит "127.0.0.1:5432:5432" — порт виден только с самой машины. Заодно так меньше шансов столкнуться с локально установленным Postgres, который уже занял 5432.
volumes
volumes в секции сервиса монтирует том или директорию хоста внутрь контейнера. В примере выше именованный том postgres_data хранит данные Postgres между перезапусками. Именованные тома объявляются в разделе volumes верхнего уровня.
Для разработки удобно монтировать в контейнер то, что часто меняется. Только с Java это работает не так, как в мире Node или Python: там достаточно подсунуть исходники, потому что их читает сам интерпретатор. Java-приложение внутри контейнера запускается из jar, и примонтированный ./src никто не скомпилирует — файлы просто полежат рядом без дела.
Поэтому монтируют результат сборки:
volumes:
- ./build/libs:/app # пересобрали jar на хосте — перезапустили контейнер
Пересобрали ./gradlew bootJar, сделали docker compose restart app — в контейнере новая версия, образ пересобирать не нужно.
healthcheck
healthcheck задаёт команду проверки готовности сервиса. Compose периодически запускает её внутри контейнера и отмечает сервис как healthy только после успешного прохождения.
Без healthcheck зависимость depends_on срабатывает, когда контейнер запущен, но Postgres ещё не принимает соединения — приложение упадёт с ошибкой подключения. С healthcheck и condition: service_healthy Compose дождётся реальной готовности базы.
depends_on
depends_on управляет порядком запуска. В простейшем виде:
depends_on:
- db
Но это только гарантирует, что контейнер db запущен раньше app. Для гарантии готовности используйте расширенный вариант с condition:
depends_on:
db:
condition: service_healthy
healthcheck и depends_on: две частые ошибки
В примере выше у базы есть проверка работоспособности, а у приложения — ожидание service_healthy. Это правильная связка, и в ней есть два места, где легко потерять время.
Первое: проверка запускается сразу и тратит время зря. Параметр interval: 5s означает «проверять каждые пять секунд», в том числе пока база ещё поднимается. Первые попытки заведомо неудачны, и приложение ждёт до следующего интервала — то есть лишние секунды на каждом запуске стека. Лечится это двумя параметрами:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
interval: 10s # обычный интервал, когда контейнер уже работает
timeout: 5s
retries: 5
start_period: 30s # время на старт: неудачи в этот период не считаются
start_interval: 1s # но проверять в этот период — часто
start_period даёт запас на медленный старт (для JVM-приложения это десятки секунд), а start_interval заставляет проверять во время старта каждую секунду — и как только проверка проходит, контейнер сразу считается работоспособным, не дожидаясь общего интервала. Вместе они убирают те самые лишние секунды: обычный интервал можно сделать большим, не замедляя запуск.
Второе: depends_on работает только на старте. Условие service_healthy задерживает запуск приложения, пока база не ответит, — и на этом его роль заканчивается. Если база упадёт через час, Compose ничего не сделает: приложение продолжит работать со сломанным соединением, а перезапуск ему не назначат. И наоборот: перезапуск базы не перезапускает приложение.
Отсюда практический вывод, который относится не только к Compose: приложение обязано уметь переподключаться само. Пул соединений должен переживать недоступность базы и восстанавливаться, проверка готовности приложения — отвечать «не готов», пока соединения нет, а очередь и внешние вызовы — повторяться. depends_on избавляет от шума в логах при запуске, но не заменяет устойчивость; в оркестраторе всё то же самое, только там ещё и перезапустят.
Имя проекта: откуда берутся имена сетей и томов
Compose не придумывает имена из воздуха: у каждого запуска есть имя проекта, и оно становится префиксом всего, что создаётся. По умолчанию это имя каталога, в котором лежит файл.
Каталог shop с нашим примером даёт: контейнеры shop-db-1 и shop-app-1, сеть shop_default, том shop_postgres_data.
Одна команда docker compose up в каталоге shop создаёт четыре объекта; смотрите, как имя проекта становится префиксом у каждого.
Отсюда самая частая история из серии «данные пропали». Каталог проекта переименовали (или склонировали репозиторий в папку с другим именем) — имя проекта изменилось, Compose создал новый том newname_postgres_data, и база оказалась пустой. Старый том жив, лежит рядом, и его видно в docker volume ls — но приложение о нём не знает.
Поэтому имя проекта задают явно, а не полагаются на имя каталога:
name: shop # прямо в compose-файле (Compose v2.4+)
docker compose -p shop up -d # или флагом
COMPOSE_PROJECT_NAME=shop docker compose up -d # или переменной окружения
Два практических следствия. Одно и то же имя проекта означает один и тот же набор контейнеров: повторный up не поднимет второй экземпляр, а обновит существующий. И разные имена позволяют держать несколько независимых копий стека на одной машине — по копии на ветку, каждая со своими томами и своей сетью, и это удобный приём, когда надо проверить две версии одновременно.
Проверить, как именно всё будет названо, можно заранее: docker compose config печатает итоговый файл со всеми подстановками, а docker compose ps и docker volume ls покажут, что уже создано.
Два разных .env, которые легко перепутать
Слово «переменные окружения» в Compose означает две разные вещи, и это источник долгих недоумений.
Файл .env рядом с compose-файлом подставляет значения в сам YAML. Он нужен, чтобы не писать версии и порты в файле:
# .env
POSTGRES_VERSION=16
APP_PORT=8080
services:
db:
image: postgres:${POSTGRES_VERSION}
app:
ports:
- "${APP_PORT}:8080"
Этот файл читается только Compose и только для подстановки ${...}. Внутрь контейнера его переменные сами по себе не попадают.
Ключ env_file: внутри сервиса передаёт переменные в контейнер:
services:
app:
env_file:
- app.env # эти переменные увидит процесс внутри контейнера
Приложение получит их так же, как если бы они были перечислены в environment:. Compose при этом в них не заглядывает и подстановку ${...} в YAML из них не делает.
Отсюда правила, которые снимают путаницу:
- Нужно подставить значение в compose-файл (версия образа, порт, имя проекта) — это
.envрядом с файлом. - Нужно передать значение приложению (адрес базы, пароль, профиль Spring) — это
environment:илиenv_file:. - Одна и та же переменная может понадобиться в обоих местах, и тогда она пишется дважды: в
.envдля подстановки и вenvironment:со значением${...}. Это не дублирование по недосмотру, а два разных потребителя. - Что именно получилось, показывает
docker compose config: там видно и подставленные значения, и итоговый список переменных сервиса. Это первая команда при вопросе «почему приложение не видит переменную».
И про секреты: .env с паролями не кладут в репозиторий (.gitignore), а в промышленной среде их передают не файлом рядом с кодом, а из хранилища секретов — об этом статья про промышленные образы.
Основные команды
# запустить все сервисы в фоне
docker compose up -d
# посмотреть логи всех сервисов (или конкретного: ... logs db)
docker compose logs -f
# остановить и удалить контейнеры (тома сохраняются)
docker compose down
# остановить и удалить контейнеры вместе с томами
docker compose down -v
# пересобрать образ и перезапустить
docker compose up -d --build
# выполнить команду внутри запущенного контейнера
docker compose exec db psql -U myapp
Этого набора хватает на первое время, а дальше пригодятся ещё несколько.
# поднять и дождаться, пока все сервисы станут работоспособными
docker compose up -d --build --wait
# что сейчас запущено, в каком состоянии, какие порты
docker compose ps
# итоговый файл со всеми подстановками — первая команда при любой непонятной ошибке
docker compose config
# остановить, не удаляя контейнеры (и потом поднять обратно)
docker compose stop
docker compose start
# посмотреть, что съедает ресурсы
docker compose stats
Из них стоит выделить два.
--wait заставляет команду не возвращать управление, пока сервисы не станут работоспособными (по их проверкам) — это то, чего обычно ждут от up -d и не получают. В конвейере это единственный способ надёжно дождаться готовности окружения перед запуском тестов, без sleep 30 в скрипте.
config разворачивает все подстановки, дополнительные файлы и профили в один итоговый YAML. Когда переменная «не подставилась», путь к базе «не тот» или том оказался не тем — ответ виден здесь за секунду.
Две возможности, о которых полезно знать заранее.
Дополнительный файл docker-compose.override.yml Compose подхватывает автоматически, если он лежит рядом, и накладывает поверх основного. Так держат разницу между «как у всех» и «как у меня»: в основном файле образ и порты, в дополнительном — монтирование исходников, отладочный порт JVM, другой уровень журналирования. Файл добавляют в .gitignore, и у каждого он свой. Явно набор файлов задают флагами: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d.
Профили позволяют держать в одном файле сервисы, которые нужны не всегда:
services:
app:
image: myapp:1.0
kafka:
image: apache/kafka:3.8.0
profiles: ["full"]
Обычный docker compose up поднимет только app, а docker compose --profile full up — вместе с Kafka. Удобно, когда полный стек тяжёлый, а половине задач нужна только база.
Сети в Compose
По умолчанию Compose создаёт одну общую сеть для всех сервисов файла. Все контейнеры в этой сети видят друг друга по именам сервисов. Вы можете явно описывать несколько сетей — например, чтобы изолировать часть сервисов:
services:
app:
networks:
- frontend
- backend
db:
networks:
- backend
networks:
frontend:
backend:
Подробнее о сетях — в статье Сети в Docker.
Две сети одного файла: app стоит в обеих, а db только в backend, поэтому из frontend до базы не достучаться.
Пример: Spring Boot + Postgres с профилем разработки
Полный docker-compose.yml для локальной разработки. От примера выше он отличается тремя вещами: приложение собирается из исходников (build: . вместо image:), включён профиль dev, и Hibernate только сверяет схему (validate), а не создаёт её — об этой детали ниже:
services:
db:
image: postgres:16
environment:
POSTGRES_DB: orders
POSTGRES_USER: orders
POSTGRES_PASSWORD: dev_secret
ports:
- "127.0.0.1:5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U orders -d orders"]
interval: 5s
retries: 10
app:
build: .
environment:
SPRING_PROFILES_ACTIVE: dev
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/orders
SPRING_DATASOURCE_USERNAME: orders
SPRING_DATASOURCE_PASSWORD: dev_secret
SPRING_JPA_HIBERNATE_DDL_AUTO: validate
ports:
- "8080:8080"
depends_on:
db:
condition: service_healthy
volumes:
pg_data:
Команда docker compose up -d поднимет Postgres, дождётся его готовности и только потом запустит приложение. Логи обоих контейнеров будут доступны через docker compose logs -f.
Одна деталь, о которую спотыкаются при первом запуске. SPRING_JPA_HIBERNATE_DDL_AUTO: validate означает «схема уже есть, просто сверь её с сущностями». На чистом томе схемы нет ни одной таблицы, и приложение упадёт на старте. Таблицы кто-то должен создать — в приложении это делают Liquibase или Flyway: они лежат в самом проекте, прогоняются при запуске и приводят базу к нужному виду. С ними validate — правильная настройка: миграции создают схему, Hibernate убеждается, что она совпадает с кодом. Без них проще поставить update и не удивляться.
То же окружение из тестов: Testcontainers
Compose поднимает окружение для работы руками, а тому же проекту нужно такое же окружение в тестах — и это не то же самое. Compose придётся запускать до теста и останавливать после, следить, что порты свободны, а состояние базы не протекло из предыдущего запуска. Для Java эту задачу закрывает библиотека Testcontainers: контейнеры поднимает сам тест.
@SpringBootTest
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16");
@Autowired OrderRepository orders;
@Test
void сохраняетИЧитает() {
orders.save(new Order("ord-1"));
assertThat(orders.findById("ord-1")).isPresent();
}
}
Что здесь происходит: контейнер поднимается перед тестами класса, @ServiceConnection подставляет в настройки Spring адрес, пользователя и пароль (без него те же три строки пишут руками через @DynamicPropertySource), после тестов контейнер удаляется вместе с данными. Порт выбирается свободный, поэтому параллельные сборки не конфликтуют.
Что важно знать про связку с Compose.
Один и тот же образ — в обоих местах. Версия PostgreSQL в тестах и в compose-файле должна совпадать, иначе тест проверяет не то, что работает у вас на машине. Удобно держать версию в одном месте — в .env для Compose и в константе теста, а лучше в свойстве проекта, откуда её читают оба.
Testcontainers умеет читать и сам compose-файл. Если окружение сложное (база, брокер, кэш), есть DockerComposeContainer, который поднимает его целиком из вашего же YAML. Платой будет скорость: поднять четыре контейнера дольше, чем один, поэтому так делают для нескольких интеграционных тестов, а не для всех.
Для Spring Boot 3 есть и третий путь. Тот же контейнер описывают бином в тестовой конфигурации и запускают приложение локально прямо на нём (@TestConfiguration плюс поддержка Docker Compose в самом Spring Boot, которая умеет поднимать ваш compose-файл при старте приложения в режиме разработки). Тогда «запустить приложение» и «запустить тесты» используют одно и то же окружение без ручных шагов.
Подробный разбор с кодом — в статье про Docker в тестах и в разделе про тестирование.
Глубже: политики перезапуска: restart в Compose и у docker runрасширенное
Процесс в контейнере упал, и что дальше, решает политика перезапуска, а по умолчанию она no: контейнер остаётся в Exited, и сервис лежит, пока кто-то не заметит. Политику задают флагом --restart у docker run или ключом restart: у сервиса в Compose, и вариантов четыре.
on-failure[:N] перезапускает только при ненулевом коде выхода, не больше N раз, и не трогает контейнер, который вышел с нулём. always перезапускает при любом выходе и поднимает контейнер после перезагрузки машины, даже если его остановили руками до неё. unless-stopped то же, что always, но остановленный руками контейнер после перезагрузки не поднимается, и это обычный выбор для сервисов на одной машине. Между попытками Docker ждёт с удвоением паузы, начиная со ста миллисекунд, а после успешного старта на десять секунд счётчик сбрасывается.
services:
app:
image: myapp:1.4.2
restart: unless-stopped
Три вещи, которые политика не делает. Она не смотрит на HEALTHCHECK: контейнер со статусом unhealthy продолжает работать, Docker его не перезапускает, это делают только оркестраторы. Она не помогает при бесконечном падении: контейнер с ошибкой в конфигурации перезапускается вечно со статусом Restarting, и по docker ps это выглядит почти как живой; смотрят docker events и логи. И она не заменяет корректной остановки: при перезапуске процесс получает SIGTERM и десять секунд, о чём раздел про PID 1 в статье про Spring Boot в контейнере.
docker stop отключает перезапуск до следующего docker start, поэтому остановленный для обслуживания контейнер не восстанет сам. В Kubernetes политика перезапуска всегда есть у пода и дополняется пробами, а Compose с unless-stopped это потолок того, что даёт один Docker.
Глубже: что Docker не делает сам: мостик к оркестрациирасширенное
Compose запускает всё одной командой, и на этом Docker заканчивается. Четыре вещи, которых от него ждут и которые он не умеет, и все четыре живут в соседних разделах.
Перезапуск по готовности. Политика перезапуска реагирует на выход процесса, а не на то, что приложение перестало отвечать; HEALTHCHECK меняет статус, и только. Проверки живости и готовности, по которым контейнер перезапускают или выводят из-под трафика, это Kubernetes, и там же HEALTHCHECK заменяют пробы, о чём раздел про Kubernetes.
Масштабирование. docker compose up --scale app=3 поднимает три копии на одной машине и без балансировщика перед ними; на несколько машин Docker сам по себе не масштабирует. Реплики, распределение по узлам и автоматическое масштабирование по нагрузке это оркестратор.
Обновление без простоя. docker compose up с новым образом останавливает старый контейнер и запускает новый, и между ними сервис недоступен. Плавное обновление, когда новая копия принимает трафик, а старая дорабатывает запросы, требует балансировщика и оркестратора, и оно держится на том, что приложение корректно завершается по SIGTERM, о чём раздел про корректное завершение и его связка с Kubernetes.
Сборка и доставка. docker build на ноутбуке это не конвейер: сборка образа, тесты, публикация в реестр и выкат по тегу живут в CI/CD, и статья про реестры показывает, как образ туда попадает.
Compose остаётся нужным на двух местах: локальный стенд разработчика, где нужны база, брокер и сервис одной командой, и небольшой сервис на одной машине, где оркестратор дороже проблемы. Всё, что должно переживать выход из строя машины и обновляться без простоя, уезжает в Kubernetes, и переезд туда начинается с того же образа, тех же переменных окружения и того же SIGTERM, что описаны в этом разделе.
Коротко
- Docker Compose описывает многоконтейнерное окружение в одном YAML-файле и запускает его одной командой. Сервисы видят друг друга по именам, заданным в
services:— DNS-резолюция работает автоматически. healthcheck+depends_on: condition: service_healthyгарантируют, что зависимый сервис стартует после реальной готовности базы.depends_on: service_healthyпомогает только на старте: упавшую позже базу Compose не лечит, поэтому приложение обязано переподключаться само.volumesсохраняют данные между перезапусками контейнеров.docker compose downостанавливает и удаляет контейнеры, тома при этом сохраняются;down -vудаляет и тома.environmentпередаёт конфигурацию — Spring Boot читает переменные окружения напрямую как свойства. Два разных.env: файл рядом с compose-файлом подставляет${...}в YAML,env_file:передаёт переменные внутрь контейнера; что получилось, показываетdocker compose config.- Файл
docker-compose.ymlкоммитится в репозиторий — окружение воспроизводимо на любой машине. Политика перезапуска по умолчаниюno;unless-stoppedдля сервисов на одной машине;HEALTHCHECKона не смотрит, бесконечное падение выглядит какRestarting,docker stopотключает её доstart. - Docker не перезапускает по готовности, не масштабирует между машинами, не обновляет без простоя и не доставляет образы: это Kubernetes, корректное завершение и CI/CD; Compose остаётся для стенда и одной машины.
start_periodиstart_intervalв проверке убирают лишние секунды запуска: неудачи во время старта не считаются, а проверяется он часто.- Имя проекта (по умолчанию — имя каталога) становится префиксом сети и томов: переименовали папку — база «пропала», поэтому имя задают через
name:или-p. - Полезные команды дальше базовых:
up -d --build --wait(дождаться готовности),ps,config, файлoverrideдля локальных отличий иprofilesдля тяжёлых сервисов. - В тестах то же окружение поднимает Testcontainers:
@Containerс@ServiceConnection, версия образа общая с compose-файлом.
Что почитать дальше
- Сети в Docker — как устроены сети между контейнерами, как их изолировать.
- Тома и хранение данных — типы томов, bind mounts, когда что использовать.
- Запуск контейнеров — флаги
docker run, управление жизненным циклом контейнера.