Docker Compose: кілька файлів і правильне злиття
Як Compose сам завантажує compose.override.yaml, у якому порядку зливаються файли, чому ports залишає порт відкритим і як розділити dev та prod через include.
Що Compose робить із кількома файлами
Docker Compose може створити один проєкт із кількох файлів. Він читає їх у переданому порядку та об’єднує в одну модель, тому пізніший файл має перевагу для кожного конфліктного значення. У командному рядку для цього використовують два механізми: файл перевизначень, який Compose завантажує автоматично, і прапорець -f, який ви передаєте вручну. Третій механізм міститься безпосередньо у файлі — елемент include. Він працює інакше, ніж обидва попередні.
Злиття не є простим перезаписуванням. Відображення об’єднуються за ключами, послідовності доповнюються, а невеликий набір полів замінюється повністю. Саме ця відмінність спричиняє несподівані результати, а список ports найчастіше вводить в оману.
Усе нижче стосується Compose v2 і плагіна docker compose, а не старого скрипту docker-compose. Виконайте docker compose version для перевірки. Якщо ви ще не створили файл Compose, почніть із посібника з основ Docker Compose, а потім поверніться сюди.
Файл override, який Compose завантажує автоматично
Виконайте docker compose up без прапорця -f, і Compose шукатиме у робочому каталозі, а потім у батьківських каталогах, файл compose.yaml або docker-compose.yaml. Якщо файл override розташований поруч із базовим файлом, Compose самостійно завантажить його другим.
ls compose.yaml compose.override.yaml
docker compose up -dЯкщо присутні обидва файли, це еквівалентно їхньому ручному зазначенню.
docker compose -f compose.yaml -f compose.override.yaml up -dCompose розпізнає імена compose.override.yaml, compose.override.yml, а також старіші docker-compose.override.yml і docker-compose.override.yaml. Файл з іншим іменем, наприклад compose.dev.yaml, завантажується лише тоді, коли ви зазначаєте його за допомогою -f.
Щойно ви передаєте хоча б один -f, автоматичне завантаження припиняється. docker compose -f compose.yaml up читає лише цей файл і ігнорує override. Саме на цій властивості побудовано описаний далі в цьому посібнику підхід із dev і prod.
На сервері це може мати негативні наслідки. Файл override, залишений у каталозі розгортання, завантажується кожною командою docker compose без параметрів, виконаною з цього каталогу, зокрема командою, яку запускає cron. Через це production stack може монтувати через bind mount каталог із вихідним кодом, який ніхто не планував розгортати. Після кожного розгортання виконуйте docker compose config і переглядайте отриманий результат. Якщо розгортання виконується без участі оператора, така перевірка корисна лише тоді, коли хтось повідомляє про помилку. Для цього потрібен push-канал, наприклад self-hosted сервер ntfy, куди cron або модуль systemd OnFailure може надсилати повідомлення.
Порядок із -f і визначення відносних шляхів
Compose формує конфігурацію в порядку, у якому ви вказуєте файли. Наступні файли перевизначають і доповнюють попередні. У напрямку зліва направо перемагає останній файл.
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -dДля кожної команди в цьому проєкті потрібен однаковий список файлів. Якщо запустити up із двома файлами, а logs — з одним, ви працюватимете з іншою об’єднаною моделлю. Це швидкий спосіб отримати сервіс, якого, за повідомленням Compose, не існує. Ризик зростає для стеку, оновлення якого виконуються одноразовими командами, наприклад для кроку міграції бази даних у self-hosted системі підтримки Chatwoot, де docker compose run, виконана з неправильним списком файлів, непомітно працює з іншою моделлю, ніж та, яку вже використовують ваші сервіси. Задайте список один раз за допомогою змінної середовища COMPOSE_FILE.
export COMPOSE_FILE=compose.yaml:compose.prod.yaml
docker compose config
docker compose up -dУ Linux роздільником є :, а COMPOSE_PATH_SEPARATOR змінює його. COMPOSE_FILE також можна зберігати у файлі проєкту .env. Тоді цей параметр буде частиною checkout, а не історії командної оболонки. Значення, явно задане в командному рядку, має пріоритет над змінною середовища.
Тепер правило, яке часто порушує bind mounts. Коли ви використовуєте кілька файлів із -f, усі відносні шляхи в усіх цих файлах визначаються відносно каталогу першого файлу, а не файлу, у якому вони вказані. Додайте ./data:/var/lib/postgresql/data у deploy/prod/compose.prod.yaml, і Compose все одно шукатиме ./data поруч із базовим файлом. Тоді Docker створить порожній каталог за неправильним шляхом, а контейнер запуститься без даних у ньому. Це схоже на втрату даних, але нею не є. Передайте --project-directory, щоб указати базовий шлях вручну, або використовуйте include, який визначає шлях для кожного файлу відносно його власного каталогу.
Ім’я проєкту визначається з того самого базового каталогу, тому зміна першого файлу може перейменувати проєкт. Перейменований проєкт отримує нові імена контейнерів і томів, а старий том залишається на диску під старим ім’ям. Задайте ім’я явно за допомогою параметра верхнього рівня name: у базовому файлі.
name: myappЯкі поля об’єднуються, а які замінюються
Compose об’єднує поля за типом значення, а не за назвою поля.
- Поля з одним значенням замінюються.
image,command,entrypointіmem_limitбезумовно отримують пізніше значення. Не можна додати один аргумент доcommand, оскільки override повністю переписує весь рядок. - Mappings об’єднуються ключ за ключем.
environment,labels,volumesіdevicesзберігають усі ключі з обох файлів, а пізніший файл має пріоритет для ключів, наявних в обох файлах. Дляenvironmentіlabelsключем є ім’я змінної або label. Дляvolumesіdevicesключем є шлях у контейнері. - Sequences додаються.
dns,dns_search,expose,tmpfsіexternal_linksконкатенуються. Якщо base міститьexpose: ["3000"], а override —["4000", "5000"], результатом буде["3000", "4000", "5000"].
Чотири sequences мають ключ ідентифікації, тому записи з однаковим ключем об’єднуються, а не додаються. volumes, secrets і configs порівнюються за target. ports порівнюється за комбінацією ip, target, published і protocol.
Уважно прочитайте правило ports ще раз, оскільки саме тут часто виникає помилка. Два записи про порти є одним записом лише тоді, коли всі чотири складові збігаються. Якщо змінити будь-яку з них, Compose сприймає запис як другий, незалежний порт і зберігає обидва.
Чому порт усе ще опублікований після override
Базовий файл публікує сервіс на всіх інтерфейсах:
services:
web:
image: nginx:1.27
ports:
- "8080:80"Override прив’язує його лише до localhost, оскільки перед ним працюватиме reverse proxy:
services:
web:
ports:
- "127.0.0.1:8080:80"Перевірте результат, перш ніж вважати, що зміни застосовано.
docker compose -f compose.yaml -f compose.prod.yaml configУ виводі є обидва записи. Частина ip відрізняється: 0.0.0.0 проти 127.0.0.1. Тому під час злиття це два різні порти, а публічне прив’язування, яке ви намагалися видалити, усе ще присутнє в моделі. Для Docker це важливіше, ніж для інших систем, оскільки опублікований порт додається до iptables раніше за правила вашого firewall. Цей механізм описано в матеріалі чому опубліковані порти Docker обходять ufw.
Є два способи виправити це. Явний спосіб — тег !override. Він замінює весь атрибут і пропускає правила злиття:
services:
web:
ports: !override
- "127.0.0.1:8080:80"Для !override потрібен Compose v2.24.4 або новіший. Портативний спосіб не потребує тегів: повністю приберіть ports з базового файла й оголошуйте його лише у файлах для конкретного середовища. Немає чого зливати — немає чого випадково опублікувати. Саме цей підхід використано в наведеному нижче прикладі.
Видалення значення, заданого в базовому файлі
!reset видаляє атрибут, повертаючи його до значення за замовчуванням або до null. Команда приймає значення та ігнорує його, тому вкажіть будь-яке коректне порожнє значення.
services:
web:
ports: !reset []
environment:
DEBUG: !reset null!reset потребує Compose версії 2.24 або новішої. Використовуйте її, коли базовий файл не можна редагувати, наприклад якщо це фрагмент постачальника, який ви підключаєте. Опублікований upstream stack — саме такий випадок: Compose-файл для self-hosted робочого простору AFFiNE оголошує чотири контейнери, які ви не створювали, а !reset дає змогу очистити один атрибут одного з них без форку файлу та без необхідності надалі відстежувати зміни в ньому.
include для стеків, зібраних із частин
include підключає інший Compose-застосунок до вашої моделі. Це елемент верхнього рівня, а не прапорець.
include:
- path: ../commons/compose.yamlКожен шлях у include завантажується як окрема модель Compose-застосунку з власним каталогом проєкту. Тому відносні шляхи в цьому файлі визначаються відносно каталогу самого файлу. У цьому полягає фактична відмінність від -f і причина, чому include є правильним інструментом, коли фрагмент розташований в іншій папці або іншому репозиторії. Так зазвичай виглядає стек постачальника, який ви не писали: багатосервісний Compose-файл для self-hosted інсталяції Authentik SSO може зберігатися у власному каталозі зі своїми відносними шляхами, тоді як ваш файл містить лише ваші сервіси.
Розгорнута форма підтримує додаткові параметри.
include:
- path:
- ../monitoring/compose.yaml
- ../monitoring/compose.vps.yaml
project_directory: ../monitoring
env_file: ../monitoring/.envpath приймає список, а ці файли об’єднуються за стандартними правилами до додавання результату до вашої моделі. project_directory задає базовий шлях для визначення відносних шляхів у включеному файлі. env_file надає включеному файлу власні змінні для інтерполяції. Це не дає спільному фрагменту непомітно читати .env вашого проєкту. Для include потрібен Compose версії 2.20.0 або новішої. Ті самі параметри підходять для окремого контейнерного додатка до вже запущеного стека, наприклад Halcyon, який стилізує бібліотеку Jellyfin під відеопрокат 90-х років: його файл зберігає власний тег образу та власний env_file, тому його оновлення не потребує змін у файлі вашого медіастека.
Якщо у вашому файлі та включеному файлі є ресурси з однаковими іменами, Compose повідомляє про помилку, а не об’єднує їх непомітно. Це зроблено навмисно. Щоб змінити оголошення у включеному файлі, внесіть зміну в compose.override.yaml: перевизначення застосовується до зібраної моделі, тому може змінювати включені ресурси без конфлікту з ними. Це особливо корисно для стека, вихідний файл якого постачальник переписує під час кожного випуску, наприклад для багатоконтейнерних фотосерверів, розглянутих у PhotoPrism проти Immich. У такому разі прив’язку до localhost або додатковий том слід задавати у вашому файлі перевизначень, а не у файлі, який буде замінено під час наступного оновлення.
Коротко: include об’єднує окремі застосунки, а -f додає шари конфігурації до одного застосунку.
Розділення dev і prod на одному VPS
Ось повний шаблон у трьох файлах. Базовий файл описує спільні параметри та взагалі не публікує порти.
name: myapp
services:
app:
image: ghcr.io/example/app:1.4.2
environment:
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
LOG_LEVEL: info
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
db_data:Умова depends_on змушує застосунок чекати на базу даних, яка відповідає на перевірку, а не лише на контейнер, що існує. Це описано в розділі healthchecks і умови depends_on. Значення POSTGRES_PASSWORD підставляється з файлу проекту .env, який ніколи не додають до git. Безпечніші варіанти наведено в розділі env-файли та Compose secrets.
Далі — compose.override.yaml, який Compose завантажує автоматично. Це файл розробника.
services:
app:
build: .
command: npm run dev
environment:
LOG_LEVEL: debug
ports:
- "3000:3000"
volumes:
- ./src:/app/src
db:
ports:
- "127.0.0.1:5432:5432"На ноутбуці команда без параметрів docker compose up об’єднує ці два файли. command замінює значення за замовчуванням для image, оскільки це одиночне значення. LOG_LEVEL замінює info, оскільки environment об’єднує за ключем. Bind mount і два опубліковані порти є простими доповненнями. Порт бази даних прив’язано лише до localhost, тому ноутбук у спільній мережі не відкриває PostgreSQL для інших пристроїв.
Останній файл — compose.prod.yaml. Його ім’я не належить до файлів, які Compose шукає автоматично, тому він ніколи не завантажується випадково.
services:
app:
ports:
- "127.0.0.1:8000:3000"
deploy:
resources:
limits:
memory: 512MНа VPS потрібно вказати обидва файли. Саме явне зазначення файлів виключає override.
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose -f compose.yaml -f compose.prod.yaml psps має показати обидва сервіси як запущені, а db — значення (healthy). Оскільки ви передали -f, файл compose.override.yaml не читався. Тому команда dev, bind mount із вихідним кодом і публічний порт 3000 не можуть потрапити в production, навіть якщо цей файл лежить у тому самому каталозі. Порт 8000 доступний лише через localhost і готовий для проксі. Коли додасте другий сервіс, дивіться запуск кількох застосунків за Traefik.
Встановіть COMPOSE_FILE=compose.yaml:compose.prod.yaml у серверному .env, після чого решта команд знову матиме звичайний вигляд docker compose logs -f app.
Для стека з одним сервісом діє та сама схема, оскільки self-hosted трекер тренувань openGym має відповідати через TLS за проксі ще до реєстрації першого passkey. Базовий файл без ports не дає випадковому публічному прив’язанню порту випередити проксі.
Прочитайте об’єднану модель перед розгортанням
docker compose config виводить повністю об’єднану модель із повністю підставленими значеннями. Це не попередній перегляд. Це точний вхідний файл, з яким працюватиме Compose. Тому якщо результат відрізняється від очікуваного, правильним є саме результат.
docker compose -f compose.yaml -f compose.prod.yaml config
docker compose -f compose.yaml -f compose.prod.yaml config --no-interpolate
docker compose -f compose.yaml -f compose.prod.yaml config --services--no-interpolate залишає ${VAR} без розгортання. Використовуйте цю команду перед вставленням результату в будь-яке місце, оскільки звичайний config виводить усі розкриті секрети у відкритому тексті. --services виводить лише назви сервісів. Це швидкий спосіб перевірити, що include підключив потрібні залежності.
Режими відмови та їхні ознаки
no configuration file provided: not found. Compose не знайшов файлів для читання. Ви перебуваєте не в каталозі проєкту або COMPOSE_FILE вказує шлях, якого не існує. Compose шукає базовий файл за замовчуванням у батьківських каталогах, але не шукає файл, який ви вказали явно, в інших каталогах.
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string. Інтерполяція виконується на основі файлу проєкту .env і середовища shell, а каталогом проєкту в цьому випадку є каталог першого файлу -f. Якщо розгортати проєкт з іншого каталогу, ніж каталог, у якому міститься .env, ви отримаєте це попередження, а потім базу даних, яка відхиляє всі підключення.
Зміни у файлі override не відображаються в docker compose config. Або ви передали -f, що вимикає автоматичне завантаження override-файлу, або Compose знайшов compose.yaml у батьківському каталозі, а ваш override-файл розташований не поруч із ним. Запуск docker compose config без інших аргументів покаже, яку модель Compose фактично створює.
Bind mount порожній, і Docker створив каталог, якого ви не вказували. Відносний шлях було обчислено відносно каталогу першого файлу. Виправте шлях, передайте --project-directory або перемістіть цей фрагмент після include.
Контейнери запускаються з новими іменами, а volume виглядає порожнім. Назва проєкту змінилася, оскільки вона визначається каталогом першого файлу. Додайте name: верхнього рівня до базового файлу, щоб назви більше не змінювалися. Старий volume все ще існує зі старим префіксом, і docker volume ls покаже його.
Порт, який ви видалили в override-файлі, усе ще відкритий. Під час злиття ports додав запис замість його заміни. Перевірте це за допомогою docker compose config, а потім використайте !override або перемістіть ports з базового файлу.
FAQ
Чи завантажує Compose файл compose.override.yaml автоматично?
Так, коли ви запускаєте docker compose без прапорця -f. Compose шукає в робочому каталозі та його батьківських каталогах файли compose.yaml або docker-compose.yaml. Якщо файл override міститься поруч із ним, Compose завантажує цей файл другим. Розпізнаються назви compose.override.yaml, compose.override.yml, docker-compose.override.yml і docker-compose.override.yaml. Передавання будь-якого -f вимикає цю поведінку, тому docker compose -f compose.yaml up читає лише один файл.
У якому порядку об’єднуються кілька файлів -f?
Зліва направо. Compose формує конфігурацію в порядку, у якому ви вказуєте файли. Кожен наступний файл змінює та доповнює попередні, тому в разі конфлікту перевагу має останній файл у командному рядку. Для кожної команди в цьому проєкті потрібно використовувати той самий список файлів. Саме для цього призначено COMPOSE_FILE=compose.yaml:compose.prod.yaml.
Чому порт усе ще опублікований після перевизначення?
Тому що записи ports ідентифікуються повним набором ip, target, published і protocol. Перевизначення 127.0.0.1:8080:80 для базового значення 8080:80 відрізняється частиною ip. Тому Compose сприймає його як другий порт і зберігає обидва записи. Виконайте docker compose config, щоб побачити ці два записи. Використовуйте ports: !override у Compose v2.24.4 або новішій версії. Або не додавайте ports до базового файлу, щоб не було запису, з яким потрібно виконувати об’єднання.
У чому різниця між include і -f?
-f накладає кілька файлів на один застосунок. Відносні шляхи в кожному файлі визначаються відносно каталогу першого файлу. include підключає окремий Compose-застосунок. Кожен підключений файл зберігає власний каталог проєкту, тому його відносні шляхи визначаються відносно цього каталогу. Використовуйте -f для шарів середовища власного стека, а include — для фрагмента, який підтримується в іншому місці. Для include потрібен Compose v2.20.0 або новіша версія.
Як видалити значення, задане в базовому файлі?
Використовуйте тег !reset у Compose v2.24 або новішій версії. У файлі, що перевизначає базовий, вкажіть ports: !reset [] або MY_VAR: !reset null. Тоді атрибут повернеться до значення за замовчуванням або до null. Значення, яке ви передаєте тегу, є обов’язковим, але ігнорується. Якщо потрібно замінити атрибут, а не очистити його, використовуйте !override. Для цього потрібен Compose v2.24.4 або новіша версія.