Compose
تطبيق كامل موصوف في ملف واحد.
Docker Compose
لا يتكوّن التطبيق الحقيقي من حاوية واحدة تقريبًا أبدًا. فهو واجهة API، وقاعدة بيانات، وذاكرة تخزين مؤقت، وأحيانًا عامل (worker) غير متزامن، وخادم وكيل (proxy) في الواجهة الأمامية. خمس حاويات، أي خمسة أوامر docker run — بالمنافذ الصحيحة، والمتغيرات الصحيحة، والشبكة الصحيحة، وبالترتيب الصحيح، مع ضرورة تذكّر ذلك كله صباح الغد.
يصمد هذا الأسلوب لبعض الوقت، بفضل سكربتات shell تُنسخ من مشروع إلى آخر. ثم يتدهور دائمًا بالطريقة نفسها: سكربت الزميل لا يستخدم إصدار Postgres نفسه، ولا أحد يعرف لماذا يحمل العامل ذلك الخيار الغامض، وانضمام مطوّر جديد يكلّف نصف يوم، وكلمة مرور قاعدة البيانات تبقى عالقة في سجل ثلاث نوافذ طرفية. المشكلة ليست في عدد الأوامر، بل في عدم وجود وصف واحد قابل للتحقق لما يعنيه «التطبيق».
يعالج Docker Compose هذا النقص بالضبط. يعلن ملف YAML واحد جميع الخدمات وصورها ومتغيراتها وأحجامها وتبعياتها. يعيش هذا الملف في مستودع Git بجوار الشيفرة. يكفي استنساخ المشروع وكتابة أمر واحد للحصول على الحزمة الكاملة، متطابقة عند الجميع — ويشرح git log سبب إضافة هذا الخيار أو ذاك.
كلمة عن الصيغة قبل البدء، لأنها مصدر للالتباس. الصيغة الحديثة هي docker compose بكلمتين: وهي مكوّن إضافي مدمج في عميل Docker. أما الصيغة القديمة docker-compose بشرطة فهي ملف تنفيذي منفصل مكتوب بلغة Python، وهو الإصدار «v1»، وقد توقّف تطويره منذ عام 2023. يتشابه الاثنان، لكن v1 يتجاهل جزءًا من الصيغة الحالية. كل ما يلي يستخدم docker compose.
ملف أول، خدمة تلو الأخرى
يُسمّى الملف compose.yaml ويوضع في جذر المشروع. هذا هو الاسم الموصى به في مواصفة Compose؛ ويبقى docker-compose.yml معترفًا به للتوافق، وستصادفه كثيرًا في المشاريع القائمة.
# compose.yaml
# Pas de cle "version:" en tete : elle est obsolete et sans effet.
services:
# ---------- L'application ----------
api:
build:
context: .
dockerfile: Dockerfile
image: mon-api:dev # nom attribue a l'image construite
ports:
- "3000:3000" # hote:conteneur
environment:
NODE_ENV: development
# "db" et "cache" sont des noms de SERVICES : ils sont resolus
# en DNS sur le reseau de la pile. Aucune adresse IP a ecrire.
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/appdb
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
restart: unless-stopped
# ---------- La base de donnees ----------
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdb
volumes:
# Volume nomme : les donnees survivent a la suppression du conteneur
- db-data:/var/lib/postgresql/data
healthcheck:
# Le double $$ echappe l'interpolation Compose : la variable doit
# etre resolue DANS le conteneur, pas par Compose.
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped
# ---------- Le cache ----------
cache:
image: redis:7-alpine
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
volumes:
- cache-data:/data
restart: unless-stopped
volumes:
db-data:
cache-data:
هناك تفصيلان في هذا الملف يستحقان الانتباه فورًا.
قاعدة البيانات لا تنشر أي منفذ: لا يوجد مفتاح ports تحت db. ومع ذلك تبقى قابلة للوصول تمامًا من الـ API على العنوان db:5432، لأن الخدمتين تتشاركان شبكة داخلية ينشئها Compose تلقائيًا. أما نشر 5432:5432 فسيعرّض قاعدتك على جميع واجهات الجهاز، وهو أمر لا يلزم إلا لربط عميل رسومي بها من المضيف.
كذلك تُشار إلى الخدمات بأسمائها وليس بعناوين IP أبدًا. فعناوين IP للحاويات تتغير عند كل إعادة إنشاء؛ أما الأسماء فلا. يفصّل الدرس التالي آلية الشبكة هذه.
دورة الأوامر اليومية
يعمل Compose بمبدأ التقارب (convergence): تصف الحالة المطلوبة، فيحسب هو الإجراءات اللازمة. لا يعيد up تشغيل كل شيء، بل يمسّ فقط ما تغيّر.
docker compose up -d # demarrer en arriere-plan
docker compose up -d --build # reconstruire les images locales avant
docker compose up -d --wait # attendre que les healthchecks soient verts
docker compose ps # etat de la pile, avec la sante
docker compose logs -f # journaux agreges, en direct
docker compose logs -f --tail 100 api # un seul service
# Ouvrir un shell dans un service qui TOURNE
docker compose exec api sh
docker compose exec db psql -U app -d appdb
# Lancer une commande dans un conteneur JETABLE.
# run cree un nouveau conteneur, exec entre dans celui qui tourne.
docker compose run --rm api npm run migrate
docker compose restart api
عند الإيقاف ينبغي التمييز بعناية بين ثلاثة أوامر، لأن أحدها يتلف البيانات:
# Arreter sans rien supprimer. Reversible par "start".
docker compose stop
# Arreter ET supprimer conteneurs et reseaux.
# Les VOLUMES sont conserves : vos donnees survivent.
docker compose down
# Idem, plus la suppression des volumes. Vos donnees sont PERDUES.
docker compose down -v
# Supprimer aussi les conteneurs orphelins d'une ancienne version du fichier
docker compose down --remove-orphans
وأخيرًا، أكثر أوامر التشخيص جدوى:
# Affiche le fichier tel que Compose le comprend REELLEMENT : variables
# interpolees, fichiers fusionnes, valeurs par defaut appliquees.
# Le premier reflexe quand une variable "ne passe pas".
docker compose config
كلمة عن اسم المشروع. يضيف Compose إلى جميع الكائنات التي ينشئها بادئةً مشتقة من اسم المجلد الحالي: فيصبح الحجم db-data هو monprojet_db-data. وهذا ما يتيح تشغيل حزمتين مستقلتين على الجهاز نفسه، وهو أيضًا ما يفسّر لماذا يبدو أن إعادة تسمية المجلد «تُضيّع» البيانات — فهي ما تزال موجودة، تحت البادئة القديمة.
docker compose -p mon-projet up -d
# ou, de facon persistante, dans .env : COMPOSE_PROJECT_NAME=mon-projet
image أو build: الاستهلاك أو الإنتاج
استخدام image وحده يسحب صورة موجودة من سجل (registry): وهذه حال Postgres وRedis وNginx التي لا تبنيها أبدًا. أما build فيبني انطلاقًا من Dockerfile: وهذه حال شيفرتك. ويؤدي الجمع بين المفتاحين إلى إعطاء اسم للصورة المبنية، مما يتيح دفعها إلى سجل.
services:
api:
build:
context: . # comme le "." de docker build
dockerfile: docker/Dockerfile.api # relatif au contexte
target: runtime # etape d'un Dockerfile multi-stage
args: # equivaut a --build-arg
NODE_VERSION: "22"
image: registre.exemple.com/equipe/mon-api:dev
وهنا يكمن فخ المبتدئين: الأمر up لا يعيد بناء صورة موجودة. تعدّل ملف Dockerfile، وتعيد تشغيل up -d، ولا يتغير شيء. يعيد Compose استخدام الصورة المبنية سابقًا؛ ولا بد من طلب إعادة البناء صراحةً.
docker compose build api # reconstruire un service
docker compose build --no-cache api # sans cache
docker compose up -d --build # reconstruire puis demarrer
التبعيات: depends_on وحده لا يكفي
هذا هو مصدر الخطأ الأكثر شيوعًا والأكثر إرباكًا، لأن العَرَض فيه متقطع.
الصيغة المختصرة من depends_on تضمن فقط ترتيب البدء. ينتظر Compose أن تكون الحاوية db قد انطلقت، لا أن يكون Postgres قادرًا على قبول الاتصالات. وPostgres يحتاج من ثانيتين إلى عشر ثوانٍ لتهيئة عنقوده (cluster) عند أول تشغيل. تبدأ الـ API خلال هذه المدة، فتفشل برسالة connection refused، ثم تنتهي. وعلى جهاز سريع قد ينجح الأمر أحيانًا: ومن هنا الانطباع بوجود خطأ عشوائي.
# Insuffisant : garantit l'ORDRE, pas la DISPONIBILITE
services:
api:
depends_on:
- db
يجمع الحل الصحيح بين healthcheck على الخدمة المعتمَد عليها وشرط على الخدمة التابعة:
services:
api:
depends_on:
db:
condition: service_healthy # attend le healthcheck au vert
cache:
condition: service_started # le demarrage suffit
migrate:
condition: service_completed_successfully # attend une sortie en code 0
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s # frequence des verifications
timeout: 3s # au-dela, la verification est un echec
retries: 10 # echecs consecutifs avant le statut "unhealthy"
start_period: 10s # periode de grace : les echecs n'y sont pas comptes
migrate:
build: .
command: ["npm", "run", "migrate"]
depends_on:
db:
condition: service_healthy
restart: "no" # un travail ponctuel, pas un service
الشرط الثالث service_completed_successfully يحل بأناقة مشكلة ترحيلات المخطط (schema migrations): تُنفَّذ الخدمة migrate مرة واحدة، وتنتهي برمز 0، ولا تبدأ الـ API إلا بعد ذلك. لا حاجة بعد الآن إلى سكربتات انتظار يدوية الصنع.
# La colonne STATUS affiche (healthy), (unhealthy) ou (health: starting)
docker compose ps
# Historique detaille des verifications, avec les sorties de commande
docker inspect --format '{{json .State.Health}}' $(docker compose ps -q db)
تحفّظ صريح: لا يعمل depends_on إلا عند البدء. فإذا أُعيد تشغيل قاعدة البيانات أثناء عمر التطبيق، وجب على تطبيقك أن يعرف كيف يعيد الاتصال. يبقى منطق إعادة الاتصال مع مهلة متصاعدة تدريجيًا في الشيفرة أمرًا لا غنى عنه؛ فـdepends_on يزيل الضوضاء عند البدء، لكنه لا يحل محل المتانة.
متغيرات البيئة: ثلاث آليات متمايزة
ينشأ الالتباس من أن ثلاثة أشياء مختلفة تحمل أسماءً متقاربة جدًا.
يغذّي ملف .env المجاور لـcompose.yaml عملية الاستيفاء (interpolation) في ملف YAML نفسه: فقيمه تحل محل ${...} قبل أن يعالج Compose أي شيء. أما المفتاح environment فيحدد المتغيرات التي تستقبلها العملية داخل الحاوية. وأما المفتاح env_file فيحمّل ملف متغيرات مباشرةً داخل الحاوية دون المرور بالاستيفاء.
# .env -- a exclure de Git via .gitignore
DB_PASSWORD=motdepasse-de-dev
API_PORT=3000
TAG=dev
services:
api:
image: mon-api:${TAG} # interpole AVANT le demarrage
ports:
- "${API_PORT}:3000"
env_file:
- ./config/api.env # injecte tel quel dans le conteneur
environment:
LOG_LEVEL: ${LOG_LEVEL:-info} # valeur par defaut si absente
# Deux-points + point d'interrogation : Compose ECHOUE si la
# variable est absente ou vide. A utiliser pour tout secret.
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET est obligatoire}
الصيغة ${VAR:?message} هي أفضل وسيلة حماية متاحة: فهي تحوّل السر المنسي إلى خطأ فوري وواضح، بدلًا من تطبيق يبدأ بسلسلة فارغة ثم يفشل في المصادقة بعد ثلاث ساعات.
وقاعدتان أمنيتان في الختام. ملف .env الذي يحتوي أسرارًا حقيقية لا يدخل Git أبدًا: أضف إلى الإصدارات ملف .env.example موثّقًا وخاليًا من القيم. وفي الإنتاج، لا تمرّ الأسرار عبر متغيرات مقروءة في docker inspect: بل نستخدم قسم secrets الذي يركّبها كملفات تحت /run/secrets/، فيقرأ التطبيق مسارًا بدلًا من قيمة.
الملفات الشخصية (Profiles): خدمات اختيارية في الحزمة نفسها
تنتهي حزمة التطوير بتراكم أدوات لا يحتاج إليها أحد بصفة دائمة: واجهة لإدارة قاعدة البيانات، وخادم SMTP وهمي، ومولّد أحمال. تشغيلها دائمًا يهدر الذاكرة؛ ووضعها في ملف ثانٍ يكرّر الإعدادات. أما الخدمة المسندة إلى ملف شخصي (profile) فلا تبدأ إلا إذا طُلب هذا الملف الشخصي.
services:
api:
build: .
ports:
- "3000:3000"
# Aucun profil : ce service demarre toujours
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
adminer: # administration de la base
image: adminer:4
profiles: ["debug"]
ports:
- "8080:8080"
mailpit: # faux serveur SMTP, dans DEUX profils
image: axllent/mailpit:latest
profiles: ["debug", "mail"]
ports:
- "8025:8025"
k6: # tests de charge, jamais par accident
image: grafana/k6:latest
profiles: ["perf"]
command: ["run", "/scripts/charge.js"]
volumes:
- ./perf:/scripts:ro # :ro monte en lecture seule
docker compose up -d # api + db seulement
docker compose --profile debug up -d # + adminer + mailpit
docker compose --profile debug --profile perf up -d # profils cumules
COMPOSE_PROFILES=debug docker compose up -d # via l'environnement
docker compose --profile debug config --services # lister sans demarrer
تركيب عدة ملفات فوق بعضها
كثيرًا ما يجب أن يعمل المشروع نفسه في سياقات مختلفة: شيفرة مركّبة وإعادة تحميل فورية في التطوير، وصورة مجمّدة وحدود للموارد في الإنتاج. يركّب Compose الملفات فوق بعضها بدلًا من تكرارها. فالملف المسمّى compose.override.yaml يُحمَّل تلقائيًا إضافةً إلى compose.yaml.
# compose.yaml -- la base commune, valable partout
services:
api:
image: registre.exemple.com/equipe/mon-api:${TAG:-latest}
environment:
NODE_ENV: production
restart: unless-stopped
# compose.override.yaml -- charge automatiquement, pour le developpement
services:
api:
build: . # on construit localement
environment:
NODE_ENV: development
DEBUG: "app:*"
volumes:
# Le code de l'hote remplace celui de l'image : une modification
# est visible sans reconstruire.
- ./src:/app/src
# Volume anonyme : protege le node_modules de l'image de
# l'ecrasement par celui de l'hote.
- /app/node_modules
command: ["npm", "run", "dev"]
ports:
- "9229:9229" # port du debogueur Node
# compose.prod.yaml -- charge explicitement
services:
api:
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Développement : compose.yaml + compose.override.yaml, automatiquement
docker compose up -d
# Production : on nomme les fichiers, l'override n'est PAS chargé
docker compose -f compose.yaml -f compose.prod.yaml up -d
# Toujours vérifier le résultat de la fusion avant de déployer
docker compose -f compose.yaml -f compose.prod.yaml config
يجب معرفة قاعدة الدمج: في القيم البسيطة يتغلّب الملف الأخير، أما في القوائم مثل ports أو volumes فتُجمَع المدخلات. وهذه مفاجأة شائعة يكشفها الأمر docker compose config فورًا.
ما لا يقوم به Compose
يُنسّق Compose الحاويات على مضيف واحد فقط. فهو لا يوزّع شيئًا على عدة أجهزة، ولا يعيد تشغيل خدمة في مكان آخر عند تعطّل خادم، ولا يضمن أي عملية نشر بلا انقطاع. هذا هو مجال Kubernetes أو Docker Swarm.
ولا يُسقط ذلك صلاحيته للإنتاج: فالخادم الواحد الذي يديره Compose بنية معمارية مقبولة تمامًا لخدمة ذات حركة مرور معتدلة، وهو أبسط بكثير في التشغيل من عنقود (cluster). وتتناول الدرس 6 هذه الحالة تحديدًا.
ومع ذلك، تتوفر إمكانية التوسّع المحلي، وهي مفيدة لاختبار worker:
# Trois instances du service worker
docker compose up -d --scale worker=3
وهي لا تتوافق مع نشر منفذ ثابت: فلا يمكن لثلاث حاويات الارتباط بالمنفذ 3000 على المضيف. احذف المفتاح ports من الخدمة المعنية، أو دع Docker يختار منافذ حرة.
أهم النقاط
- يحلّ ملف
compose.yamlالخاضع للإصدارات محلّ مجموعة هشّة من أوامرdocker run: يصبح تعريف التطبيق مستندًا واحدًا، يمكن مراجعته وإعادة إنتاجه لدى كل أعضاء الفريق. - استخدم
docker composeبكلمتين. أماdocker-composeبالشرطة فهو الإصدار الأول v1، وقد تم التخلي عنه، ولا يدعم جزءًا من الصياغة الحالية. والمفتاحversion:في رأس الملف قديم ولا أثر له. - تتواصل الخدمات بأسمائها بفضل DNS الداخلي. لا تنشر عبر
portsإلا ما يجب الوصول إليه من الخارج: وقاعدة البيانات في الغالب ليست منه. - لا يضمن
depends_onوحده سوى ترتيب البدء. وللانتظار حتى يتحقق الجاهزية الفعلية، أضفhealthcheckمعcondition: service_healthy؛ أماservice_completed_successfullyفيعالج حالة عمليات الترحيل (migrations) معالجة سليمة. - لا يعيد
docker compose upبناء صورك: تذكّر استخدام--buildبعد أي تعديل على Dockerfile. كما أنdocker compose down -vيحذف الأحجام (volumes)، أي البيانات. - يحوّل
${VAR:?message}غياب سرّ ما إلى خطأ فوري. وعند الشك في متغيّر، يعرضdocker compose configالملف كما تم تفسيره فعليًا. - تعزل الملفات التعريفية (profiles) الأدوات الاختيارية، وتكيّف ملفات التجاوز (override) المجموعة نفسها مع التطوير أو الإنتاج دون تكرار الإعدادات.
نصيحة: اعتد تشغيل
docker compose configقبل أيupعلى إعداد جرى تعديله. فمعظم الساعات الضائعة مع Compose تكون بسبب الفرق بين ما تظن أنك كتبته وما أنتجته عملية الاستيفاء (interpolation) فعليًا.
التعليقات
التعليقات مدعومة عبر GitHub Discussions
سجّل الدخول باستخدام GitHub للمشاركة في النقاش