0%
شبكات Docker والوحدات التخزينية (Volumes)

الشبكات والوحدات التخزينية

كيف تتخاطب الحاويات، وما الذي يبقى بعد زوالها.

10-15 دقيقة

شبكات Docker والوحدات التخزينية (Volumes)

يتكرر سؤالان حتمًا بمجرد أن نتجاوز الحاوية المعزولة الواحدة، وهما وحدهما مصدر أغلب رسائل الخطأ المبهمة في الأشهر الأولى.

السؤال الأول: «لماذا لا يستطيع تطبيقي الوصول إلى قاعدة بياناته؟» شغّلنا الحاويتين، وكلٌّ منهما تعمل، وأمر docker ps يعرض كل شيء بحالة سليمة، ومع ذلك تردّ واجهة API بإصرار connection refused على localhost:5432. وردّ الفعل المعتاد هو البحث عن عنوان IP وكتابته مباشرة في الشيفرة، ثم ملاحظة أن الأمر يعمل… إلى أن يحدث إعادة التشغيل التالية ويتغيّر العنوان. لم تكن المشكلة في العنوان: بل في أن localhost داخل الحاوية لا يشير إلى الجهاز نفسه الذي يشير إليه على المضيف.

السؤال الثاني: «لماذا اختفت بياناتي؟» قاعدة Postgres امتلأت على مدى يومين من الاختبارات، ثم أمر docker compose down -v كُتب دون تفكير، فذهب كل شيء. هذا ليس حادثًا عرَضيًا: إنه السلوك الموثّق. فالحاوية مؤقتة بحكم تصميمها، وطبقة الكتابة الخاصة بها تموت معها. والاستمرارية لا تكون ضمنية أبدًا، بل يجب التصريح بها.

لهذين السؤالين جذر واحد: الحاوية معزولة افتراضيًا، سواء على مستوى الشبكة أو التخزين. وهذا العزل هو ما يجعل Docker قابلًا لإعادة الإنتاج؛ أما الشبكات والوحدات التخزينية فهما الآليتان الصريحتان اللتان تخترقانه تمامًا في المكان الذي تقرّرونه.

مشغّلات الشبكة (Network drivers)، وكيف تختارون بينها

المشغّلالاستخدامملاحظة
bridgeالافتراضي. شبكة افتراضية خاصة على المضيف.المشغّل الوحيد اللازم في 95% من الحالات.
hostتتشارك الحاوية حزمة الشبكة الخاصة بالمضيف.لا عزل ولا ربط منافذ. على Linux فقط.
noneلا شبكة، باستثناء واجهة الحلقة المحلية (loopback).معالجة محلية بحتة.
macvlanعنوان MAC خاص على الشبكة الفعلية.تكامل خاص مع الشبكة.
overlayشبكة موزّعة على عدة مضيفات.مخصّصة لعناقيد Swarm.
# الشبكات الموجودة. يتم إنشاء bridge وhost وnone عند التثبيت.
docker network ls

# التفاصيل: نطاق العناوين، البوابة، الحاويات المتصلة
docker network inspect bridge

يوفّر مشغّل host بضعة ميكروثوانٍ من زمن التأخير بإلغاء ترجمة العناوين، لكن بثمن التخلي عن كل عزل: إذ تشغل الحاوية منافذ المضيف مباشرة، مع ما يترتب على ذلك من تعارضات. إنه ملجأ أخير وليس تحسينًا افتراضيًا. كما أنه لا يصلح إلا على Linux: ففي macOS وWindows تكون حزمة «المضيف» هي حزمة الآلة الافتراضية الوسيطة.

النقطة التي تحلّ كل شيء: bridge الافتراضية مقابل bridge مخصّصة

هذا هو الفرق الأكثر فائدة في هذا الدرس، ويكاد يُهمَل دائمًا.

كل حاوية تُشغَّل دون تحديد تنضم إلى شبكة bridge الافتراضية. وعلى هذه الشبكة، لا يعمل حلّ DNS بحسب اسم الحاوية.

docker run -d --name serveur nginx:1.27-alpine
docker run --rm alpine:3.20 ping -c 2 serveur
# ping: bad address 'serveur'   ->  فشل متوقع

أنشئوا شبكة بأنفسكم فيتغيّر السلوك كليًا: يربط Docker بها خادم DNS داخليًا يحلّ أسماء الحاويات.

docker network create app-net

docker run -d --name serveur --network app-net nginx:1.27-alpine
docker run --rm --network app-net alpine:3.20 ping -c 2 serveur
# 2 packets transmitted, 2 packets received  ->  نجاح

# يمرّ طلب HTTP عبر المنفذ الداخلي (80)، دون أن يكون أي
# منفذ قد نُشر على المضيف.
docker run --rm --network app-net alpine:3.20 \
  sh -c "apk add --no-cache curl >/dev/null && curl -s -o /dev/null -w '%{http_code}\n' http://serveur"

القاعدة التي يجب تذكّرها: أنشئوا دائمًا شبكة مسمّاة لتطبيقاتكم. وهذا ما يفعله Docker Compose تلقائيًا — الشبكة <projet>_default — ولهذا كانت واجهة API في الدرس السابق تصل إلى قاعدتها على العنوان db:5432 دون أي إعداد. ويحلّ هذا في الوقت نفسه مشكلة عناوين IP: فخادم DNS الداخلي يتتبّع الحاويات، وأي خدمة أُعيد إنشاؤها بعنوان IP جديد تبقى قابلة للوصول تحت الاسم نفسه فورًا.

يمكن ربط الحاوية بعدة شبكات أثناء تشغيلها:

docker network create back-net
docker network connect back-net serveur
docker network disconnect back-net serveur

docker inspect serveur --format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'

نشر منفذ: ماذا يفعل -p بالضبط

الخيار -p 8080:80 لا يفتح المنفذ 80 في الحاوية — فقد كان مفتوحًا أصلًا داخل شبكتها. ما يفعله -p هو تثبيت قاعدة لترجمة العناوين على المضيف. ويسهل تذكّر ترتيب المضيف:الحاوية من خلال اتجاه الحركة: نأتي من المضيف ونتجه نحو الحاوية.

# المنفذ 8080 على المضيف -> المنفذ 80 في الحاوية
docker run -d -p 8080:80 nginx:1.27-alpine

# الربط بواجهة الحلقة المحلية فقط: يمكن الوصول إليه من
# الجهاز نفسه، وهو غير مرئي من الشبكة المحلية.
docker run -d -p 127.0.0.1:8080:80 nginx:1.27-alpine

# ترك Docker يختار منفذًا حرًا على المضيف
docker run -d -p 80 nginx:1.27-alpine

# نشر جميع المنافذ المعلنة بواسطة EXPOSE
docker run -d -P nginx:1.27-alpine

# عرض التقابلات الفعلية
docker port <conteneur>

تستحق الصيغة الثانية تأكيدًا خاصًا، لأنها تعالج مشكلة أمنية يكتشفها كثيرون بعد فوات الأوان. فالأمر -p 8080:80 يرتبط افتراضيًا بالعنوان 0.0.0.0، أي بـجميع الواجهات، بما فيها العنوان العام. وعلى Linux، يكتب Docker قواعد الترجمة الخاصة به في سلسلة iptables تُقيَّم قبل قواعد جدار حماية التطبيقات مثل ufw: لذلك قد يبقى المنفذ الذي نشره Docker قابلًا للوصول من الإنترنت بينما يؤكد ufw status أنه محجوب. وهذا ليس خللًا، بل هو ترتيب سلاسل التصفية — لكن المفاجأة مزعجة حين تكون القاعدة المعنية بلا كلمة مرور.

وخلاصة الممارسة الجيدة في جملة واحدة: لا تنشروا إلا ما يجب أن يكون متاحًا من الخارج، وأضيفوا البادئة 127.0.0.1 إلى كل ما لا يحتاج إلا إلى وصول محلي.

services:
  web:
    ports:
      - "80:8080"              # عام: وهذا مقصود
  db:
    ports:
      - "127.0.0.1:5432:5432"  # محلي فقط، لعميل رسومي
  cache:
    expose:
      - "6379"                 # للتوثيق فقط، لا شيء يُنشر

ملاحظة بشأن localhost، وهو أصل المشكلة الأولى المطروحة في المقدمة. داخل الحاوية، يشير localhost إلى الحاوية نفسها. فالتطبيق الذي يبحث عن قاعدته على localhost:5432 يبحث إذن عن Postgres داخل نفسه؛ والعنوان الصحيح هو اسم الخدمة، db:5432. وإذا كان على حاوية أن تصل فعلًا إلى خدمة تعمل على المضيف، فهناك اسم مخصّص لذلك:

# على Docker Desktop (macOS وWindows)، يُوفَّر هذا الاسم تلقائيًا
docker run --rm alpine:3.20 ping -c 1 host.docker.internal

# على Linux، يجب التصريح به صراحةً
docker run --rm --add-host=host.docker.internal:host-gateway \
  alpine:3.20 ping -c 1 host.docker.internal

تقسيم المنظومة عبر الشبكة

بما أن الحاوية لا تصل إلا إلى الشبكات التي تنتمي إليها، فإن الشبكات أداة حقيقية للفصل. والنمط الكلاسيكي يفصل بين طبقة مكشوفة وطبقة داخلية.

services:
  proxy:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
    networks:
      - frontend            # الخدمة الوحيدة التي يمكن الوصول إليها من الخارج

  api:
    build: .
    networks:
      - frontend            # يصل إليها الوكيل (proxy)
      - backend             # تستطيع الوصول إلى القاعدة

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - backend             # الشبكة الداخلية فقط

networks:
  frontend:
  backend:
    internal: true          # لا مسار نحو الخارج

volumes:
  db-data:

الفائدة هنا ليست نظرية. فالخدمة proxy لا تنتمي إلى backend: حتى لو اختُرقت، لا تستطيع فتح اتصال TCP نحو db، لأنه لا يوجد أي مسار. أما الخيار internal: true فيذهب أبعد بإلغاء المسار الافتراضي نحو الخارج، مما يحدّ بقوة من تسريب البيانات انطلاقًا من هذه الشبكة.

docker compose exec proxy sh -c "nc -zv db 5432"   # يجب أن يفشل
docker compose exec api sh -c "nc -zv db 5432"     # يجب أن ينجح

ثلاث طرق للحفاظ على البيانات، ومتى نستخدم كلًا منها

النوعتتولى إدارتهالموقعالاستخدام الرئيسي
وحدة تخزينية مسمّاة (Volume)Docker/var/lib/docker/volumes/...بيانات الإنتاج: قواعد البيانات، الملفات المرفوعة
Bind mountأنتمأي مكان على المضيفالتطوير: الشيفرة المصدرية، الإعدادات
tmpfsDockerفي الذاكرة الحية فقطالبيانات الحساسة أو المؤقتة

الوحدة التخزينية المسمّاة هي مساحة ينشئها Docker ويديرها. أنتم لا تعرفون مسارها الفعلي، وهذا هو بالضبط المقصود: فهي قابلة للنقل، ومستقلة عن بنية مجلدات المضيف، ويمكن نسخها احتياطيًا بأدوات Docker، وهي متطابقة على الأنظمة الثلاثة.

أما bind mount فيربط مجلدًا موجودًا على المضيف بالحاوية. وهو مثالي للتطوير — تعدّلون ملفًا فتراه الحاوية فورًا — وغير مناسب للإنتاج، لأنه يقرن التطبيق ببنية المجلدات الدقيقة لجهاز بعينه.

# وحدة تخزينية مسمّاة: يدير Docker موقعها
docker run -d --name pg1 \
  -e POSTGRES_PASSWORD=secret \
  -v pg-data:/var/lib/postgresql/data \
  postgres:16-alpine

# Bind mount: مسار المضيف المطلق، وجوبًا
docker run -d --name web1 \
  -v "$(pwd)/site:/usr/share/nginx/html:ro" \
  -p 8080:80 nginx:1.27-alpine

# tmpfs: في الذاكرة، يختفي عند الإيقاف
docker run -d --name calcul --tmpfs /tmp:rw,size=64m alpine:3.20 sleep 3600

يتحدد الفرق بحرف واحد: -v pg-data:/chemin بلا شرطة مائلة في البداية يعني وحدة تخزينية مسمّاة، بينما -v /chemin/hote:/chemin يعني bind mount. وهكذا يؤدي خطأ مطبعي إلى الانتقال بصمت من آلية إلى أخرى. أما صيغة --mount الأكثر إسهابًا فتزيل الالتباس وتفشل بوضوح إذا لم يكن المصدر موجودًا:

docker run -d \
  --mount type=volume,source=pg-data,target=/var/lib/postgresql/data \
  --mount type=bind,source="$(pwd)/conf",target=/etc/app,readonly \
  --mount type=tmpfs,target=/tmp,tmpfs-size=64m \
  -e POSTGRES_PASSWORD=secret \
  postgres:16-alpine

ثمة سلوك فارق ينبغي معرفته لأنه يفسّر مواقف محيّرة. فحين تُربط وحدة تخزينية مسمّاة فارغة بمجلد غير فارغ في الصورة، ينسخ Docker محتوى الصورة إلى الوحدة عند أول استخدام. أما bind mount فلا ينسخ شيئًا: بل يحجب محتوى الصورة حجبًا تامًا.

# تُملأ الوحدة التخزينية مسبقًا بصفحة nginx الافتراضية
docker run -d --name v1 -v html-vol:/usr/share/nginx/html nginx:1.27-alpine
docker run --rm -v html-vol:/data alpine:3.20 ls /data   # index.html موجود

# يحجب bind mount لمجلد فارغ كل شيء: يعيد nginx الخطأ 403
mkdir -p ./vide
docker run -d --name v2 -p 8081:80 \
  -v "$(pwd)/vide:/usr/share/nginx/html" nginx:1.27-alpine

ولهذا السبب بالتحديد أضاف ملف التجاوز في الدرس السابق وحدة تخزينية مجهولة - /app/node_modules: فلم يكن ينبغي لـ bind mount الخاص بـ ./src أن يحجب الاعتماديات المثبّتة في الصورة.

إدارة الوحدات التخزينية في الاستخدام اليومي

# الإنشاء الصريح (اختياري: تُنشأ الوحدة غير المعروفة تلقائيًا)
docker volume create pg-data

# العرض. الأسماء السداسية عشرية هي وحدات مجهولة (ANONYMES).
docker volume ls

# أين توجد البيانات فعليًا، ومن يستخدمها
docker volume inspect pg-data

# الحذف. يُرفض إذا كانت حاوية تستخدمها.
docker volume rm pg-data

# حذف جميع الوحدات غير المستخدمة. أمر مدمّر:
# اعرضوا أولًا، ثم قرّروا.
docker volume ls -f dangling=true
docker volume prune

نقطتان تستدعيان الحذر. الوحدات التخزينية تبقى بعد docker rm: فحذف الحاوية لا يحذف بياناتها، وهذا مقصود، لكنه يفسّر التراكم الصامت للوحدات اليتيمة. كما أن الوحدات المجهولة تتكدّس بمعرّفات غير مقروءة: لذا سمّوا وحداتكم دائمًا.

ولفحص وحدة تخزينية دون تشغيل الخدمة التي تستخدمها، تكون الحاوية المؤقتة هي الأداة المثالية:

docker run --rm -v pg-data:/data alpine:3.20 ls -la /data
docker run --rm -v pg-data:/data alpine:3.20 du -sh /data

مشكلة الأذونات في bind mounts

على Linux، يتربّص فخٌّ بكل واحد منا مرة. فالأذونات تقوم على معرّفات رقمية (UID وGID)، ولا تُترجم هذه الأرقام بين المضيف والحاوية. فإذا كتبت الحاوية بصفة المستخدم 1001 في مجلد مربوط من المضيف، فإن الملفات المنشأة تعود إلى UID 1001 على المضيف — وهو حساب قد لا يكون موجودًا. والعَرَض المعتاد: تظهر ملفات مملوكة لـ root في مجلد عملكم، أو يشكو التطبيق من permission denied على مجلد ترونه مع ذلك متاحًا.

# إعادة إنتاج المشكلة: تكتب الحاوية بصفة root
docker run --rm -v "$(pwd):/work" -w /work alpine:3.20 touch fichier-root
ls -l fichier-root     # مملوك لـ root

# الحل: فرض UID وGID المستخدم الحالي
docker run --rm --user "$(id -u):$(id -g)" \
  -v "$(pwd):/work" -w /work alpine:3.20 touch fichier-moi
ls -l fichier-moi      # مملوك لكم
# في Compose، مع متغيّر للحفاظ على قابلية النقل
services:
  api:
    build: .
    user: "${UID:-1000}:${GID:-1000}"
    volumes:
      - ./src:/app/src

لا توجد هذه المشكلة على macOS وWindows، حيث تعيد طبقة مشاركة الملفات في Docker Desktop كتابة المالكين. وفي المقابل، تعاني هاتان المنصتان من كلفة أداء على الملفات المشتركة: فقد يؤدي مجلد node_modules أو .git المربوط عبر bind mount إلى إبطاء مشروع بمقدار رتبة كاملة. والحل هو إبقاء هذه المجلدات في وحدة تخزينية من Docker وعدم ربط سوى الشيفرة المصدرية.

وآخر ردّ فعل مفيد: اربطوا بوضع القراءة فقط كل ما لا يلزم الحاوية تعديله — الإعدادات، ومجموعات البيانات، والشهادات. فهذا مجاني ويزيل فئة كاملة من الحوادث.

docker run -d -v "$(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:1.27-alpine

النسخ الاحتياطي للوحدة التخزينية واستعادتها

لا تُنسخ الوحدة التخزينية المسمّاة بالأمر cp: فموقعها يعود إلى Docker، وعلى macOS أو Windows توجد داخل الآلة الافتراضية. والطريقة القابلة للنقل هي ربطها بحاوية مؤقتة تكتب أرشيفًا في مجلد على المضيف.

# --- النسخ الاحتياطي: الوحدة للقراءة، ومجلد على المضيف للكتابة
docker run --rm \
  -v pg-data:/data:ro \
  -v "$(pwd):/sauvegarde" \
  alpine:3.20 \
  tar czf /sauvegarde/pg-data-$(date +%F).tar.gz -C /data .

# --- الاستعادة
docker volume create pg-data-restaure
docker run --rm \
  -v pg-data-restaure:/data \
  -v "$(pwd):/sauvegarde" \
  alpine:3.20 \
  tar xzf /sauvegarde/pg-data-2026-01-15.tar.gz -C /data

ملاحظة مهمة بخصوص قواعد البيانات: نسخ ملفات محرّك قاعدة بيانات أثناء الكتابة عليها قد ينتج أرشيفًا غير متّسق. أوقفوا الحاوية قبل النسخ، أو — والأفضل بكثير — استخدموا أداة النسخ الاحتياطي المنطقي الخاصة بالمحرّك، فهي تضمن حالة متّسقة:

# نسخ احتياطي منطقي لقاعدة Postgres أثناء عمل المحرّك
docker compose exec -T db pg_dump -U app -d appdb | gzip > appdb-$(date +%F).sql.gz

# الاستعادة
gunzip -c appdb-2026-01-15.sql.gz | docker compose exec -T db psql -U app -d appdb

الخيار -T يعطّل تخصيص طرفية وهمية (pseudo-terminal)، وهو ضروري بمجرد إعادة توجيه تدفق البيانات: فبدونه يتلف الأرشيف الناتج بسبب محارف التحكم الخاصة بالطرفية.

الشبكات والوحدات التخزينية في Compose

يعلن Compose عن الاثنين في المستوى الأعلى من الملف، مما يتيح رؤية كاملة للبنية في مكان واحد. الصيغة الطويلة أكثر إسهابًا لكنها أوضح بكثير عند المراجعة.

services:
  api:
    build: .
    networks:
      - backend
    volumes:
      - uploads:/app/uploads          # الصيغة القصيرة: وحدة تخزين مسمّاة
      - type: bind
        source: ./config
        target: /app/config
        read_only: true
      - type: tmpfs
        target: /tmp
        tmpfs:
          size: 67108864              # 64 ميغابايت، بالبايت

networks:
  backend:
    driver: bridge

volumes:
  uploads:

تذكير بفخّ التسمية الذي رأيناه في الدرس السابق: يضيف Compose اسم المشروع بادئةً لأسماء الوحدات التخزينية، لذا يصبح uploads هو monprojet_uploads. إعادة تسمية مجلد المشروع تنشئ وحدة تخزين جديدة فارغة وتعطي انطباعًا بأن البيانات اختفت، بينما هي في الحقيقة باقية تحت البادئة القديمة.

docker volume ls | grep uploads       # العثور على وحدة تخزين خلف بادئتها
docker compose -p monprojet up -d     # تثبيت البادئة حتى لا نعتمد عليها بعد الآن

ما يجب تذكّره

  • على شبكة bridge الافتراضية، لا يعمل تحليل الأسماء. أنشئوا دائمًا شبكة مسمّاة — وهذا ما يفعله Compose نيابةً عنكم — واربطوا خدماتكم ببعضها بأسمائها، لا بعنوان IP سيتغيّر.
  • داخل الحاوية، يشير localhost إلى الحاوية نفسها. للوصول إلى خدمة أخرى استخدموا اسمها؛ وللوصول إلى المضيف استخدموا host.docker.internal (مع --add-host=host.docker.internal:host-gateway على Linux).
  • الخيار -p 8080:80 يرتبط بـجميع الواجهات ويتجاوز ufw على Linux. أضيفوا البادئة 127.0.0.1 لكل ما لا ينبغي أن يكون عامًا، ولا تنشروا قاعدة بيانات بدافع العادة.
  • وحدة التخزين المسمّاة للبيانات التي يجب أن تبقى، وbind mount للشيفرة أثناء التطوير، وtmpfs لما يجب ألا يلامس القرص أبدًا. وحدة التخزين المسمّاة الفارغة ترث محتوى الصورة؛ أما bind mount فيحجبه.
  • تبقى وحدات التخزين بعد حذف الحاويات. الأمر docker compose down يحتفظ بها، أما docker compose down -v فيدمّرها: الفرق بين الأمرين هو بياناتكم.
  • على Linux، لا تُترجَم معرّفات UID عبر bind mount. الخيار --user "$(id -u):$(id -g)" يجنّبكم وجود ملفات مملوكة للمستخدم root في مجلد عملكم.
  • انسخوا وحدة التخزين احتياطيًا عبر حاوية مؤقتة تنتج أرشيفًا؛ وفي حالة قاعدة البيانات، فضّلوا دائمًا التصدير المنطقي (pg_dump) على نسخ الملفات أثناء التشغيل.

نصيحة: عند الشك في اتصال بين حاويات، يعرض الأمر docker network inspect <reseau> القائمة الدقيقة للحاويات المرتبطة بالشبكة. في تسع حالات من عشر، تكون الخدمة التي «لا تستجيب» ببساطة ليست على الشبكة نفسها.

التعليقات

التعليقات مدعومة عبر GitHub Discussions

سجّل الدخول باستخدام GitHub للمشاركة في النقاش

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