Docker Compose مع عدة ملفات: الدمج وملف التجاوز
تعرّف إلى تحميل compose.override.yaml تلقائيًا، وترتيب دمج الملفات، وفخ ports الذي يُبقي المنفذ مفتوحًا، واستخدام include لفصل التطوير عن الإنتاج.
ما الذي يفعله Compose عند استخدام أكثر من ملف
يمكن لـ Docker Compose إنشاء مشروع واحد من عدة ملفات. يقرأها بالترتيب الذي يتلقاها به، ويدمجها في نموذج واحد. لذلك تكون قيمة الملف اللاحق هي المعتمدة عند تعارض أي قيمة. توجد آليتان لتنفيذ ذلك من سطر الأوامر: ملف تجاوز يحمله Compose تلقائيًا، والعَلَمة -f التي تمررها يدويًا. وتوجد آلية ثالثة داخل الملف نفسه، وهي العنصر include، لكنها تعمل بطريقة مختلفة عن الآليتين السابقتين.
لا تتم عملية الدمج باستبدال بسيط. تُدمج الخرائط مفتاحًا تلو الآخر، وتُضاف العناصر في التسلسلات، بينما تُستبدل مجموعة صغيرة من الحقول بالكامل. ينشأ الالتباس من هذا الاختلاف، وتُعد قائمة ports أكثر ما يسبب المشكلات لمعظم المستخدمين.
يفترض كل ما يلي استخدام Compose v2، أي المكوّن الإضافي docker compose بدلًا من السكربت القديم docker-compose. شغّل docker compose version للتحقق. إذا لم تكتب ملف Compose بعد، فابدأ بـ دليل أساسيات Docker Compose ثم عُد إلى هنا.
ملف التجاوز الذي يحمّله Compose تلقائيًا
شغّل docker compose up من دون العلامة -f، وسيبحث Compose في دليل العمل، ثم في الأدلة الأصلية، عن compose.yaml أو docker-compose.yaml. إذا كان ملف التجاوز موجودًا بجانب الملف الأساسي، فسيحمّله Compose بعده تلقائيًا.
ls compose.yaml compose.override.yaml
docker compose up -dعند وجود الملفين، تكون النتيجة مماثلة لكتابتهما يدويًا.
docker compose -f compose.yaml -f compose.override.yaml up -dالأسماء التي يتعرف عليها Compose هي 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 ذلك الملف وحده ويتجاهل ملف التجاوز. وتعتمد هذه الخاصية عليها لاحقًا في هذا الدليل بنية dev وprod.
لهذا الأمر جانبان على الخادم. يُحمّل ملف التجاوز الموجود في دليل النشر مع كل أمر docker compose يُشغّل من ذلك الدليل، بما في ذلك الأمر الذي تشغّله مهمة cron. وهكذا قد تنتهي حزمة تشغيل الإنتاج بتركيب دليل مصدر باستخدام bind mount، رغم أن أحدًا لم يقصد تضمينه في الإصدار. شغّل docker compose config بعد كل عملية نشر، واقرأ الناتج.
الترتيب باستخدام -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 إنها غير موجودة. عيّن القائمة مرة واحدة باستخدام متغير البيئة COMPOSE_FILE.
export COMPOSE_FILE=compose.yaml:compose.prod.yaml
docker compose config
docker compose up -dالفاصل هو : في Linux، ويغيّره COMPOSE_PATH_SEPARATOR. ويمكن أن يوجد COMPOSE_FILE أيضًا في ملف المشروع .env، وبذلك يصبح جزءًا من نسخة المستودع بدلًا من سجل أوامر الصدفة. تتغلب أي قيمة تعيّنها صراحةً في سطر الأوامر على متغير البيئة.
إليك القاعدة التي تسبب مشكلات في عمليات الربط. عند استخدام ملفات متعددة مع -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، لأن التجاوز يعيد كتابة السطر بأكمله. - تُدمج الخرائط حسب المفتاح. تحتفظ
environmentوlabelsوvolumesوdevicesبكل مفتاح من كلا الملفين، ويكون الملف اللاحق هو الفائز عند وجود المفتاح في الملفين. بالنسبة إلىenvironmentوlabels، يكون المفتاح هو اسم المتغير أو التصنيف. وبالنسبة إلىvolumesوdevices، يكون المفتاح هو مسار الحاوية. - تُلحَق المتتاليات. تُدمج
dnsوdns_searchوexposeوtmpfsوexternal_linksبالتسلسل. إذا احتوى الملف الأساسي علىexpose: ["3000"]واحتوى التجاوز على["4000", "5000"]، تكون النتيجة["3000", "4000", "5000"].
تحتوي أربع متتاليات على مفتاح هوية، لذلك تُدمج الإدخالات المتطابقة في هذا المفتاح بدلاً من إلحاقها. تطابق volumes وsecrets وconfigs الإدخالات استنادًا إلى target. أما ports فتطابقها استنادًا إلى مجموعة ip وtarget وpublished وprotocol.
اقرأ قاعدة ports مرتين، لأنها موضع الخطأ الشائع. لا يكون إدخالا المنفذ نفس الإدخال إلا عندما تتطابق الأجزاء الأربعة كلها. إذا غيّرت أي جزء منها، تعتبر Compose الإدخال منفذًا ثانيًا غير مرتبط، ولذلك تحتفظ بكلا الإدخالين.
لماذا يظل منفذك منشورًا بعد التجاوز
ملف أساسي ينشر خدمة على كل الواجهات:
services:
web:
image: nginx:1.27
ports:
- "8080:80"تجاوز لربطها بـ localhost فقط، لأن وكيلًا عكسيًا سيعمل أمامها:
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 قبل قواعد جدار الحماية. يشرح سبب تجاوز منافذ Docker المنشورة لـ ufw هذه الآلية.
هناك حلان. الحل الصريح هو الوسم !override، الذي يستبدل السمة كاملةً ويتجاوز قواعد الدمج:
services:
web:
ports: !override
- "127.0.0.1:8080:80"يتطلب !override الإصدار 2.24.4 من Compose أو إصدارًا أحدث. أما الحل المحمول فلا يحتاج إلى أي وسم: أبقِ ports خارج الملف الأساسي تمامًا، وصرّح عنه في الملفات الخاصة بكل بيئة فقط. عندما لا يوجد شيء لدمجه، لا يوجد شيء يمكن تسريبه. هذا هو النمط المستخدم في المثال التطبيقي أدناه.
حذف قيمة يحددها الملف الأساسي
يزيل !reset إحدى السمات، ويعيدها إلى قيمتها الافتراضية أو إلى القيمة الفارغة. يتطلب قيمة ويتجاهلها، لذلك اكتب قيمة صالحة وفارغة.
services:
web:
ports: !reset []
environment:
DEBUG: !reset nullيتطلب !reset الإصدار 2.24 من Compose أو إصدارًا أحدث. استخدمه عندما لا يكون الملف الأساسي قابلًا للتعديل بواسطتك، مثل جزء يوفّره أحد المورّدين وتُدرجه.
include، للمكدسات المجمّعة من أجزاء
تسحب include تطبيق Compose آخر إلى نموذجك. وهي عنصر على المستوى الأعلى، وليست علامة.
include:
- path: ../commons/compose.yamlيُحمَّل كل مسار في include كنموذج تطبيق Compose مستقل، مع دليل مشروع خاص به. لذلك تُحلّ المسارات النسبية داخل ذلك الملف بالنسبة إلى دليله الخاص. وهذا هو الفرق الفعلي عن -f، والسبب في أن include هي الأداة المناسبة عندما يكون الجزء في مجلد آخر أو في مستودع آخر.
يقبل الشكل المطوّل خيارات فرعية.
include:
- path:
- ../monitoring/compose.yaml
- ../monitoring/compose.vps.yaml
project_directory: ../monitoring
env_file: ../monitoring/.envتقبل path قائمة، وتُدمج هذه الملفات معًا وفق القواعد المعتادة قبل أن تنضم النتيجة إلى نموذجك. تحدد project_directory المسار الأساسي المستخدم لحلّ المسارات النسبية في الملف المضمَّن. تمنح env_file الملف المضمَّن متغيراته الخاصة للاستبدال، ما يمنع الجزء المشترك من قراءة .env الخاص بمشروعك دون قصد. تتطلب include الإصدار Compose v2.20.0 أو أحدث.
يُبلَّغ عن أسماء الموارد المكررة بين ملفك والملف المضمَّن كخطأ بدل دمجها بصمت، وهذا مقصود. لتغيير شيء يعلنه الملف المضمَّن، ضع التغيير في compose.override.yaml. يُطبَّق التجاوز على النموذج المجمّع، لذلك يمكنه تعديل الموارد المضمَّنة دون حدوث تعارض معها.
باختصار: تنشئ include تطبيقات منفصلة معًا، بينما تضيف -f طبقات من الإعدادات إلى تطبيق واحد.
فصل بيئة التطوير عن بيئة الإنتاج على VPS واحد
إليك النمط الكامل في 3 ملفات. يعرّف الملف الأساسي ما ينطبق في كل مكان، ولا ينشر أي منافذ.
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 هو ما يجعل التطبيق ينتظر قاعدة بيانات تستجيب، بدلًا من انتظار حاوية موجودة فقط. يوضّح ذلك فحوصات الصحة وشروط depends_on. تُستبدل قيمة POSTGRES_PASSWORD من ملف المشروع .env، الذي لا ينبغي أن يكون ضمن git. راجع ملفات البيئة وأسرار Compose للتعرّف على البدائل الأكثر أمانًا.
بعد ذلك، 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 القيمة الافتراضية للصورة لأنه عنصر ذو قيمة واحدة. ويستبدل LOG_LEVEL قيمة info لأن environment يدمج العناصر حسب المفتاح. إضافة الربط والمنافذ المنشورة الاثنين تتم فقط بإضافة عناصر جديدة. ويرتبط منفذ قاعدة البيانات بـ localhost، حتى لا يتيح الحاسوب المحمول PostgreSQL للأجهزة الموجودة على الشبكة المشتركة.
أخيرًا، compose.prod.yaml. لا يبحث Compose عن هذا الاسم، لذلك لا يُحمّل بالخطأ.
services:
app:
ports:
- "127.0.0.1:8000:3000"
deploy:
resources:
limits:
memory: 512Mعلى VPS، تسمّي الملفين معًا، وهذا التحديد هو ما يستبعد ملف التجاوز بالضبط.
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 psينبغي أن يعرض ps الخدمتين قيد التشغيل، مع إظهار db للقيمة (healthy). وبما أنك مرّرت -f، لم تتم قراءة compose.override.yaml. لذلك لا يمكن لأمر التطوير، أو ربط المصدر، أو المنفذ العام 3000 الوصول إلى بيئة الإنتاج، رغم وجود الملف في الدليل نفسه. المنفذ 8000 مرتبط بـ localhost فقط، وهو جاهز لوسيط وكيل. راجع تشغيل عدة تطبيقات خلف Traefik عند إضافة الخدمة الثانية.
اضبط COMPOSE_FILE=compose.yaml:compose.prod.yaml في .env على الخادم، وستعود بقية أوامرك إلى استخدام docker compose logs -f app العادي.
اقرأ النموذج المدمج قبل النشر
يطبع 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 إلى ظهور هذا التحذير، ثم إلى قاعدة بيانات ترفض كل اتصال.
لا يظهر التعديل الذي أجريته على ملف التجاوز في docker compose config. إما أنك مررت -f، ما يؤدي إلى تعطيل التحميل التلقائي لملف التجاوز، أو أن Compose عثر على compose.yaml في دليل أصلي، وملف التجاوز ليس بجواره. يخبرك تشغيل docker compose config دون أي وسيطات أخرى بالنموذج الذي ينشئه Compose فعليًا.
يكون الربط mount فارغًا، وينشئ Docker دليلًا لم تطلبه. حُل المسار النسبي بالاعتماد على دليل الملف الأول. صحح المسار، أو مرر --project-directory، أو انقل المقطع إلى ما بعد include.
تعود الحاويات بأسماء جديدة، ويبدو أحد المجلدات الدائمة فارغًا. تغيّر اسم المشروع، لأن اسم المشروع يتبع دليل الملف الأول. أضف name: على المستوى الأعلى إلى الملف الأساسي، وسيتوقف تغيّر الأسماء. لا يزال المجلد الدائم القديم موجودًا تحت البادئة القديمة، وسيعرضه docker volume ls.
لا يزال المنفذ الذي أزلته في ملف التجاوز مفتوحًا. أضاف الدمج ports بدلًا من استبداله. تحقق باستخدام docker compose config، ثم استخدم !override أو انقل ports خارج الملف الأساسي.
FAQ
هل يحمّل Compose الملف compose.override.yaml تلقائيًا؟
نعم، عند تشغيل docker compose من دون علامة -f. يبحث Compose في دليل العمل والأدلة الأصلية عنه عن compose.yaml أو docker-compose.yaml. إذا كان ملف التجاوز موجودًا بجانبه، يُحمَّل ذلك الملف ثانيًا. الأسماء المعترف بها هي 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 الإصدار v2.20.0 أو إصدارًا أحدث من Compose.
كيف أزيل قيمة يحددها الملف الأساسي؟
استخدم وسم !reset في Compose v2.24 أو إصدار أحدث. اكتب ports: !reset [] أو MY_VAR: !reset null في ملف التجاوز، فتعود السمة إلى قيمتها الافتراضية أو إلى null. القيمة التي تمررها إلى الوسم مطلوبة، لكنها تُتجاهل. إذا أردت استبدال سمة بدلًا من مسحها، فاستخدم !override. ويتطلب ذلك الإصدار v2.24.4 أو إصدارًا أحدث.