Docker Compose для реальных проектов: конфиги, сети, volume и воспроизводимые окружения
Научимся поднимать набор сервисов одним файлом, правильно разделять окружения и делать так, чтобы «у меня работает» исчезло.
Содержание
Docker Compose для реальных проектов: конфиги, сети, volume и воспроизводимые окружения
Docker Compose часто начинают использовать как удобную утилиту: «вот один файл — и поднимем БД и приложение». Но на практике почти каждый зрелый проект упирается в более сложные вопросы: как хранить конфиги по окружениям, как сделать сеть и сервисные имена стабильными, как правильно работать с volume, как воспроизводить окружение так, чтобы фраза «у меня работает» стала исключением, а не правилом.
Эта статья — практический разбор того, как собирать Compose-набор сервисов для реальных задач: с контрольной структурой конфигов, предсказуемыми сетями, аккуратным управлением данными и подходами к воспроизводимости.
Базовая модель Compose и где обычно ломается реальная разработка
Compose описывает набор сервисов и их зависимости в YAML. На практике важны четыре слоя:
- Образ/билд: откуда берётся код и как он собирается.
- Конфигурация: env, файлы конфигурации, секреты, флаги.
- Сети: как сервисы находят друг друга, какой DNS и какие порты наружу.
- Хранилище: volume/bind mount и стратегия персистентности.
Проблемы начинаются, когда:
- конфиги размазаны по файлам и переменным без чёткой схемы;
- используется один
.envдля всех сред (или вообще ручное редактирование YAML); - приложение обращается к
localhostвнутри контейнера; - персистентные данные живут «где-то там» и меняют поведение между запусками;
- окружение нельзя воссоздать после чистой машины.
Чтобы «у меня работает» исчезло, нужно договориться о правилах: как задаём конфиги, как описываем сеть, где живут данные и как фиксируем зависимости окружения.
Структура репозитория: конфиги, compose-версии и единый контракт
Хорошая практика — разделить Compose-спецификацию и окруженческие параметры.
Рекомендуемая схема файлов
Пример структуры:
.
├── docker-compose.yml
├── docker-compose.override.yml
├── docker/
│ ├── env/
│ │ ├── dev.env
│ │ ├── test.env
│ │ └── prod.env
│ ├── config/
│ │ ├── nginx/
│ │ │ └── nginx.conf
│ │ └── scripts/
│ │ └── entrypoint.sh
├── .env.example
└── README.md
Идея такая:
- базовый
docker-compose.ymlзадаёт «что именно запускаем»; - окружение определяется отдельными
*.envфайлами; - опционально используем
docker-compose.override.ymlдля локальной разработки (горячая перезагрузка, маппинг исходников и т.п.).
Один контракт для запуска
В README.md вы фиксируете команды, которые должны работать у каждого:
# Dev
docker compose --env-file docker/env/dev.env up -d --build
# Test (например, другая БД/фичи)
docker compose --env-file docker/env/test.env up -d --build
# Чистая остановка
docker compose down
На уровне команды важно использовать одинаковую схему передачи env, чтобы не было ситуации «у меня .env лежит в корне, а у тебя — в docker/env».
Конфиги: переменные окружения, файлы конфигурации и секреты
Где хранить env-переменные
Переменные окружения (environment, env_file, ${VAR}) — самый распространённый способ подставлять различия между dev/test/prod: адреса, названия баз, флаги логирования.
Compose поддерживает два подхода:
env_file— указать файл с переменными.environment— задать переменные прямо в YAML (часто с подстановками из окружения).
В реальном проекте удобно сочетать: текущая среда — через --env-file, а внутри YAML — ссылаться на переменные.
Пример базового docker-compose.yml:
services:
app:
image: myorg/myapp:dev
env_file:
- ./docker/env/${APP_ENV}.env
environment:
# Пример добавления переменной поверх env_file
LOG_LEVEL: ${LOG_LEVEL:-info}
ports:
- "${APP_PORT:-8080}:8080"
Но тут есть нюанс: ${APP_ENV} должен быть известен. Поэтому чаще делают так: вообще не хранить имя окружения в YAML, а передавать файл напрямую через --env-file:
docker compose --env-file docker/env/dev.env up -d --build
Тогда в YAML можно оперировать переменными, просто не добавляя env_file:
services:
app:
image: myorg/myapp:dev
environment:
APP_ENV: ${APP_ENV}
LOG_LEVEL: ${LOG_LEVEL}
DATABASE_URL: ${DATABASE_URL}
ports:
- "${APP_PORT}:${APP_PORT_CONTAINER}"
Так вы получаете один источник правды: файл dev.env полностью описывает среду.
Конфиги в виде файлов (не только env)
Иногда параметров слишком много, или нужны конфиги сложной структуры: nginx, php-fpm pool, сервисная конфигурация. В таких случаях лучше использовать bind mount или размещать файлы в образе.
Для dev удобно монтировать конфиги как файлы, для prod — избегать bind mount и класть конфиги в образ или использовать управляемые секреты.
Например, nginx:
services:
nginx:
image: nginx:1.27
volumes:
- ./docker/config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app
ports:
- "80:80"
Важно: :ro снижает риск случайной порчи конфигурации внутри контейнера.
Секреты: не класть в Git и не передавать как простые env в реальности
Compose в целом позволяет хранить секреты через механизм Compose Secrets (и интеграции с Docker Swarm), но если вы живёте в классическом Docker Compose без Swarm — всё равно важно не превращать env в «секреты». На практике подходы такие:
- для dev: использовать тестовые значения;
- для test/stage/prod: хранить секреты в CI/CD или в системах управления секретами (Vault, cloud secrets);
- в Compose указывать их через переменные окружения, которые CI подставляет в рантайме.
Компромисс: Compose держим общим, а секреты — внешними. Тогда локально всё запускается со «слабой безопасностью», а в проде — с реальными секретами.
Сети: сервисные имена, предсказуемая маршрутизация и отсутствие ловушек с localhost
Compose по умолчанию создаёт сеть для проекта. Внутри этой сети сервисы доступны по имени сервиса (service name), например postgres или redis. Это фундамент: внутри контейнеров не используйте localhost для обращения к другому сервису.
Пример: приложение соединяется с БД по имени сервиса
Допустим, docker-compose.yml:
services:
db:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
app:
build: .
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
depends_on:
- db
ports:
- "${APP_PORT}:${APP_PORT_CONTAINER}"
Здесь db — корректное hostname для контейнера app.
Когда нужна явная настройка сетей
Иногда требуется:
- несколько сетей (front/back) для ограничения доступа;
- статический выбор подсетей для интеграции с внешними системами;
- подключение внешнего сервиса.
Пример с двумя сетями:
services:
app:
build: .
networks:
- internal
- external
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
nginx:
image: nginx:1.27
networks:
- external
ports:
- "80:80"
db:
image: postgres:16
networks:
- internal
volumes:
- db_data:/var/lib/postgresql/data
networks:
internal:
driver: bridge
external:
driver: bridge
Подход полезен, когда вы хотите, чтобы БД была видна только приложению, а не внешнему миру.
Очередная частая ошибка: порты наружу vs внутренняя связь
Compose-порты (ports:) нужны для доступа извне хоста. Для связи сервисов внутри сети порты наружу не требуются — контейнеры могут общаться напрямую по внутреннему порту.
Поэтому, если вы в app ожидаете, что БД «доступна на 5432 через хост», это будет работать только локально и только при пробросе ports (а затем сломается в интеграции/CI).
Volume: как правильно управлять данными, чтобы они не «переезжали»
Разделяйте dev и тестовое состояние
Volume — это персистентность. Если вы смонтировали volume с БД, данные останутся между запусками, и это может:
- ускорять dev,
- но ломать тесты (из-за старых миграций/данных),
- и создавать «призраки» в отладке.
Поэтому важно разделить:
- dev: обычно оставляют volume, чтобы не пересоздавать данные каждый раз.
- test: чаще используют либо отдельные named volumes на отдельную среду, либо очищают volume при запуске тестов, либо запускают БД с seed-скриптами.
Named volume vs bind mount
named volumeсоздаётся Docker-ом и живёт как управляемый объём. Плюс — предсказуемое поведение.bind mountмонтирует каталог с хоста. Плюс — удобство для разработки, минус — зависимость от структуры ОС/путей и лишние риски.
Для БД почти всегда выбирают named volume:
volumes:
db_data:
и в сервисе:
services:
db:
volumes:
- db_data:/var/lib/postgresql/data
Изоляция volume по окружениям
Если вы используете один и тот же named volume для dev и test — данные будут смешиваться.
Варианты:
- Держать разные volume имена, подставляя суффиксы через переменные.
- Использовать разные проекты Compose (у Compose есть префикс проекта).
Рассмотрим второй подход: имя проекта можно менять опцией -p. Тогда тома и сети автоматически будут различаться.
Например:
docker compose -p myapp-dev up -ddocker compose -p myapp-test up -d
И тогда volume и сеть будут иметь префикс проекта.
Если хочется всё контролировать через YAML, можно подставлять суффикс:
volumes:
db_data_${APP_ENV}:
но чаще удобнее держать это в командах CI/локально через -p.
Когда volume нужно очищать
Для тестов часто полезна команда:
docker compose -p myapp-test down -v
-v удалит volume. Да, это делает тесты более «дорогими», но вы платите за воспроизводимость.
Воспроизводимые окружения: фиксируем зависимости и избегаем скрытых допущений
Compose помогает стартовать, но воспроизводимость обеспечивают ещё дисциплина и фиксация.
Правило №1: фиксируйте образы (или тщательно описывайте build)
Для prod/test желательно использовать конкретные теги образов (иммутабельность). Для dev допустим build: из исходников.
Compose может выглядеть так:
services:
app:
build:
context: .
dockerfile: Dockerfile
environment:
NODE_ENV: ${NODE_ENV}
Но если вы в CI или тестовом контуре ожидаете «в точности то же окружение», сборка должна быть детерминированной:
- фиксированные версии зависимостей в
package-lock.json/poetry.lock/requirements.txtс хешами; - стабильные base images (конкретные digest’ы, если критично).
Правило №2: не полагайтесь на локальные файлы, которых нет в образе
Типичная ловушка — приложение читает конфиг или ключ из пути на хосте, потому что «у нас на машине так устроено». В Compose это приводит к тому, что контейнер в CI падает.
Решение:
- либо кладите такие файлы в образ,
- либо монтируйте их через
volumes:так, чтобы путь существовал в репозитории.
Например, для локальной разработки можно добавить bind mount исходников:
services:
app:
build: .
volumes:
- ./:/app
working_dir: /app
Но помните: это dev-паттерн. Для prod его лучше отключать.
Пример рабочего docker-compose.yml для набора сервисов (dev)
Ниже — пример «типового» стека: приложение + БД + кеш + фронт (nginx). Он демонстрирует:
- env-переменные,
- сеть по имени сервиса,
- volume для БД,
- разделение по портам.
services:
db:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
networks:
- internal
redis:
image: redis:7.2
networks:
- internal
app:
build:
context: .
dockerfile: Dockerfile
environment:
APP_ENV: ${APP_ENV}
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
REDIS_URL: redis://redis:6379/0
PORT: ${APP_PORT_CONTAINER}
depends_on:
- db
- redis
ports:
- "${APP_PORT}:${APP_PORT_CONTAINER}"
networks:
- internal
- external
nginx:
image: nginx:1.27
volumes:
- ./docker/config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app
ports:
- "${HTTP_PORT:-80}:80"
networks:
- external
volumes:
db_data:
networks:
internal:
driver: bridge
external:
driver: bridge
Что важно в этом примере
DATABASE_URLиспользуетdbкак hostname — это правильная сетевая модель Compose.redis://redis:6379/0— тот же принцип.db_data— named volume для персистентности БД.nginxподключён кexternal, БД — кinternal.portsпробрасываются только тем сервисам, которым нужен доступ с хоста.
Compose и окружения: один YAML или отдельные файлы?
Есть два популярных подхода:
- Один
docker-compose.yml, а окружение задаётся только.env. - Отдельные compose-файлы:
docker-compose.dev.yml,docker-compose.test.yml,docker-compose.prod.yml— и сборка через-f.
Первый подход проще для старта, но быстро усложняется, когда отличий становится много (разные volume, разные команды, разные healthcheck, разные маппинги).
Второй подход лучше масштабируется. Пример команды:
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file docker/env/dev.env up -d --build
Где базовый файл содержит общую структуру, а dev-файл дополняет.
Пример docker-compose.dev.yml
services:
app:
volumes:
- ./:/app
command: ["npm", "run", "dev"]
В base файле command не задан, поэтому используется то, что определено в Dockerfile, либо дефолт в образе.
Так вы избегаете монструозных условных выражений внутри одного YAML.
Здоровье сервисов: healthcheck и wait-for-dependencies
Compose умеет управлять зависимостями через depends_on, но важно понимать: он не ждёт готовности приложения. Он лишь определяет порядок старта.
Чтобы избежать гонок (например, приложение стартует и пытается мигрировать БД, которая ещё не приняла соединения), используйте:
healthcheckдля БД и зависимых сервисов,- либо «инициализацию» через отдельный job/скрипт,
- либо retry logic в приложении.
Пример healthcheck для postgres:
services:
db:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
Далее в приложении уже лучше делать retries на подключение или запускать миграции после того, как БД здорова.
Типичные ошибки, которые ломают воспроизводимость
1) “Внутри контейнера обратиться к localhost”
В контейнере localhost
Комментарии
Пока нет комментариев