0%
Docker Compose

Compose

تطبيق كامل موصوف في ملف واحد.

10-15 دقيقة

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 للمشاركة في النقاش

تم نسخ الرابط!