9.6. Несколько файлов и profiles
Цели
После этого материала вы сможете:
- предсказать результат слияния двух Compose-файлов для каждого типа значения;
- объяснить, почему
compose.override.yamlподхватывается сам, а-fего отключает; - применять теги
!resetи!override, когда обычного слияния недостаточно; - включать опциональные сервисы через profiles и понимать их взаимодействие с
depends_on; - устранять дублирование якорями YAML, полями
x-и ключомextends; - организовать конфигурации dev, test и prod без копирования файлов.
Предварительные знания
- 9.2. Справочник по services;
- 9.5. Environment и secrets;
- синтаксис YAML: якоря
&, ссылки*, слияние<<.
Ключевые термины
| Термин | Объяснение |
|---|---|
override | Файл, дополняющий базовый |
merge | Слияние конфигураций по правилам типа значения |
!reset | Тег: сбросить значение, заданное в базовом файле |
!override | Тег: заменить целиком вместо слияния |
profile | Метка, включающая сервис только при явном запросе |
x- | Префикс пользовательских полей верхнего уровня |
Теория
Автоматическое слияние
Если рядом с compose.yaml лежит compose.override.yaml, Compose объединяет их без дополнительных флагов:
compose.yaml базовая конфигурация
compose.override.yaml локальные дополнения
Приём даёт разделение: базовый файл в репозитории, override — у каждого разработчика свой и в .gitignore.
Важно: флаг -f отменяет автоматику. docker compose -f compose.yaml up подхватит только указанный файл, override будет проигнорирован.
Явный список файлов
docker compose -f compose.yaml -f compose.prod.yaml up -d
Порядок значим: каждый следующий файл накладывается на результат предыдущих. Относительные пути внутри всех файлов разрешаются от каталога первого файла.
Альтернатива флагам — переменная окружения:
export COMPOSE_FILE=compose.yaml:compose.prod.yaml
docker compose up -d
Разделитель — двоеточие в Linux; меняется переменной COMPOSE_PATH_SEPARATOR.
Правила слияния
Результат зависит от типа значения — и это главное, что нужно знать.
| Тип значения | Примеры ключей | Правило |
|---|---|---|
| Скаляр | image, user, restart, working_dir | Заменяется |
| Список-команда | command, entrypoint, healthcheck.test | Заменяется целиком |
| Отображение | environment, labels, extra_hosts | Сливается по ключам |
| Список с ключом | volumes, devices | Сливается по целевому пути |
| Простой список | ports, expose, dns, tmpfs | Дополняется |
Практические следствия:
# базовый
services:
app:
ports: ["8000:8000"]
environment:
A: "1"
B: "2"
command: ["python", "app.py", "--verbose"]
# override
services:
app:
ports: ["9000:9000"]
environment:
B: "переопределено"
C: "3"
command: ["python", "app.py"]
Результат:
| Ключ | Значение | Почему |
|---|---|---|
ports | ["8000:8000", "9000:9000"] | Список дополнен |
environment | A=1, B=переопределено, C=3 | Отображение слито |
command | ["python", "app.py"] | Список-команда заменён |
Первая строка удивляет чаще всего: порты не заменяются, а складываются. Попытка «поменять порт» в override даёт два опубликованных порта вместо одного.
!reset и !override
Обычного слияния не всегда достаточно. Compose предоставляет два YAML-тега.
# compose.override.yaml
services:
app:
ports: !reset [] # убрать все порты из базового файла
environment:
DEBUG: !reset null # убрать переменную
volumes: !override
- ./only-this:/data # заменить список целиком, а не дополнить
| Тег | Действие |
|---|---|
!reset | Удаляет значение, заданное в предыдущих файлах |
!override | Заменяет значение целиком вместо слияния |
Именно они решают задачу «убрать публикацию порта в production» и «заменить набор volumes, а не дополнить его».
Без них приходилось разбивать конфигурацию на большее число файлов или дублировать сервисы.
Profiles
services:
api:
image: myapp # без profiles — запускается всегда
debug-tools:
image: nicolaka/netshoot
profiles: [debug]
test-runner:
image: myapp
command: ["pytest"]
profiles: [test, ci]
| Ситуация | Что запустится |
|---|---|
docker compose up | Только api |
docker compose --profile debug up | api и debug-tools |
COMPOSE_PROFILES=test,ci docker compose up | api и test-runner |
docker compose up debug-tools | api и debug-tools — имя активирует профиль |
Последняя строка важна: явное указание сервиса включает его профиль автоматически.
Взаимодействие с depends_on:
| Ситуация | Поведение |
|---|---|
| Активный сервис зависит от сервиса с профилем | Зависимость запустится вместе с ним |
| Сервис с профилем зависит от активного | Обычная зависимость |
| Оба с разными профилями | Оба должны быть активированы |
Profiles решают задачи, для которых раньше заводили отдельные файлы: инструменты отладки, разовые задачи, опциональные компоненты.
Устранение дублирования
Три механизма, разные по назначению.
Якоря YAML — работают на уровне парсера, до Compose:
x-common: &common
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
services:
api:
<<: *common
image: myapp
worker:
<<: *common
image: myapp
command: ["python", "-m", "worker"]
Ключ x-common начинается с x- — такие поля Compose игнорирует, они существуют только как место для якоря.
Ограничение: << сливает поверхностно. Вложенное отображение заменяется целиком, а не сливается по ключам.
Ключ extends — работает на уровне Compose и умеет брать конфигурацию из другого файла:
services:
api:
extends:
file: common-services.yaml
service: base-app
image: myapp
| Якоря | extends | |
|---|---|---|
| Между файлами | Нет | Да |
| Слияние вложенных структур | Поверхностное | По правилам Compose |
Наследование depends_on, volumes_from | — | Не наследуются |
| Читаемость | Требует знания YAML | Явная |
include — подключает целый Compose-файл как часть проекта:
include:
- path: ./infra/compose.yaml
- path: ./services/api/compose.yaml
env_file: ./services/api/.env
В отличие от -f, включённые файлы не сливаются с текущим, а добавляют свои сервисы. Применяется для крупных систем, разделённых по каталогам команд.
Организация окружений
Рабочая схема без копирования файлов:
compose.yaml общая часть: сервисы, сети, volumes
compose.override.yaml dev: bind mounts, отладочные порты (в .gitignore или в репозитории)
compose.prod.yaml prod: лимиты, replicas, без публикации лишнего
compose.test.yaml test: тестовая база, прогон pytest
| Команда | Что получается |
|---|---|
docker compose up | Базовый плюс override — окружение разработки |
docker compose -f compose.yaml -f compose.prod.yaml up | Базовый плюс prod, без override |
docker compose -f compose.yaml -f compose.test.yaml run test | Тестовый прогон |
Ключевой момент: -f отключает автоматический override, поэтому production-конфигурация не подхватит случайно чью-то локальную настройку.
Внутренний механизм
Порядок обработки
- Определяется список файлов:
-f,COMPOSE_FILEили поиск в каталоге. - Для каждого выполняется подстановка
${VAR}(урок 9.5). - Обрабатываются
include. - Файлы сливаются по порядку, применяются
!resetи!override. - Раскрывается
extends. - Отбираются сервисы по активным профилям.
Шаг 2 идёт до слияния: каждый файл интерполируется отдельно, и переменные видны всем.
Как проверить результат слияния
docker compose -f a.yaml -f b.yaml config
Эта команда — единственный надёжный способ узнать, что получилось. Слияние достаточно нетривиально, чтобы предсказывать его в уме было ненадёжно (урок 9.1).
Команды и примеры
Правила слияния на практике
mkdir -p /tmp/merge && cd /tmp/merge
cat > compose.yaml <<'EOF'
name: merge
services:
app:
image: python:3.13-slim
command: ["python", "-c", "print('базовая команда')"]
working_dir: /base
user: "0:0"
ports:
- "18901:8000"
expose:
- "9000"
environment:
A: базовое-A
B: базовое-B
labels:
role: base
volumes:
- vol-base:/data
- vol-shared:/shared
volumes:
vol-base:
vol-shared:
vol-extra:
EOF
cat > compose.override.yaml <<'EOF'
services:
app:
command: ["python", "-c", "print('команда из override')"]
working_dir: /overridden
ports:
- "18902:8001"
expose:
- "9001"
environment:
B: переопределённое-B
C: новое-C
labels:
env: dev
volumes:
- vol-extra:/extra
- vol-shared:/shared-переопределён
EOF
echo "═══ результат слияния ═══"
docker compose config 2>/dev/null | python3 -c "
import sys, yaml
svc = yaml.safe_load(sys.stdin)['services']['app']
print(' command: ', svc.get('command'))
print(' working_dir:', svc.get('working_dir'))
print(' user: ', svc.get('user'))
print(' ports: ', [f\"{p['published']}->{p['target']}\" for p in svc.get('ports', [])])
print(' expose: ', svc.get('expose'))
print(' environment:', svc.get('environment'))
print(' labels: ', svc.get('labels'))
print(' volumes:')
for v in svc.get('volumes', []):
print(f\" {v.get('source')} -> {v.get('target')}\")
"
Ожидаемый вывод:
═══ результат слияния ═══
command: ['python', '-c', "print('команда из override')"]
working_dir: /overridden
user: 0:0
ports: ['18901->8000', '18902->8001']
expose: ['9000', '9001']
environment: {'A': 'базовое-A', 'B': 'переопределённое-B', 'C': 'новое-C'}
labels: {'role': 'base', 'env': 'dev'}
volumes:
vol-base -> /data
vol-shared -> /shared-переопределён
vol-extra -> /extra
Пять разных правил в одном выводе:
| Ключ | Что произошло | Правило |
|---|---|---|
command | Заменён | Список-команда заменяется целиком |
working_dir, user | Заменён / сохранён | Скаляр: задан в override — заменён, не задан — остался |
ports, expose | Оба значения | Простой список дополняется |
environment, labels | Слиты по ключам | Отображение |
volumes | vol-shared заменён по цели, vol-extra добавлен | Список с ключом — по целевому пути |
Строка ports: ['18901->8000', '18902->8001'] — самая частая неожиданность. Заменить порт простым переопределением нельзя.
!reset и !override
cd /tmp/merge
cat > compose.prod.yaml <<'EOF'
services:
app:
# Убрать ВСЕ порты из базового файла
ports: !reset []
# Убрать конкретную переменную
environment:
A: !reset null
PROD_ONLY: "да"
# Заменить список volumes целиком, а не дополнить
volumes: !override
- vol-base:/data
EOF
echo "═══ compose.yaml + compose.prod.yaml (без override) ═══"
docker compose -f compose.yaml -f compose.prod.yaml config 2>/dev/null | python3 -c "
import sys, yaml
svc = yaml.safe_load(sys.stdin)['services']['app']
print(' ports: ', [f\"{p['published']}->{p['target']}\" for p in svc.get('ports', [])] or '(нет)')
print(' environment:', svc.get('environment'))
print(' volumes:')
for v in svc.get('volumes', []):
print(f\" {v.get('source')} -> {v.get('target')}\")
"
Ожидаемый вывод:
═══ compose.yaml + compose.prod.yaml (без override) ═══
ports: (нет)
environment: {'B': 'базовое-B', 'PROD_ONLY': 'да'}
volumes:
vol-base -> /data
Все три тега сработали: порты убраны полностью, переменная A удалена, список volumes заменён вместо дополнения.
Без !reset и !override того же результата пришлось бы добиваться разбиением на файлы так, чтобы «лишнего» просто не было в базовом.
-f отменяет автоматический override
cd /tmp/merge
echo "═══ docker compose config (автоматика) ═══"
docker compose config --format json 2>/dev/null | python3 -c "
import json, sys
print(' файлов учтено:', len(json.load(sys.stdin).get('services', {})))
"
docker compose config 2>/dev/null | grep -c 'команда из override' \
| xargs printf ' override применён: %s\n'
echo "═══ docker compose -f compose.yaml config ═══"
docker compose -f compose.yaml config 2>/dev/null | grep -c 'команда из override' \
| xargs printf ' override применён: %s\n'
docker compose -f compose.yaml config 2>/dev/null | grep -c 'базовая команда' \
| xargs printf ' базовая команда: %s\n'
Ожидаемый вывод:
═══ docker compose config (автоматика) ═══
файлов учтено: 1
override применён: 1
═══ docker compose -f compose.yaml config ═══
override применён: 0
базовая команда: 1
Явный -f вернул базовую конфигурацию — override не подхватился. Это и есть механизм защиты production от локальных настроек разработчика.
Порядок файлов значим
cd /tmp/merge
cat > compose.a.yaml <<'EOF'
services:
app:
image: python:3.13-slim
environment:
WHO: файл-A
command: ["python", "-c", "import os; print(' WHO =', os.environ['WHO'])"]
EOF
cat > compose.b.yaml <<'EOF'
services:
app:
environment:
WHO: файл-B
EOF
echo "═══ a затем b ═══"
docker compose -f compose.a.yaml -f compose.b.yaml run --rm -T app 2>/dev/null
echo "═══ b затем a ═══"
docker compose -f compose.b.yaml -f compose.a.yaml run --rm -T app 2>/dev/null
Ожидаемый вывод:
═══ a затем b ═══
WHO = файл-B
═══ b затем a ═══
WHO = файл-A
Побеждает последний файл в списке. Правило простое, но забывается — особенно когда список задан через COMPOSE_FILE.
Profiles
cd /tmp/merge
rm -f compose.override.yaml compose.prod.yaml compose.a.yaml compose.b.yaml
cat > compose.yaml <<'EOF'
name: merge
services:
api:
image: python:3.13-slim
command: ["sleep", "600"]
db:
image: python:3.13-slim
command: ["sleep", "600"]
# Инструменты отладки — только по запросу
debug:
image: python:3.13-slim
command: ["sleep", "600"]
profiles: [debug]
# Тесты — в двух профилях
tests:
image: python:3.13-slim
command: ["sh", "-c", "echo тесты выполнены"]
profiles: [test, ci]
# Сервис, зависящий от профильного
reporter:
image: python:3.13-slim
command: ["sleep", "600"]
profiles: [ci]
depends_on:
tests:
condition: service_completed_successfully
EOF
show() {
printf ' %-42s → %s\n' "$1" \
"$(eval "$2" 2>/dev/null | tr '\n' ' ')"
}
echo "═══ какие сервисы активны ═══"
show "docker compose config --services" \
"docker compose config --services"
show "--profile debug" \
"docker compose --profile debug config --services"
show "COMPOSE_PROFILES=test" \
"COMPOSE_PROFILES=test docker compose config --services"
show "COMPOSE_PROFILES=ci" \
"COMPOSE_PROFILES=ci docker compose config --services"
show "--profile debug --profile ci" \
"docker compose --profile debug --profile ci config --services"
Ожидаемый вывод:
═══ какие сервисы активны ═══
docker compose config --services → api db
--profile debug → api db debug
COMPOSE_PROFILES=test → api db tests
COMPOSE_PROFILES=ci → api db reporter tests
--profile debug --profile ci → api db debug reporter tests
Сервисы без profiles активны всегда. tests состоит в двух профилях и включается любым из них.
Обратите внимание на строку COMPOSE_PROFILES=ci: активирован один профиль, а сервисов добавилось два — reporter требует tests, и профиль test включился по зависимости.
Явное указание сервиса включает профиль
cd /tmp/merge
echo "═══ обычный up ═══"
docker compose up -d > /dev/null 2>&1
docker compose ps --format ' {{.Service}}' | sort
docker compose down > /dev/null 2>&1
echo "═══ up с именем профильного сервиса ═══"
docker compose up -d debug > /dev/null 2>&1
docker compose ps --format ' {{.Service}}' | sort
docker compose down > /dev/null 2>&1
echo "═══ разовый запуск профильного сервиса ═══"
docker compose run --rm -T tests 2>/dev/null | sed 's/^/ /'
docker compose down --remove-orphans > /dev/null 2>&1
Ожидаемый вывод:
═══ обычный up ═══
api
db
═══ up с именем профильного сервиса ═══
debug
═══ разовый запуск профильного сервиса ═══
тесты выполнены
Второй блок показывает деталь, которую легко упустить: up debug запустил только debug, а не весь стек плюс debug. Явное указание сервисов ограничивает запуск ими и их зависимостями.
Якоря и поля x-
cd /tmp/merge
cat > compose.yaml <<'EOF'
name: merge
# Поля x- игнорируются Compose и служат местом для якорей
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
x-healthcheck: &default-healthcheck
interval: 10s
timeout: 3s
retries: 3
start_period: 15s
x-service-base: &service-base
image: python:3.13-slim
restart: unless-stopped
logging: *default-logging
environment:
TZ: UTC
LOG_LEVEL: INFO
services:
api:
<<: *service-base
command: ["sleep", "600"]
healthcheck:
<<: *default-healthcheck
test: ["CMD", "true"]
worker:
<<: *service-base
command: ["sleep", "600"]
environment:
# ВНИМАНИЕ: заменяет ВСЁ отображение из якоря, а не дополняет
LOG_LEVEL: DEBUG
QUEUE: tasks
EOF
echo "═══ что получилось ═══"
docker compose config 2>/dev/null | python3 -c "
import sys, yaml
cfg = yaml.safe_load(sys.stdin)
for name in ('api', 'worker'):
s = cfg['services'][name]
print(f' {name}:')
print(f' restart: {s.get(\"restart\")}')
print(f' logging: {s.get(\"logging\", {}).get(\"options\")}')
print(f' environment: {s.get(\"environment\")}')
"
Ожидаемый вывод:
═══ что получилось ═══
api:
restart: unless-stopped
logging: {'max-size': '10m', 'max-file': '3'}
environment: {'TZ': 'UTC', 'LOG_LEVEL': 'INFO'}
worker:
restart: unless-stopped
logging: {'max-size': '10m', 'max-file': '3'}
environment: {'LOG_LEVEL': 'DEBUG', 'QUEUE': 'tasks'}
Ключевое наблюдение в последней строке: у worker исчезла переменная TZ.
Слияние << в YAML поверхностное: ключ environment целиком взят из сервиса, а не слит с якорем. Это отличается от слияния Compose-файлов, где отображения сливаются по ключам.
Правило: якоря хорошо переносят целые блоки, но плохо — частичные изменения вложенных структур.
Обходной путь — якорь на само отображение:
cd /tmp/merge
cat > compose.yaml <<'EOF'
name: merge
x-common-env: &common-env
TZ: UTC
LOG_LEVEL: INFO
services:
worker:
image: python:3.13-slim
command: ["sleep", "600"]
environment:
<<: *common-env # слияние на уровне отображения
LOG_LEVEL: DEBUG
QUEUE: tasks
EOF
docker compose config 2>/dev/null | python3 -c "
import sys, yaml
print(' environment:', yaml.safe_load(sys.stdin)['services']['worker']['environment'])
"
Ожидаемый вывод:
environment: {'TZ': 'UTC', 'LOG_LEVEL': 'DEBUG', 'QUEUE': 'tasks'}
TZ сохранена: << применён к самому отображению environment, а не к сервису.
extends между файлами
cd /tmp/merge
cat > common.yaml <<'EOF'
services:
base-app:
image: python:3.13-slim
restart: unless-stopped
environment:
TZ: UTC
LOG_LEVEL: INFO
healthcheck:
test: ["CMD", "true"]
interval: 10s
EOF
cat > compose.yaml <<'EOF'
name: merge
services:
api:
extends:
file: common.yaml
service: base-app
command: ["sleep", "600"]
environment:
LOG_LEVEL: DEBUG # дополняет, а НЕ заменяет
SERVICE: api
EOF
docker compose config 2>/dev/null | python3 -c "
import sys, yaml
s = yaml.safe_load(sys.stdin)['services']['api']
print(' image: ', s.get('image'))
print(' restart: ', s.get('restart'))
print(' environment:', s.get('environment'))
print(' healthcheck:', s.get('healthcheck', {}).get('test'))
"
Ожидаемый вывод:
image: python:3.13-slim
restart: unless-stopped
environment: {'TZ': 'UTC', 'LOG_LEVEL': 'DEBUG', 'SERVICE': 'api'}
healthcheck: ['CMD', 'true']
Здесь TZ сохранилась — в отличие от якорей. extends использует правила слияния Compose, а не YAML.
Это ключевое различие между двумя механизмами и повод предпочитать extends для конфигураций сервисов.
Три окружения без дублирования
cd /tmp/merge
rm -f common.yaml
cat > compose.yaml <<'EOF'
name: merge
x-app: &app
image: python:3.13-slim
restart: unless-stopped
services:
api:
<<: *app
command: ["sleep", "600"]
environment:
MODE: base
ports:
- "18910:8000"
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: "${DB_PASSWORD:-devpassword}"
EOF
cat > compose.override.yaml <<'EOF'
# Разработка: подхватывается автоматически
services:
api:
environment:
MODE: development
DEBUG: "1"
volumes:
- ./src:/app/src
EOF
cat > compose.prod.yaml <<'EOF'
# Production: только с явным -f
services:
api:
environment:
MODE: production
DEBUG: !reset null
ports: !reset []
deploy:
replicas: 2
resources:
limits:
memory: 512M
cpus: "1.0"
db:
deploy:
resources:
limits:
memory: 1G
EOF
cat > compose.test.yaml <<'EOF'
services:
api:
environment:
MODE: test
ports: !reset []
tests:
image: python:3.13-slim
command: ["sh", "-c", "echo прогон тестов; exit 0"]
depends_on:
- api
EOF
mkdir -p src
show_env() {
printf ' %-28s ' "$1"
shift
"$@" config 2>/dev/null | python3 -c "
import sys, yaml
cfg = yaml.safe_load(sys.stdin)
api = cfg['services']['api']
ports = [str(p.get('published')) for p in api.get('ports', [])]
print(f\"MODE={api['environment'].get('MODE')} \"
f\"DEBUG={api['environment'].get('DEBUG', '—')} \"
f\"ports={ports or '[]'} \"
f\"services={sorted(cfg['services'])}\")
"
}
echo "═══ три окружения из одного набора файлов ═══"
show_env "разработка (по умолчанию)" docker compose
show_env "production" docker compose -f compose.yaml -f compose.prod.yaml
show_env "тесты" docker compose -f compose.yaml -f compose.test.yaml
cd /tmp && rm -rf /tmp/merge
Ожидаемый вывод:
═══ три окружения из одного набора файлов ═══
разработка (по умолчанию) MODE=development DEBUG=1 ports=['18910'] services=['api', 'db']
production MODE=production DEBUG=— ports=[] services=['api', 'db']
тесты MODE=test DEBUG=— ports=[] services=['api', 'db', 'tests']
Одна база, три результата. В production переменная DEBUG удалена тегом !reset, публикация портов снята, добавлены лимиты; в тестах появился дополнительный сервис.
Ни один файл не дублирует определение сервисов — каждый содержит только различия.
Практическое упражнение
Задание. Организуйте конфигурацию для четырёх сценариев из общей базы.
Требования:
compose.yamlсодержит определения сервисов и не содержит настроек, специфичных для окружения.- Разработка подхватывается автоматически: bind mount кода, отладочный порт,
DEBUG=1. - Production: порты не публикуются,
DEBUGудалена, заданы лимиты, две реплики. - Тесты: добавляется сервис прогона, публикация снята, база отдельная.
- Профиль
toolsвключает служебный container, не запускающийся по умолчанию. - Дублирование устранено: общие настройки заданы один раз.
Скрипт проверки печатает итоговую конфигурацию для всех четырёх сценариев и подтверждает каждое требование.
Подсказки
Подсказка 1
Для требования 3 понадобятся теги !reset: обычное переопределение портов их не уберёт.
Подсказка 2
Требование 6 удобнее выполнить якорем на отображение, а не на весь сервис.
Подсказка 3
Проверять результат надо через docker compose config, а не запуском.
Решение
Показать решение
mkdir -p /tmp/envfiles/src && cd /tmp/envfiles
cat > compose.yaml <<'EOF'
name: envfiles
# Общие фрагменты — задаются один раз (требование 6)
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
x-common-env: &common-env
TZ: UTC
PYTHONUNBUFFERED: "1"
x-python-service: &python-service
image: python:3.13-slim
restart: unless-stopped
logging: *logging
services:
api:
<<: *python-service
command: ["sleep", "900"]
environment:
<<: *common-env
SERVICE: api
depends_on:
db:
condition: service_healthy
worker:
<<: *python-service
command: ["sleep", "900"]
environment:
<<: *common-env
SERVICE: worker
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
restart: unless-stopped
logging: *logging
environment:
POSTGRES_PASSWORD: "${DB_PASSWORD:-devpassword}"
POSTGRES_DB: appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 3s
timeout: 3s
retries: 20
start_period: 10s
volumes:
- db-data:/var/lib/postgresql/data
# Требование 5: только по запросу
tools:
<<: *python-service
restart: "no"
command: ["sleep", "900"]
profiles: [tools]
environment:
<<: *common-env
SERVICE: tools
volumes:
db-data:
EOF
cat > compose.override.yaml <<'EOF'
# Требование 2: разработка, подхватывается автоматически
services:
api:
environment:
DEBUG: "1"
LOG_LEVEL: DEBUG
ports:
- "18920:8000"
volumes:
- ./src:/app/src
worker:
environment:
DEBUG: "1"
LOG_LEVEL: DEBUG
volumes:
- ./src:/app/src
EOF
cat > compose.prod.yaml <<'EOF'
# Требование 3: production
services:
api:
environment:
DEBUG: !reset null # удалить, а не переопределить
LOG_LEVEL: WARNING
ports: !reset [] # снять публикацию
volumes: !override [] # никаких bind mount
deploy:
replicas: 2
resources:
limits:
memory: 512M
cpus: "1.0"
worker:
environment:
DEBUG: !reset null
LOG_LEVEL: WARNING
volumes: !override []
deploy:
resources:
limits:
memory: 256M
cpus: "0.5"
db:
deploy:
resources:
limits:
memory: 1G
EOF
cat > compose.test.yaml <<'EOF'
# Требование 4: тесты
services:
api:
environment:
LOG_LEVEL: WARNING
DATABASE_URL: "postgresql://postgres:testpass@db-test:5432/testdb"
ports: !reset []
depends_on: !override
db-test:
condition: service_healthy
worker:
ports: !reset []
depends_on: !override
db-test:
condition: service_healthy
db-test:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
tmpfs:
- /var/lib/postgresql/data:size=256m
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d testdb"]
interval: 2s
retries: 20
start_period: 8s
tests:
image: python:3.13-slim
command: ["sh", "-c", "echo ПРОГОН ТЕСТОВ; exit 0"]
depends_on:
api:
condition: service_started
EOF
cat > check.sh <<'SH'
#!/usr/bin/env bash
set -uo pipefail
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
dump() { # dump <заголовок> <команда compose...>
printf '\n── %s ──\n' "$1"; shift
"$@" config 2>/dev/null | python3 -c "
import sys, yaml
cfg = yaml.safe_load(sys.stdin)
print(' сервисы:', ' '.join(sorted(cfg['services'])))
for name in sorted(cfg['services']):
s = cfg['services'][name]
env = s.get('environment') or {}
ports = [str(p.get('published')) for p in s.get('ports') or []]
vols = [v.get('source') for v in s.get('volumes') or []]
lim = ((s.get('deploy') or {}).get('resources') or {}).get('limits') or {}
rep = (s.get('deploy') or {}).get('replicas')
print(f' {name:<9} DEBUG={env.get(\"DEBUG\", \"—\"):<4} '
f'LOG={env.get(\"LOG_LEVEL\", \"—\"):<8} '
f'ports={ports or \"[]\"} vols={len(vols)} '
f'mem={lim.get(\"memory\", \"—\")} repl={rep or \"—\"}')
"
}
dump "разработка (compose.yaml + override)" docker compose
dump "production" docker compose -f compose.yaml -f compose.prod.yaml
dump "тесты" docker compose -f compose.yaml -f compose.test.yaml
dump "разработка + профиль tools" docker compose --profile tools
get() { # get <файлы...> -- <python-выражение>
local args=()
while [ "$1" != "--" ]; do args+=("$1"); shift; done
shift
docker compose "${args[@]}" config 2>/dev/null | python3 -c "
import sys, yaml
cfg = yaml.safe_load(sys.stdin)
print($1)
"
}
printf '\n═══ Проверки ═══\n'
# Требование 1
if grep -qE '^\s+(ports|volumes):' compose.yaml; then
bad "compose.yaml содержит ports или volumes сервисов"
else
ok "compose.yaml без настроек окружения (требование 1)"
fi
# Требование 2
d_debug="$(get -- "cfg['services']['api']['environment'].get('DEBUG','—')")"
d_ports="$(get -- "len(cfg['services']['api'].get('ports') or [])")"
d_vols="$(get -- "len(cfg['services']['api'].get('volumes') or [])")"
[ "$d_debug" = "1" ] && [ "$d_ports" = "1" ] && [ "$d_vols" = "1" ] \
&& ok "разработка: DEBUG, порт и bind mount на месте (требование 2)" \
|| bad "разработка: DEBUG=$d_debug ports=$d_ports vols=$d_vols"
# Требование 3
p_debug="$(get -f compose.yaml -f compose.prod.yaml -- "cfg['services']['api']['environment'].get('DEBUG','УДАЛЕНА')")"
p_ports="$(get -f compose.yaml -f compose.prod.yaml -- "len(cfg['services']['api'].get('ports') or [])")"
p_vols="$(get -f compose.yaml -f compose.prod.yaml -- "len(cfg['services']['api'].get('volumes') or [])")"
p_mem="$(get -f compose.yaml -f compose.prod.yaml -- "cfg['services']['api']['deploy']['resources']['limits']['memory']")"
p_rep="$(get -f compose.yaml -f compose.prod.yaml -- "cfg['services']['api']['deploy']['replicas']")"
[ "$p_debug" = "УДАЛЕНА" ] && ok "production: DEBUG удалена тегом !reset" || bad "DEBUG=$p_debug"
[ "$p_ports" = "0" ] && ok "production: публикации портов нет" || bad "портов: $p_ports"
[ "$p_vols" = "0" ] && ok "production: bind mount убран тегом !override" || bad "volumes: $p_vols"
[ -n "$p_mem" ] && [ "$p_rep" = "2" ] && ok "production: лимит $p_mem, реплик $p_rep (требование 3)" \
|| bad "лимиты: mem=$p_mem repl=$p_rep"
# Требование 4
t_services="$(get -f compose.yaml -f compose.test.yaml -- "' '.join(sorted(cfg['services']))")"
t_ports="$(get -f compose.yaml -f compose.test.yaml -- "len(cfg['services']['api'].get('ports') or [])")"
t_dep="$(get -f compose.yaml -f compose.test.yaml -- "' '.join(sorted(cfg['services']['api'].get('depends_on') or {}))")"
printf ' сервисы в тестах: %s\n' "$t_services"
printf ' зависимости api: %s\n' "$t_dep"
echo "$t_services" | grep -q tests && echo "$t_services" | grep -q db-test \
&& [ "$t_ports" = "0" ] && [ "$t_dep" = "db-test" ] \
&& ok "тесты: свой сервис и своя база, публикации нет (требование 4)" \
|| bad "тесты: services=[$t_services] ports=$t_ports deps=[$t_dep]"
# Требование 5
default_services="$(get -- "' '.join(sorted(cfg['services']))")"
tools_services="$(get --profile tools -- "' '.join(sorted(cfg['services']))")"
echo "$default_services" | grep -q tools && bad "tools активен без профиля" \
|| ok "tools не запускается по умолчанию (требование 5)"
echo "$tools_services" | grep -q tools && ok "tools включается профилем" \
|| bad "профиль не активировал tools"
# Требование 6
anchors="$(grep -c '^x-' compose.yaml)"
dup_logging="$(grep -c 'max-size' compose.yaml)"
printf ' якорей x-: %s, повторов настроек logging: %s\n' "$anchors" "$dup_logging"
[ "$anchors" -ge 3 ] && [ "$dup_logging" -eq 1 ] \
&& ok "общие настройки заданы один раз (требование 6)" \
|| bad "дублирование осталось"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
exit "$fail"
SH
chmod +x check.sh
./check.sh
echo "КОД: $?"
cd /tmp && rm -rf /tmp/envfiles
Ожидаемый вывод:
── разработка (compose.yaml + override) ──
сервисы: api db worker
api DEBUG=1 LOG=DEBUG ports=['18920'] vols=1 mem=— repl=—
db DEBUG=— LOG=— ports=[] vols=1 mem=— repl=—
worker DEBUG=1 LOG=DEBUG ports=[] vols=1 mem=— repl=—
── production ──
сервисы: api db worker
api DEBUG=— LOG=WARNING ports=[] vols=0 mem=512M repl=2
db DEBUG=— LOG=— ports=[] vols=1 mem=1G repl=—
worker DEBUG=— LOG=WARNING ports=[] vols=0 mem=256M repl=—
── тесты ──
сервисы: api db db-test tests worker
api DEBUG=— LOG=WARNING ports=[] vols=0 mem=— repl=—
db DEBUG=— LOG=— ports=[] vols=1 mem=— repl=—
db-test DEBUG=— LOG=— ports=[] vols=0 mem=— repl=—
tests DEBUG=— LOG=— ports=[] vols=0 mem=— repl=—
worker DEBUG=— LOG=— ports=[] vols=0 mem=— repl=—
── разработка + профиль tools ──
сервисы: api db tools worker
api DEBUG=1 LOG=DEBUG ports=['18920'] vols=1 mem=— repl=—
db DEBUG=— LOG=— ports=[] vols=1 mem=— repl=—
tools DEBUG=— LOG=— ports=[] vols=0 mem=— repl=—
worker DEBUG=1 LOG=DEBUG ports=[] vols=1 mem=— repl=—
═══ Проверки ═══
✓ compose.yaml без настроек окружения (требование 1)
✓ разработка: DEBUG, порт и bind mount на месте (требование 2)
✓ production: DEBUG удалена тегом !reset
✓ production: публикации портов нет
✓ production: bind mount убран тегом !override
✓ production: лимит 512M, реплик 2 (требование 3)
сервисы в тестах: api db db-test tests worker
зависимости api: db-test
✓ тесты: свой сервис и своя база, публикации нет (требование 4)
✓ tools не запускается по умолчанию (требование 5)
✓ tools включается профилем
якорей x-: 3, повторов настроек logging: 1
✓ общие настройки заданы один раз (требование 6)
═══ ИТОГ ═══
все требования выполнены
КОД: 0
Все требования выполнены.
Три решения, определяющие качество.
В тестах depends_on заменяется тегом !override, а не дополняется. Без него api зависел бы и от db, и от db-test: слияние отображений добавило бы новую зависимость, не убрав старую. Стек ждал бы готовности обеих баз, а тестовый прогон стал бы медленнее и хрупче. Вывод зависимости api: db-test — прямое подтверждение, что замена сработала.
Требование 6 проверяется подсчётом повторов, а не наличием якорей. Якоря могли бы присутствовать и при этом дублироваться: ничто не мешает скопировать блок logging в каждый сервис и заодно объявить якорь. Число вхождений max-size равно единице — значит, настройка действительно задана один раз.
Скрипт печатает все четыре конфигурации до проверок. Это не отладочный вывод: таблица позволяет глазом сверить, что db в тестах остался с постоянным volume, а db-test работает на tmpfs. Автоматические проверки закрывают заявленные требования, таблица — всё остальное.
Чего решение не делает. В тестовом сценарии сервис db продолжает запускаться, хотя api на него больше не ссылается: он объявлен в базовом файле и не отменён. Убрать его можно было бы профилем в базовом файле, но это усложнило бы конфигурацию разработки. Не проверяется и то, что конфигурации действительно запускаются — только то, что они верно собираются: config не поднимает container'ы.
Проверка результата
mkdir -p /tmp/mf && cd /tmp/mf
cat > compose.yaml <<'EOF'
name: mf
services:
a:
image: alpine:3.21
command: ["true"]
ports: ["18930:80"]
environment:
X: базовое
EOF
cat > compose.override.yaml <<'EOF'
services:
a:
environment:
Y: добавленное
EOF
docker compose config | grep -A3 environment
docker compose -f compose.yaml config | grep -A2 environment
cd /tmp && rm -rf /tmp/mf
Ожидается, что с автоматикой видны обе переменные, а с явным -f — только X.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Переопределяют ports в override | Ожидают замену | Списки дополняются; нужен !reset |
Ждут, что -f подхватит override | Привыкли к автоматике | -f отменяет автоматический поиск |
| Порядок файлов перепутан | Не задумывались | Побеждает последний |
<<: *anchor для частичной правки вложенного | Ожидают глубокое слияние | YAML сливает поверхностно |
extends и якоря считают равнозначными | Похожая задача | Разные правила слияния |
depends_on дополняется вместо замены | Не знают про !override | Останутся обе зависимости |
| Профильный сервис не запускается | Забыли активировать | --profile или указать имя явно |
docker compose up имя в расчёте на весь стек | Логично предположить | Запустятся только он и зависимости |
| Предсказывают слияние в уме | Кажется очевидным | Проверять docker compose config |
| Копируют весь файл для второго окружения | Кажется проще | Расхождение через месяц; нужны override |
Контрольные вопросы
На понимание:
- Какие типы значений при слиянии заменяются, какие сливаются, какие дополняются?
- Почему нельзя «поменять порт» простым переопределением в override?
- Что делают теги
!resetи!override? - Чем
<<из YAML отличается отextendsпри слиянии вложенных структур? - Как активируется профиль? Назовите три способа.
На применение:
- Как убрать публикацию портов в production-конфигурации?
- Как задать общие настройки логирования один раз для всех сервисов?
- Как сделать так, чтобы production не подхватил локальный override?
На диагностику:
- После добавления override сервис публикует два порта вместо одного. Причина?
- Переменная из якоря пропала после переопределения соседней. Что произошло?
Краткое резюме
compose.override.yamlподхватывается автоматически; флаг-fэту автоматику отменяет.- При нескольких файлах побеждает последний в списке.
- Скаляры заменяются,
commandиentrypointзаменяются целиком. environmentиlabelsсливаются по ключам,volumes— по целевому пути.ports,expose,dnsдополняются — переопределить их простым слиянием нельзя.!resetудаляет значение из предыдущих файлов,!overrideзаменяет целиком.- Сервис без
profilesактивен всегда; с профилем — только при активации. - Профиль активируют
--profile,COMPOSE_PROFILESили явное указание сервиса. - Явное указание сервисов ограничивает запуск ими и их зависимостями.
- Якоря YAML сливают поверхностно: вложенное отображение заменяется целиком.
extendsиспользует правила слияния Compose и работает между файлами.- Результат слияния проверяют командой
docker compose config, а не рассуждением.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Compose: merge and override | https://docs.docker.com/reference/compose-file/merge/ | Правила слияния по типам значений |
| Compose: multiple files | https://docs.docker.com/compose/how-tos/multiple-compose-files/ | -f, порядок, override |
Compose: !reset и !override | https://docs.docker.com/reference/compose-file/merge/#reset-value | Теги сброса и замены |
| Compose: profiles | https://docs.docker.com/compose/how-tos/profiles/ | Активация, взаимодействие с depends_on |
| Compose: extends | https://docs.docker.com/reference/compose-file/services/#extends | Наследование между файлами |
| Compose: include | https://docs.docker.com/reference/compose-file/include/ | Подключение файлов как частей проекта |
| Compose: fragments | https://docs.docker.com/reference/compose-file/fragments/ | Якоря YAML и поля x- |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практический stack
Главное оглавление