Главная/Docker Compose/Урок

9.6. Несколько файлов и profiles

Цели

После этого материала вы сможете:

  • предсказать результат слияния двух Compose-файлов для каждого типа значения;
  • объяснить, почему compose.override.yaml подхватывается сам, а -f его отключает;
  • применять теги !reset и !override, когда обычного слияния недостаточно;
  • включать опциональные сервисы через profiles и понимать их взаимодействие с depends_on;
  • устранять дублирование якорями YAML, полями x- и ключом extends;
  • организовать конфигурации dev, test и prod без копирования файлов.

Предварительные знания

Ключевые термины

ТерминОбъяснение
overrideФайл, дополняющий базовый
mergeСлияние конфигураций по правилам типа значения
!resetТег: сбросить значение, заданное в базовом файле
!overrideТег: заменить целиком вместо слияния
profileМетка, включающая сервис только при явном запросе
x-Префикс пользовательских полей верхнего уровня

Теория

Автоматическое слияние

Если рядом с compose.yaml лежит compose.override.yaml, Compose объединяет их без дополнительных флагов:

text
compose.yaml            базовая конфигурация
compose.override.yaml   локальные дополнения

Приём даёт разделение: базовый файл в репозитории, override — у каждого разработчика свой и в .gitignore.

Важно: флаг -f отменяет автоматику. docker compose -f compose.yaml up подхватит только указанный файл, override будет проигнорирован.

Явный список файлов

bash
docker compose -f compose.yaml -f compose.prod.yaml up -d

Порядок значим: каждый следующий файл накладывается на результат предыдущих. Относительные пути внутри всех файлов разрешаются от каталога первого файла.

Альтернатива флагам — переменная окружения:

bash
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Дополняется

Практические следствия:

yaml
# базовый
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"]Список дополнен
environmentA=1, B=переопределено, C=3Отображение слито
command["python", "app.py"]Список-команда заменён

Первая строка удивляет чаще всего: порты не заменяются, а складываются. Попытка «поменять порт» в override даёт два опубликованных порта вместо одного.

!reset и !override

Обычного слияния не всегда достаточно. Compose предоставляет два YAML-тега.

yaml
# compose.override.yaml
services:
  app:
    ports: !reset []              # убрать все порты из базового файла
    environment:
      DEBUG: !reset null          # убрать переменную
    volumes: !override
      - ./only-this:/data         # заменить список целиком, а не дополнить
ТегДействие
!resetУдаляет значение, заданное в предыдущих файлах
!overrideЗаменяет значение целиком вместо слияния

Именно они решают задачу «убрать публикацию порта в production» и «заменить набор volumes, а не дополнить его».

Без них приходилось разбивать конфигурацию на большее число файлов или дублировать сервисы.

Profiles

yaml
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 upapi и debug-tools
COMPOSE_PROFILES=test,ci docker compose upapi и test-runner
docker compose up debug-toolsapi и debug-tools — имя активирует профиль

Последняя строка важна: явное указание сервиса включает его профиль автоматически.

Взаимодействие с depends_on:

СитуацияПоведение
Активный сервис зависит от сервиса с профилемЗависимость запустится вместе с ним
Сервис с профилем зависит от активногоОбычная зависимость
Оба с разными профилямиОба должны быть активированы

Profiles решают задачи, для которых раньше заводили отдельные файлы: инструменты отладки, разовые задачи, опциональные компоненты.

Устранение дублирования

Три механизма, разные по назначению.

Якоря YAML — работают на уровне парсера, до Compose:

yaml
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 и умеет брать конфигурацию из другого файла:

yaml
services:
  api:
    extends:
      file: common-services.yaml
      service: base-app
    image: myapp
Якоряextends
Между файламиНетДа
Слияние вложенных структурПоверхностноеПо правилам Compose
Наследование depends_on, volumes_fromНе наследуются
ЧитаемостьТребует знания YAMLЯвная

include — подключает целый Compose-файл как часть проекта:

yaml
include:
  - path: ./infra/compose.yaml
  - path: ./services/api/compose.yaml
    env_file: ./services/api/.env

В отличие от -f, включённые файлы не сливаются с текущим, а добавляют свои сервисы. Применяется для крупных систем, разделённых по каталогам команд.

Организация окружений

Рабочая схема без копирования файлов:

text
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-конфигурация не подхватит случайно чью-то локальную настройку.


Внутренний механизм

Порядок обработки

  1. Определяется список файлов: -f, COMPOSE_FILE или поиск в каталоге.
  2. Для каждого выполняется подстановка ${VAR} (урок 9.5).
  3. Обрабатываются include.
  4. Файлы сливаются по порядку, применяются !reset и !override.
  5. Раскрывается extends.
  6. Отбираются сервисы по активным профилям.

Шаг 2 идёт до слияния: каждый файл интерполируется отдельно, и переменные видны всем.

Как проверить результат слияния

bash
docker compose -f a.yaml -f b.yaml config

Эта команда — единственный надёжный способ узнать, что получилось. Слияние достаточно нетривиально, чтобы предсказывать его в уме было ненадёжно (урок 9.1).


Команды и примеры

Правила слияния на практике

bash
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')}\")
"

Ожидаемый вывод:

text
═══ результат слияния ═══
  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Слиты по ключамОтображение
volumesvol-shared заменён по цели, vol-extra добавленСписок с ключом — по целевому пути

Строка ports: ['18901->8000', '18902->8001'] — самая частая неожиданность. Заменить порт простым переопределением нельзя.

!reset и !override

bash
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')}\")
"

Ожидаемый вывод:

text
═══ compose.yaml + compose.prod.yaml (без override) ═══
  ports:       (нет)
  environment: {'B': 'базовое-B', 'PROD_ONLY': 'да'}
  volumes:
      vol-base -> /data

Все три тега сработали: порты убраны полностью, переменная A удалена, список volumes заменён вместо дополнения.

Без !reset и !override того же результата пришлось бы добиваться разбиением на файлы так, чтобы «лишнего» просто не было в базовом.

-f отменяет автоматический override

bash
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'

Ожидаемый вывод:

text
═══ docker compose config (автоматика) ═══
  файлов учтено: 1
  override применён: 1
═══ docker compose -f compose.yaml config ═══
  override применён: 0
  базовая команда:   1

Явный -f вернул базовую конфигурацию — override не подхватился. Это и есть механизм защиты production от локальных настроек разработчика.

Порядок файлов значим

bash
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

Ожидаемый вывод:

text
═══ a затем b ═══
  WHO = файл-B
═══ b затем a ═══
  WHO = файл-A

Побеждает последний файл в списке. Правило простое, но забывается — особенно когда список задан через COMPOSE_FILE.

Profiles

bash
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"

Ожидаемый вывод:

text
═══ какие сервисы активны ═══
  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 включился по зависимости.

Явное указание сервиса включает профиль

bash
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

Ожидаемый вывод:

text
═══ обычный up ═══
  api
  db
═══ up с именем профильного сервиса ═══
  debug
═══ разовый запуск профильного сервиса ═══
  тесты выполнены

Второй блок показывает деталь, которую легко упустить: up debug запустил только debug, а не весь стек плюс debug. Явное указание сервисов ограничивает запуск ими и их зависимостями.

Якоря и поля x-

bash
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\")}')
"

Ожидаемый вывод:

text
═══ что получилось ═══
  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-файлов, где отображения сливаются по ключам.

Правило: якоря хорошо переносят целые блоки, но плохо — частичные изменения вложенных структур.

Обходной путь — якорь на само отображение:

bash
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'])
"

Ожидаемый вывод:

text
  environment: {'TZ': 'UTC', 'LOG_LEVEL': 'DEBUG', 'QUEUE': 'tasks'}

TZ сохранена: << применён к самому отображению environment, а не к сервису.

extends между файлами

bash
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'))
"

Ожидаемый вывод:

text
  image:       python:3.13-slim
  restart:     unless-stopped
  environment: {'TZ': 'UTC', 'LOG_LEVEL': 'DEBUG', 'SERVICE': 'api'}
  healthcheck: ['CMD', 'true']

Здесь TZ сохранилась — в отличие от якорей. extends использует правила слияния Compose, а не YAML.

Это ключевое различие между двумя механизмами и повод предпочитать extends для конфигураций сервисов.

Три окружения без дублирования

bash
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

Ожидаемый вывод:

text
═══ три окружения из одного набора файлов ═══
  разработка (по умолчанию)    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, публикация портов снята, добавлены лимиты; в тестах появился дополнительный сервис.

Ни один файл не дублирует определение сервисов — каждый содержит только различия.


Практическое упражнение

Задание. Организуйте конфигурацию для четырёх сценариев из общей базы.

Требования:

  1. compose.yaml содержит определения сервисов и не содержит настроек, специфичных для окружения.
  2. Разработка подхватывается автоматически: bind mount кода, отладочный порт, DEBUG=1.
  3. Production: порты не публикуются, DEBUG удалена, заданы лимиты, две реплики.
  4. Тесты: добавляется сервис прогона, публикация снята, база отдельная.
  5. Профиль tools включает служебный container, не запускающийся по умолчанию.
  6. Дублирование устранено: общие настройки заданы один раз.

Скрипт проверки печатает итоговую конфигурацию для всех четырёх сценариев и подтверждает каждое требование.

Подсказки

Подсказка 1

Для требования 3 понадобятся теги !reset: обычное переопределение портов их не уберёт.

Подсказка 2

Требование 6 удобнее выполнить якорем на отображение, а не на весь сервис.

Подсказка 3

Проверять результат надо через docker compose config, а не запуском.

Решение

Показать решение
bash
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

Ожидаемый вывод:

text
── разработка (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'ы.

Проверка результата

bash
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

Контрольные вопросы

На понимание:

  1. Какие типы значений при слиянии заменяются, какие сливаются, какие дополняются?
  2. Почему нельзя «поменять порт» простым переопределением в override?
  3. Что делают теги !reset и !override?
  4. Чем << из YAML отличается от extends при слиянии вложенных структур?
  5. Как активируется профиль? Назовите три способа.

На применение:

  1. Как убрать публикацию портов в production-конфигурации?
  2. Как задать общие настройки логирования один раз для всех сервисов?
  3. Как сделать так, чтобы production не подхватил локальный override?

На диагностику:

  1. После добавления override сервис публикует два порта вместо одного. Причина?
  2. Переменная из якоря пропала после переопределения соседней. Что произошло?

Краткое резюме

  1. compose.override.yaml подхватывается автоматически; флаг -f эту автоматику отменяет.
  2. При нескольких файлах побеждает последний в списке.
  3. Скаляры заменяются, command и entrypoint заменяются целиком.
  4. environment и labels сливаются по ключам, volumes — по целевому пути.
  5. ports, expose, dns дополняются — переопределить их простым слиянием нельзя.
  6. !reset удаляет значение из предыдущих файлов, !override заменяет целиком.
  7. Сервис без profiles активен всегда; с профилем — только при активации.
  8. Профиль активируют --profile, COMPOSE_PROFILES или явное указание сервиса.
  9. Явное указание сервисов ограничивает запуск ими и их зависимостями.
  10. Якоря YAML сливают поверхностно: вложенное отображение заменяется целиком.
  11. extends использует правила слияния Compose и работает между файлами.
  12. Результат слияния проверяют командой docker compose config, а не рассуждением.

Официальные источники

ИсточникСсылкаЧто подтверждает
Compose: merge and overridehttps://docs.docker.com/reference/compose-file/merge/Правила слияния по типам значений
Compose: multiple fileshttps://docs.docker.com/compose/how-tos/multiple-compose-files/-f, порядок, override
Compose: !reset и !overridehttps://docs.docker.com/reference/compose-file/merge/#reset-valueТеги сброса и замены
Compose: profileshttps://docs.docker.com/compose/how-tos/profiles/Активация, взаимодействие с depends_on
Compose: extendshttps://docs.docker.com/reference/compose-file/services/#extendsНаследование между файлами
Compose: includehttps://docs.docker.com/reference/compose-file/include/Подключение файлов как частей проекта
Compose: fragmentshttps://docs.docker.com/reference/compose-file/fragments/Якоря YAML и поля x-

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Практический stack
Главное оглавление

Markdown на GitHub ↗