الأدلة التقنية

شبكات Docker وأفضل الممارسات مع Docker Compose

كل منفذ تنشره في ملف Compose بدون عنوان يصبح مفتوحاً على الإنترنت حتى لو كان ufw مفعلاً. يشرح هذا الدليل أنواع شبكات Docker، والنمط الذي نستخدمه في كل أدلة الموقع: شبكة proxy مشتركة لل Reverse Proxy والتطبيقات، وشبكة داخلية لقاعدة بيانات كل مشروع.

شبكات Docker وأفضل الممارسات مع Docker Compose

عندما تشغل أول تطبيق على سيرفرك بـ Docker Compose فغالباً سوف تنسخ ملف compose.yaml من صفحة المشروع كما هو، وفي هذا الملف سطر ports ينشر منفذ التطبيق على السيرفر، وأحياناً سطر آخر ينشر منفذ قاعدة البيانات Database حتى تتصل بها من جهازك، ومع التطبيق الخامس يصبح على السيرفر عشرة منافذ مفتوحة لا تتذكر أنت لماذا فتحت نصفها. والمشكلة أن هذه المنافذ ليست مفتوحة على السيرفر وحده كما يظن الكثيرون، وإنما على الإنترنت، حتى لو كان جدار الحماية Firewall مفعلاً.

وفي دليل Nginx Proxy Manager ذكرنا أننا سوف نضع NPM وكل التطبيقات التي يخدمها على شبكة Docker مشتركة Docker Network اسمها proxy، وعلى هذه الشبكة يصل NPM إلى كل Container باسمه، وبالتالي لا تحتاج التطبيقات إلى نشر أي منفذ على السيرفر، ويبقى ال Reverse Proxy هو الطريق الوحيد إليها. وهذه الجملة تختصر نمطاً كاملاً نستخدمه في كل أدلة الموقع، سواءً كان ال Reverse Proxy هو NPM أو Traefik أو Caddy، لذلك سوف نشرحه هنا مرة واحدة بالتفصيل، ونبدأ من الأساس: ما هي شبكة Docker، وما أنواعها، ولماذا يعمل هذا النمط.

في هذا المقال سوف نتناول:

  • ما الذي يحدث عندما تنشر كل منفذ على السيرفر، ولماذا يتجاوز Docker جدار ufw.
  • أنواع شبكات Docker Network Drivers: bridge وhost وnone وoverlay وmacvlan وipvlan، ومتى يناسبك كل منها.
  • الشبكة التي ينشئها Compose لكل مشروع، وكيف تصل ال Containers إلى بعضها بالاسم.
  • الفرق بين ports وexpose، ومتى لا تنشر أي منفذ أصلاً.
  • النمط الذي نستخدمه في الموقع: شبكة proxy مشتركة لل Reverse Proxy والتطبيقات، وشبكة داخلية Internal Network خاصة بكل مشروع لقاعدة بياناته.
  • الأخطاء الشائعة: خطأ 502 من ال Reverse Proxy، وتعارض الأسماء بين المشاريع، والمخرج الافتراضي Default Gateway عند الاتصال بشبكتين، ونفاد نطاقات العناوين Address Pools، وملاحظات IPv6.

وإذا لم يكن Docker مثبتاً على سيرفرك بعد، فابدأ بدليل تثبيت Docker Engine وDocker Compose على Ubuntu.

ماذا يحدث عندما تنشر كل منفذ؟

لنأخذ مثالاً، فلنفرض أن لديك تطبيقاً بسيطاً مع قاعدة بيانات PostgreSQL، والملف الذي يأتي في توثيق أغلب المشاريع يشبه ما يلي:

مخطط يقارن بين سيرفر تنشر فيه كل التطبيقات منافذها فيصل إليها الإنترنت رغم جدار الحماية، وسيرفر لا يفتح إلا منفذي ال Reverse Proxy وتصل التطبيقات إليه عبر شبكة Docker داخلية
الطريقة الشائعة مقابل أفضل ممارسة: منفذان فقط يراهما الإنترنت
services:
  app:
    image: traefik/whoami:v1.12.0
    ports:
      - "8080:80"
  db:
    image: postgres:18.0-alpine
    environment:
      POSTGRES_PASSWORD: change-me
    ports:
      - "5432:5432"

هذا الملف يعمل، والتطبيق يظهر على http://203.0.113.10:8080، ولكن لاحظ ما فعلته دون أن تقصد:

  • الصيغة "8080:80" لا تحدد عنواناً على السيرفر، لذلك ينشر Docker المنفذ على كل عناوين السيرفر، أي 0.0.0.0 و[::]، وبالتالي يصل إليه كل من يعرف عنوان السيرفر العام Public IP.
  • قاعدة البيانات أصبحت كذلك على الإنترنت بالمنفذ 5432، ولا يحميها إلا الباسورد، والتطبيق نفسه لم يكن يحتاج هذا المنفذ أصلاً، فهو يصل إلى قاعدة البيانات من داخل شبكة Docker كما سيأتي.
  • كل تطبيق جديد يحتاج منفذاً جديداً، ورقماً يتذكره المستخدم، وشهادة TLS Certificate خاصة به.

وقد يتساءل البعض: ولكني فعلت جدار ufw ولم أسمح إلا بالمنافذ 22 و80 و443، فكيف يصل أحد إلى 5432؟ والإجابة أن توثيق Docker نفسه يقول إن Docker وufw يستخدمان قواعد جدار الحماية بطريقة لا تتوافق، فعندما تنشر منفذاً يحول Docker الحركة Traffic المتجهة إلى ال Container قبل أن تمر على قواعد ufw، وبالتالي يبقى المنفذ مفتوحاً مهما كتبت في ufw. وهذه أشهر مفاجأة يواجهها من ينقل خدماته إلى Docker، وقد شرحناها في دليل تأمين خادم VPS وفي دليل التثبيت.

والصورة التالية تبين الطريق الذي تسلكه الحزمة إلى المنفذ المنشور، ولماذا لا تمر على قواعد ufw:

مخطط يوضح حزمة تصل إلى المنفذ 5432 فيغير Docker وجهتها إلى عنوان ال Container، فتمر في سلسلة FORWARD التي تبدأ بقواعد Docker ولا تمر على قواعد ufw في سلسلة INPUT
Docker يغير وجهة الحزمة إلى ال Container قبل قواعد ufw، فلا يراها جدار الحماية
📌
في 6 يناير 2017 نشرت MongoDB على مدونتها تحذيراً بعنوان How to Avoid a Malicious Attack That Ransoms Your Data، بعد موجة هجمات على قواعد MongoDB التي كان أصحابها يشغلونها مفتوحة على الإنترنت دون حماية، حيث كان المهاجم يحذف قاعدة البيانات ويترك مكانها طلب فدية Ransom مقابل إعادتها. وذكرت الشركة في نفس التحذير أن أشهر طرق تثبيتها تقصر الاتصال على localhost افتراضياً، ونصحت بنفس الإعداد مع أي طريقة تثبيت أخرى. والدرس لمن يستخدم Docker أن سطر ports واحداً بدون عنوان يكفي ليضع قاعدة بياناتك على الإنترنت.

والحل الأول الذي يخطر على البال هو أن تنشر كل شيء على 127.0.0.1، وهذا أفضل بكثير، ولكنه لا يحل المشكلة كاملة، فال Reverse Proxy الذي يعمل في Container لا يصل إلى 127.0.0.1 الخاص بالسيرفر بسهولة، لأن 127.0.0.1 داخل كل Container يشير إلى ال Container نفسه، وبالتالي تحتاج إلى حيل مثل host.docker.internal أو تشغيل ال Reverse Proxy بشبكة host. والحل الأنسب هو ألا تنشر منافذ التطبيقات أصلاً، وأن تضعها مع ال Reverse Proxy على شبكة Docker واحدة، وحتى نفهم لماذا يعمل هذا الحل نحتاج أن نفهم شبكات Docker أولاً.

ما هي شبكة Docker؟

شبكة Docker هي شبكة افتراضية Virtual Network ينشئها Docker على السيرفر، ويتصل بها ال Container كما يتصل جهاز بمفتاح شبكة Switch بكابل، حيث يأخذ عنوان IP منها ويستطيع أن يكلم كل من يتصل بنفس الشبكة. وكما يتصل الجهاز الواحد بأكثر من شبكة عبر أكثر من كابل، يتصل ال Container بأكثر من شبكة، فيكون له عنوان في كل واحدة منها.

والأهم من العنوان هو الاسم، ففي الشبكات التي تنشئها أنت (وليس الشبكة الافتراضية كما سيأتي) يعمل داخل Docker سيرفر DNS مدمج Embedded DNS Server على العنوان 127.0.0.11، وهو يحول اسم ال Container أو اسم الخدمة Service إلى عنوانها على هذه الشبكة، أما الأسماء الأخرى مثل example.com فيمررها إلى سيرفرات DNS المضبوطة على السيرفر. وبالتالي لا تحتاج أن تعرف عنوان قاعدة البيانات ولا أن تحفظه في إعدادات التطبيق، فيكفي أن تكتب db، وإذا أعاد Docker إنشاء ال Container بعنوان جديد فالاسم يبقى كما هو.

وعند تثبيت Docker سوف تجد ثلاث شبكات جاهزة، وتعرضها بالأمر التالي:

docker network ls --filter type=builtin

والمخرج سوف يكون كما يلي:

NETWORK ID     NAME      DRIVER    SCOPE
7e53e218a864   bridge    bridge    local
c9369ab16874   host      host      local
e09463bc286f   none      null      local

ولاحظ أن عمود DRIVER هو نوع الشبكة، والاسم شيء آخر، فالشبكة التي اسمها bridge هي الشبكة الافتراضية Default Bridge، ونوعها bridge، وكل شبكة تنشئها أنت بالأمر docker network create نوعها bridge أيضاً ما لم تحدد غيره.

أنواع الشبكات Network Drivers

يوفر Docker عدة أنواع من الشبكات، ولكل نوع مشغل Driver يحدد كيف يتصل ال Container بالسيرفر وبغيره، وفيما يلي كل نوع ومتى يناسبك.

والصورة التالية تبين أين يقف ال Container من السيرفر والشبكة في كل نوع منها:

مخطط يوضح أنواع شبكات Docker: bridge شبكة خاصة على السيرفر تخرج بترجمة العناوين، وhost شبكة السيرفر نفسه، وnone بدون شبكة، وoverlay شبكة واحدة على عدة سيرفرات، وmacvlan وipvlan عنوان من شبكة المنزل
أنواع شبكات Docker وأين يتصل ال Container في كل منها

شبكة bridge: الافتراضية والتي تنشئها أنت

شبكة bridge هي النوع الافتراضي، وهي شبكة خاصة على نفس السيرفر، تتصل بها ال Containers فتكلم بعضها، وتخرج إلى الإنترنت عبر السيرفر بترجمة العناوين NAT، ولا يصل إليها أحد من الخارج إلا عبر منفذ تنشره. ولكن هناك فرق كبير بين الشبكة الافتراضية التي اسمها bridge، وبين شبكة bridge تنشئها أنت User-defined Bridge، والفرق يظهر بالمثال التالي.

سوف نشغل Container اسمه db بدون تحديد أي شبكة، فيتصل تلقائياً بالشبكة الافتراضية، ثم نحاول الوصول إليه باسمه من Container آخر على نفس الشبكة:

docker run -d --name db alpine:3.24 sleep 600
docker run --rm alpine:3.24 ping -c 1 db

والمخرج سوف يكون كما يلي:

ping: bad address 'db'

الآن سوف نقوم بإنشاء شبكة خاصة بنا اسمها demo، ونوصل بها نفس ال Container وهو يعمل، ثم نكرر نفس المحاولة من هذه الشبكة:

docker network create demo
docker network connect demo db
docker run --rm --network demo alpine:3.24 ping -c 1 db

والمخرج سوف يكون كما يلي:

PING db (172.31.0.2): 56 data bytes
64 bytes from 172.31.0.2: seq=0 ttl=64 time=0.309 ms

--- db ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 0.309/0.309/0.309 ms

وهذا هو الفرق الأول، والسبب أن الشبكة الافتراضية لا تحول الأسماء إلى عناوين، فال Containers عليها تتصل ببعضها بالعنوان فقط، إلا إذا استخدمت الخيار --link الذي يصفه التوثيق بأنه قديم Legacy. وبقية الفروق كما يلي:

  • العزل Isolation: كل Container تشغله بدون تحديد شبكة يتصل بالشبكة الافتراضية، وبالتالي تستطيع تطبيقات لا علاقة لها ببعضها أن تتصل ببعضها، أما الشبكة التي تنشئها فلا يتصل بها إلا من تضيفه أنت.
  • التوصيل أثناء التشغيل: تستطيع أن توصل Container بشبكة تنشئها أو تفصله عنها وهو يعمل، كما فعلنا بالأمر docker network connect أعلاه، أما فصله عن الشبكة الافتراضية فيحتاج إلى إيقافه وإعادة إنشائه.
  • الإعدادات: كل شبكة تنشئها لها إعداداتها الخاصة، أما الشبكة الافتراضية فإعداداتها واحدة لكل ال Containers المتصلة بها.

ولذلك يقول التوثيق صراحة إن الشبكة الافتراضية bridge تفصيل قديم في Docker ولا ينصح باستخدامها في الإنتاج Production. والخبر الجيد أنك إذا كنت تستخدم Compose فأنت لا تستخدمها أصلاً، لأن Compose ينشئ شبكة خاصة لكل مشروع كما سيأتي. احذف ما أنشأناه في هذا المثال:

docker rm -f db
docker network rm demo

والصورة التالية تلخص الفرق بين الشبكتين بالمثال الذي نفذناه:

مخطط يقارن بين الشبكة الافتراضية bridge التي يفشل فيها ping db، وشبكة demo التي يجيب فيها سيرفر DNS المدمج 127.0.0.11 بعنوان قاعدة البيانات فينجح الاتصال بالاسم
على الشبكة التي تنشئها يحول DNS المدمج اسم ال Container إلى عنوانه

شبكة host وشبكة none

شبكة host تلغي العزل بين ال Container والسيرفر، فيستخدم ال Container شبكة السيرفر مباشرة، والتطبيق الذي يستمع على المنفذ 80 داخله يستمع على المنفذ 80 في السيرفر نفسه. لذلك لا معنى لنشر المنافذ معها، وإذا كتبت -p أو ports فسوف يتجاهلها Docker ويعرض التحذير Published ports are discarded when using host network mode. وهي تعمل على Docker Engine في Linux، وعلى Docker Desktop من الإصدار 4.34 بعد تفعيلها من الإعدادات. ويحتاجها القليل من التطبيقات، مثل التي تكتشف الأجهزة على شبكتك المنزلية بالبث Broadcast أو التي تتعامل مع عدد كبير جداً من المنافذ، أما باقي التطبيقات فلا تستخدمها معها، والسبب أن ال Container يصبح قادراً على الاستماع على أي منفذ في السيرفر دون أن يظهر ذلك في ملف Compose.

أما شبكة none فهي العكس تماماً، حيث تعزل ال Container عزلاً كاملاً عن السيرفر وعن غيره، فلا يبقى له إلا واجهة loopback، وهي مناسبة لمهمة تعالج ملفات في Volume ولا تحتاج أي اتصال، كأداة تحويل أو ضغط تشغلها مرة واحدة.

شبكة overlay: لأكثر من سيرفر

كل ما سبق يعمل على سيرفر واحد، أما شبكة overlay فتربط عدة سيرفرات عليها Docker في شبكة واحدة، فيكلم ال Container على السيرفر الأول Container على السيرفر الثاني باسمه. وهي تعمل مع Docker Swarm، وهو أداة Docker لإدارة مجموعة سيرفرات Cluster، ولن تحتاجها ما دامت كل خدماتك على سيرفر واحد، وهذا حال أغلب من يستضيف تطبيقاته بنفسه.

شبكة macvlan وشبكة ipvlan: عندما تريد عنواناً من شبكتك المنزلية

في المختبر المنزلي Homelab قد تريد أن يظهر Container على شبكة المنزل كأنه جهاز مستقل، بعنوان مثل 192.168.1.50 يراه ال Router وكل الأجهزة، كما يفعل البعض مع Pi-hole أو Home Assistant. وهذا ما تفعله شبكة macvlan، حيث تعطي ال Container عنوان MAC Address خاصاً به، فيبدو على الشبكة كجهاز حقيقي، أما شبكة ipvlan فتعطيه عنوان IP خاصاً به مع نفس عنوان MAC الخاص بالسيرفر، وهي الخيار عندما يرفض ال Switch أو نقطة الوصول اللاسلكية Access Point أكثر من عنوان MAC على نفس المنفذ.

ولكن لاحظ أمرين قبل أن تختار macvlan: الأول أن ال Container على هذه الشبكة لا يستطيع أن يتصل بالسيرفر نفسه مباشرة، وهذا قيد في نواة Linux Kernel وليس في Docker، والثاني أنك تحتاج إلى حجز نطاق من العناوين لا يوزعه سيرفر DHCP في ال Router حتى لا يتكرر العنوان. لذلك إذا كان التطبيق يعمل خلف Reverse Proxy فشبكة bridge أنسب له، واترك macvlan للتطبيقات التي تحتاج فعلاً أن تكون جهازاً على شبكتك.

المقارنة بين الأنواع

النوعماذا يفعلمتى يناسبك
الشبكة الافتراضية Default Bridgeشبكة bridge واحدة لكل Container لا تحدد له شبكة، والاتصال فيها بالعنوان فقطللتجربة السريعة بالأمر docker run، ولا ينصح بها في الإنتاج
شبكة bridge تنشئها أنت User-defined Bridgeشبكة خاصة على نفس السيرفر مع DNS بالأسماء وعزل عن غيرهاالخيار الطبيعي لكل تطبيق على سيرفر واحد، وهي ما ينشئه Compose
شبكة السيرفر Hostال Container يستخدم شبكة السيرفر مباشرة دون عزلتطبيقات قليلة تحتاج البث على الشبكة المحلية أو عدداً كبيراً من المنافذ
بدون شبكة Noneعزل كامل، لا يوجد إلا loopbackمهام تعالج ملفات ولا تحتاج أي اتصال
Overlayشبكة واحدة تمتد على عدة سيرفراتDocker Swarm وال Clusters
Macvlanعنوان MAC وعنوان IP من شبكتك الفعلية لكل Containerجهاز يجب أن يظهر على شبكة المنزل أو المكتب بعنوانه، مع قيد عدم الاتصال بالسيرفر نفسه
IPvlanعنوان IP من شبكتك الفعلية مع عنوان MAC الخاص بالسيرفرنفس حالة macvlan عندما يقيد ال Switch عدد عناوين MAC

كيف يربط Compose خدمات المشروع ببعضها؟

عندما تنفذ docker compose up ينشئ Compose شبكة bridge اسمها <اسم المشروع>_default ويوصل بها كل خدمات الملف، واسم المشروع Project Name هو اسم المجلد الذي فيه الملف ما لم تحدد غيره بالمفتاح name. لنأخذ مثالاً، وهو ملف المثال الأول بعد أن نحذف منه منفذ قاعدة البيانات، ونقصر منفذ التطبيق على السيرفر نفسه، وذلك في المجلد /opt/shop:

services:
  app:
    image: traefik/whoami:v1.12.0
    ports:
      - "127.0.0.1:8080:80"
  db:
    image: postgres:18.0-alpine
    environment:
      POSTGRES_PASSWORD: change-me
cd /opt/shop
docker compose up -d
docker network ls --filter name=shop
docker compose ps --format 'table {{.Name}}\t{{.Service}}\t{{.Ports}}'

والمخرج سوف يكون كما يلي:

NETWORK ID     NAME           DRIVER    SCOPE
ca89b52d3da5   shop_default   bridge    local
NAME         SERVICE   PORTS
shop-app-1   app       127.0.0.1:8080->80/tcp
shop-db-1    db        5432/tcp

في المخرج أعلاه لاحظ التالي:

  • الشبكة اسمها shop_default، وأسماء ال Containers تبدأ باسم المشروع أيضاً، وهكذا يميز Docker مشروعاً عن آخر على نفس السيرفر.
  • المنفذ في سطر app مكتوب بالصيغة 127.0.0.1:8080->80/tcp، أي منشور على السيرفر نفسه فقط.
  • أما 5432/tcp في سطر db فليس منشوراً على السيرفر، وإنما هو المنفذ الذي تعلن عنه ال Image، ولا يصل إليه إلا من يتصل بنفس الشبكة، وغياب السهم -> هو ما يدلك على ذلك.

الآن سوف نتأكد أن الخدمتين تتصلان ببعضهما بالاسم، وذلك من Container مؤقت نوصله بشبكة المشروع:

docker run --rm --network shop_default alpine:3.24 sh -c 'ping -c 1 db; ping -c 1 app' | grep 'bytes from'

والمخرج سوف يكون كما يلي:

64 bytes from 172.31.0.2: seq=0 ttl=64 time=2.611 ms
64 bytes from 172.31.0.3: seq=0 ttl=64 time=0.416 ms

وبالتالي يكفي أن تكتب في إعداد التطبيق أن قاعدة البيانات هي db على المنفذ 5432، وهذا ما يشرحه توثيق Compose: الاتصال بين الخدمات يستخدم منفذ ال Container، أما المنفذ المنشور على السيرفر فهو للوصول من خارج الشبكة فقط. ولهذا لم يكن هناك أي داع لنشر المنفذ 5432 في المثال الأول.

الشبكات التي تعرفها بنفسك

الشبكة الافتراضية للمشروع تكفي إذا كانت كل الخدمات يجب أن تكلم بعضها، أما إذا أردت أن تفصل بينها فعرف شبكاتك في المفتاح networks أعلى الملف، ثم حدد لكل خدمة الشبكات التي تتصل بها. وهنا ثلاثة إعدادات سوف نستخدمها في النمط الذي نشرحه بعد قليل:

  • الإعداد external: true: الشبكة موجودة مسبقاً ولا يديرها هذا المشروع، فلا ينشئها Compose ولا يحذفها مع docker compose down، ويتوقف بخطأ إذا لم يجدها.
  • الإعداد internal: true: شبكة معزولة عن الخارج، فال Containers عليها تكلم بعضها فقط، ولا تخرج إلى الإنترنت.
  • الإعداد name: يعطي الشبكة اسماً ثابتاً لا يضيف إليه Compose اسم المشروع.

أسماء إضافية للخدمة Aliases

اسم الخدمة هو اسمها على الشبكة، ولكن قد تحتاج اسماً آخر، كأن يبحث التطبيق عن قاعدة البيانات باسم postgres واسم الخدمة عندك db، أو تريد اسماً لا يتكرر على شبكة مشتركة كما سيأتي في قسم المشكلات. وهنا يأتي دور aliases، وهي أسماء إضافية للخدمة على شبكة محددة، ولاحظ أن الاسم الإضافي يعمل على الشبكة التي عرفته فيها فقط:

services:
  web:
    image: traefik/whoami:v1.12.0
    networks:
      proxy:
        aliases:
          - blog-web

networks:
  proxy:
    external: true

كيف ترى الشبكات وما عليها؟

هذه الأوامر سوف تحتاجها كثيراً عند حل المشكلات:

  • docker network ls: قائمة الشبكات ونوع كل منها.
  • docker network inspect proxy: تفاصيل الشبكة، ومنها النطاق Subnet وال Containers المتصلة بها وعنوان كل منها.
  • docker network connect proxy <container> وdocker network disconnect proxy <container>: توصيل Container بشبكة أو فصله عنها وهو يعمل. ولكن تذكر أن هذا التغيير يضيع عندما يعيد Compose إنشاء ال Container، لذلك استخدمه للتجربة، أما الإعداد الدائم فمكانه ملف Compose.

ومخرج docker network inspect طويل، لذلك نفضل أن نطلب منه ال Containers وعناوينها فقط بالصيغة التالية:

docker network inspect proxy --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'

متى تنشر منفذاً ومتى لا تنشره؟

في ملف Compose ثلاث حالات للمنفذ، والفرق بينها هو الفرق بين سيرفر آمن وسيرفر مكشوف:

  • النشر على كل العناوين ports: ["8080:80"]: المنفذ مفتوح للإنترنت، ويتجاوز ufw كما ذكرنا، وتوثيق Compose يحذر من ذلك صراحة. ولا تستخدم هذه الصيغة إلا مع المنفذين 80 و443 الخاصين بال Reverse Proxy.
  • النشر على السيرفر وحده ports: ["127.0.0.1:8080:80"]: المنفذ متاح من السيرفر نفسه فقط، وتصل إليه من جهازك عبر نفق SSH Tunnel. وهذا مناسب لواجهات الإدارة Admin UIs التي لا تريدها على الإنترنت أبداً، ولاحظ أن الإصدارات الأقدم من Docker 28.0 كانت تسمح للأجهزة على نفس الشبكة المحلية بالوصول إلى هذه المنافذ، وهذا سبب إضافي لتحديث Docker.
  • عدم النشر إطلاقاً: لا يوجد سطر ports، وال Container يصل إليه فقط من يتصل بنفس شبكة Docker، وهذا ما نريده لكل تطبيق يعمل خلف Reverse Proxy ولكل قاعدة بيانات.

أما expose فلا ينشر أي شيء على السيرفر، وإنما يعلن أن الخدمة تستمع على منفذ معين، وهو توثيق داخل الملف أكثر منه إعداد، فال Containers على نفس الشبكة تصل إلى أي منفذ يستمع عليه التطبيق سواءً كتبته في expose أو لم تكتبه. لذلك إذا رأيت ملفاً فيه expose: ["3000"] فاعلم أن المنفذ غير منشور، وأن ال Reverse Proxy سوف يصل إليه عبر الشبكة.

الشبكة المشتركة proxy: النمط الذي نستخدمه في الموقع

الآن نجمع كل ما سبق في نمط واحد، والفكرة كما يلي:

  1. شبكة واحدة اسمها proxy ننشئها مرة واحدة خارج أي مشروع.
  2. ال Reverse Proxy (NPM أو Traefik أو Caddy) هو الوحيد الذي ينشر منافذ على كل العناوين، وهي 80 و443 فقط.
  3. كل تطبيق يخدمه ال Reverse Proxy يتصل بشبكة proxy، ولا ينشر أي منفذ.
  4. قاعدة بيانات كل تطبيق على شبكة داخلية خاصة بمشروعه، ولا تتصل بشبكة proxy أبداً.
  5. واجهات الإدارة على 127.0.0.1، أو خلف ال Reverse Proxy بقائمة وصول Access List تسمح بشبكة ال VPN فقط.

والصورة التالية تبين هذا النمط على سيرفر فيه مشروعان:

مخطط يوضح سيرفراً لا ينشر إلا المنفذين 80 و443 لل Reverse Proxy على شبكة proxy المشتركة، ويصل منها إلى التطبيقات بأسمائها، ولكل مشروع شبكة داخلية لقاعدة بياناته بدون طريق إلى الإنترنت، وواجهة الإدارة على 127.0.0.1
شبكة proxy مشتركة لل Reverse Proxy والتطبيقات، وشبكة داخلية لقاعدة بيانات كل مشروع

وقد يتساءل البعض: لماذا شبكة مشتركة يدوية، ولماذا لا يتصل ال Reverse Proxy بشبكة _default الخاصة بكل مشروع؟ والإجابة أن هذا ممكن، ولكن كل تطبيق جديد سوف يحتاج حينها إلى تعديل ملف ال Reverse Proxy وإعادة إنشائه، وسوف يصبح ال Reverse Proxy متصلاً بقواعد بيانات كل المشاريع، أما الشبكة المشتركة فتنشئها مرة واحدة، ويتصل بها كل تطبيق جديد من ملفه هو دون أن تمس ال Reverse Proxy.

إنشاء الشبكة وال Reverse Proxy

أنشئ الشبكة مرة واحدة على السيرفر:

docker network create proxy

وسوف نستخدم في المثال Caddy لأن ملف إعداده قصير، والنمط نفسه ينطبق على Nginx Proxy Manager وTraefik كما في دليل كل منهما، وتجد تثبيت Caddy بالتفصيل في دليل Caddy. والملف /opt/proxy/compose.yaml سوف يكون كما يلي:

services:
  caddy:
    image: caddy:2.11.4
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - proxy

volumes:
  caddy_data:
  caddy_config:

networks:
  proxy:
    external: true

والملف /opt/proxy/Caddyfile:

notes.example.com {
	reverse_proxy notes:80
}

في الإعداد أعلاه لاحظ التالي:

  • المفتاح proxy تحت networks أعلى الملف مع external: true يعني أن Compose يستخدم الشبكة التي أنشأناها كما هي، ولا يضيف إلى اسمها اسم المشروع.
  • السطر reverse_proxy notes:80 يستخدم اسم الخدمة notes ومنفذها داخل ال Container، وليس أي منفذ على السيرفر، وفي NPM تكتب نفس الاسم في حقل Forward Hostname.
  • المنفذان 80 و443 هما الوحيدان المنشوران على كل العناوين في السيرفر كله.

التطبيق وقاعدة بياناته

سوف نستخدم whoami مكان تطبيقك، فهو تطبيق صغير يعرض عناوينه وتفاصيل الطلب الذي وصله، ومعه قاعدة بيانات PostgreSQL. والملف /opt/notes/compose.yaml سوف يكون كما يلي:

services:
  notes:
    image: traefik/whoami:v1.12.0
    restart: unless-stopped
    networks:
      - proxy
      - backend

  db:
    image: postgres:18.0-alpine
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: change-me
    networks:
      - backend

networks:
  proxy:
    external: true
  backend:
    internal: true

في الإعداد أعلاه لاحظ التالي:

  • لا يوجد سطر ports في الملف كله.
  • التطبيق notes متصل بشبكتين: proxy حتى يصل إليه Caddy، وbackend حتى يصل هو إلى قاعدة البيانات.
  • قاعدة البيانات db على شبكة backend فقط، وهذه الشبكة internal: true، فلا يصل إليها Caddy ولا أي تطبيق آخر على السيرفر، ولا تخرج هي إلى الإنترنت.
  • الشبكة backend ليست external، لذلك ينشئها Compose باسم notes_backend ويحذفها مع docker compose down، وكل مشروع آخر يستطيع أن يسمي شبكته backend دون أي تعارض.
  • كلمة المرور هنا للمثال فقط، أما في الإنتاج فضعها في ملف .env وولدها بالأمر openssl rand -hex 32.

الآن شغل المشروعين، ثم اعرض ما على كل شبكة:

cd /opt/proxy && docker compose up -d
cd /opt/notes && docker compose up -d
docker network inspect proxy --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
docker network inspect notes_backend --format 'internal={{.Internal}} {{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'

والمخرج سوف يكون كما يلي:

notes-notes-1 172.31.0.3/16
caddy 172.31.0.2/16

internal=true notes-notes-1 192.168.16.2/20
notes-db-1 192.168.16.3/20

ولاحظ أن قاعدة البيانات لا تظهر على شبكة proxy أبداً، وأن التطبيق له عنوانان، واحد في كل شبكة.

لنتأكد أن العزل يعمل فعلاً

أولاً: هل يصل Caddy إلى التطبيق باسمه؟ نختبر ذلك من داخل Container ال Reverse Proxy نفسه، لأن هذا هو الطريق الذي سوف يسلكه كل طلب:

docker exec caddy wget -qO- http://notes/ | head -n 5

والمخرج سوف يكون كما يلي:

Hostname: 5ee6b2628a71
IP: 127.0.0.1
IP: ::1
IP: 192.168.16.2
IP: 172.31.0.3

ثانياً: هل يصل Caddy إلى قاعدة البيانات؟ يجب ألا يصل، لأنه ليس على شبكة backend:

docker exec caddy ping -c 1 -W 2 db
ping: bad address 'db'

ثالثاً: هل تخرج قاعدة البيانات إلى الإنترنت؟ يجب ألا تخرج، لأن شبكتها داخلية:

cd /opt/notes
docker compose exec db wget -q -T 5 -O /dev/null https://example.com
docker compose exec db ip route

والمخرج سوف يكون كما يلي:

wget: bad address 'example.com'
192.168.16.0/20 dev eth0 scope link  src 192.168.16.3

ولاحظ أن جدول التوجيه Routing Table في قاعدة البيانات فيه سطر واحد لشبكتها، ولا يوجد فيه مخرج افتراضي default via، وهذا معنى internal: true. وهذه الطبقة تحميك في حالتين: إذا تسرب منفذ قاعدة البيانات بالخطأ فلا يوجد له طريق من الخارج، وإذا اخترق أحد قاعدة البيانات فلا يستطيع أن يرسل البيانات منها إلى الإنترنت مباشرة.

⚠️
لا تجعل شبكة التطبيق نفسه internal إذا كان يحتاج الإنترنت، مثل إرسال البريد أو جلب التحديثات أو تسجيل الدخول عبر Google، والسبب أن التطبيق في هذا النمط يخرج إلى الإنترنت عبر شبكة proxy، أما شبكة backend فهي لقاعدة البيانات وأمثالها، مثل Redis، التي لا تحتاج إلا أن يكلمها التطبيق.

ماذا لو نسيت إنشاء الشبكة؟

على سيرفر جديد، أو بعد استعادة نسخة احتياطية، قد تنسى الأمر docker network create proxy، وعندها يتوقف docker compose up بالخطأ التالي:

network proxy declared as external, but could not be found

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

وماذا عن واجهات الإدارة؟

واجهات مثل Portainer ولوحة NPM نفسها لها طريقان آمنان، الأول أن تنشرها على 127.0.0.1 وتصل إليها بنفق SSH، والثاني أن تضعها على شبكة proxy بدون نشر أي منفذ، ثم تجعل ال Reverse Proxy يقبل طلباتها من شبكة ال VPN فقط، كما في دليل WireGuard. أما نشرها على كل العناوين ثم الاعتماد على كلمة المرور وحدها فلا تقم به، والسبب أن من يتحكم في Portainer يتحكم في السيرفر كله.

التحقق من النجاح

  • الأمر docker ps --format '{{.Names}}\t{{.Ports}}' لا يعرض أي منفذ منشور على 0.0.0.0 أو [::] إلا 80 و443 الخاصين بال Reverse Proxy، وكل ما سواهما على 127.0.0.1 أو بدون نشر.
  • الأمر sudo ss -ltnp على السيرفر يؤكد نفس النتيجة من جهة نظام التشغيل.
  • الأمر docker network inspect proxy يعرض ال Reverse Proxy والتطبيقات فقط، ولا يعرض أي قاعدة بيانات.
  • من Container ال Reverse Proxy يعمل wget -qO- http://<service>/ لكل تطبيق، ويفشل ping db.
  • من جهاز خارج السيرفر لا يستجيب أي منفذ غير 80 و443، وتستطيع التأكد بالأمر nc -zv 203.0.113.10 5432 مثلاً.

مشكلات شائعة وحلولها

خطأ 502 أو 504 من ال Reverse Proxy

أشهر سبب لهذا الخطأ أن التطبيق ليس على شبكة proxy، كأن تنسى إضافتها إلى ملفه، أو تعيد إنشاءه من ملف قديم. ولنكسر الإعداد عمداً حتى نرى الخطأ، حيث سوف نفصل التطبيق عن الشبكة ثم نحاول الوصول إليه من Caddy:

docker network disconnect proxy notes-notes-1
docker exec caddy wget -qO- http://notes/
wget: bad address 'notes'

وفي المتصفح سوف يظهر 502 Bad Gateway، وفي سجل Caddy سوف تجد السبب، وهو أن الاسم notes لم يعد له عنوان، وتنتهي الرسالة بعبارة i/o timeout أو no such host بحسب سيرفر DNS على جهازك:

docker logs caddy 2>&1 | grep -o '"msg":"dial tcp[^"]*"' | tail -n 1
"msg":"dial tcp: lookup notes: i/o timeout"

والحل أن تعيد التطبيق إلى الشبكة، والأفضل أن تصحح ملفه ثم تنفذ docker compose up -d، وأسباب 502 الأخرى هي منفذ خاطئ (المنفذ المنشور على السيرفر بدلاً من منفذ ال Container)، أو تطبيق يستمع على 127.0.0.1 داخل ال Container الخاص به بدلاً من 0.0.0.0، وتجد تفصيلها في دليل NPM.

أما الخطأ 504 Gateway Timeout فيظهر غالباً مع Traefik عندما يتصل التطبيق بشبكتين، حيث قد يأخذ Traefik عنوان التطبيق من الشبكة الأخرى التي لا يتصل بها هو، فينتظر حتى تنتهي المهلة Timeout. والحل أن تحدد الشبكة في إعداد مزود Docker بالخيار providers.docker.network: proxy كما في دليل Traefik، أو لكل تطبيق بالوسم traefik.docker.network=proxy.

تعارض الأسماء على الشبكة المشتركة

اسم الخدمة على شبكة المشروع الخاصة لا يتعارض مع غيره، ولكن على شبكة proxy المشتركة تلتقي خدمات كل المشاريع، فإذا كان لديك مشروعان blog وshop وفي كل منهما خدمة اسمها web متصلة بشبكة proxy، فسوف يعيد DNS الخاص بالشبكة العنوانين معاً:

docker exec caddy nslookup web
Server:		127.0.0.11
Address:	127.0.0.11:53

Non-authoritative answer:
Name:	web
Address: 172.31.0.5
Name:	web
Address: 172.31.0.4

وبالتالي يذهب الطلب مرة إلى المدونة ومرة إلى المتجر، وهي من أغرب المشكلات حين تواجهها أول مرة، لأن الموقع يعمل ثم يتغير محتواه عند تحديث الصفحة. والحل أن يكون الاسم الذي يستخدمه ال Reverse Proxy فريداً على شبكة proxy، وذلك بأحد طريقين: أن تسمي الخدمة باسم التطبيق نفسه (notes وليس app أو web)، أو أن تضيف لها اسماً فريداً بال aliases كما شرحنا أعلاه، ثم تستخدم blog-web في إعداد ال Reverse Proxy. ولنفس السبب لا تضع قاعدة البيانات على شبكة proxy، فلو فعلت ذلك في مشروعين لأصبح الاسم db يشير إلى قاعدتين مختلفتين.

ال Container على شبكتين والمخرج الافتراضي خاطئ

ال Container المتصل بشبكتين له مخرج افتراضي Default Gateway واحد، ومنه تخرج كل حركة لا تتجه إلى إحدى الشبكتين، والتوثيق يقول إن Docker هو من يختار هذا المخرج، وقد يتغير اختياره كلما تغيرت شبكات ال Container. وفي أغلب التطبيقات لا يهمك من أين تخرج الحركة، ولكن في تطبيق مثل wg-easy الذي يمرر حركة عملاء ال VPN إلى الإنترنت فالأمر مهم، حيث تنجح المصافحة ولا يعمل الإنترنت داخل النفق، كما شرحنا في دليل WireGuard. والحل خياران في Compose: gw_priority (من Compose 2.33.1) لتحديد الشبكة التي يكون منها المخرج الافتراضي، وinterface_name (من Compose 2.36.0) لتثبيت اسم واجهة الشبكة Network Interface لكل شبكة:

services:
  vpn:
    image: alpine:3.24
    command: sleep infinity
    networks:
      wg:
        interface_name: eth0
        gw_priority: 1
      proxy:
        interface_name: eth1

networks:
  proxy:
    external: true
  wg:
    ipam:
      config:
        - subnet: 10.42.42.0/24
docker compose exec vpn ip route

والمخرج سوف يكون كما يلي:

default via 10.42.42.1 dev eth0
10.42.42.0/24 dev eth0 scope link  src 10.42.42.2
172.31.0.0/16 dev eth1 scope link  src 172.31.0.3

ولاحظ أن المخرج الافتراضي عبر eth0 وهي شبكة wg، أما شبكة proxy فلها سطر واحد على eth1 للوصول إلى ال Containers عليها فقط. والقيمة الافتراضية ل gw_priority هي 0، والشبكة صاحبة القيمة الأعلى هي المخرج، لذلك يكفي أن تعطي الشبكة التي تريدها القيمة 1.

نفاد نطاقات العناوين Address Pools

كل شبكة تنشئها، وكل مشروع Compose بشبكته الافتراضية، يأخذ نطاقاً من العناوين، والنطاقات الافتراضية في Docker هي 172.17.0.0/16 حتى 172.31.0.0/16 بحجم /16 لكل شبكة، ثم 192.168.0.0/16 مقسمة إلى شبكات بحجم /20، أي نحو 31 شبكة فقط. فإذا كان على سيرفرك مشاريع كثيرة، وكل مشروع ينشئ شبكتين أو ثلاثاً، فسوف تنفد النطاقات ويفشل إنشاء أي شبكة جديدة بالخطأ all predefined address pools have been fully subnetted.

وهناك مشكلة أخرى في نفس النطاقات تخص المختبر المنزلي وال VPN، وهي أن Docker قد يأخذ نطاقاً مثل 192.168.0.0/20 وشبكة منزلك أو مكتبك على 192.168.1.0/24، فيصبح السيرفر غير قادر على الرد على الأجهزة في هذه الشبكة لأنه يظن أنها داخل Docker. والحل في الحالتين أن تحدد النطاقات بنفسك في /etc/docker/daemon.json بالمفتاح default-address-pools، وبشبكات أصغر بحجم /24 يتسع النطاق الواحد /16 ل 256 شبكة:

sudo tee /etc/docker/daemon.json <<'EOF'
{
  "log-driver": "local",
  "log-opts": {
    "max-size": "20m",
    "max-file": "5"
  },
  "live-restore": true,
  "default-address-pools": [
    { "base": "172.17.0.0/16", "size": 24 },
    { "base": "172.18.0.0/16", "size": 24 }
  ]
}
EOF
sudo dockerd --validate --config-file /etc/docker/daemon.json
sudo systemctl restart docker

في الإعداد أعلاه لاحظ التالي:

  • الملف يكمل إعداد السجلات وlive-restore من دليل التثبيت، فإذا كان لديك ملف مختلف فأضف المفتاح default-address-pools إليه ولا تستبدله.
  • الحجم 24 يعني أن كل شبكة تأخذ 254 عنواناً، وهذا يكفي أي مشروع على سيرفر واحد.
  • اختر نطاقات لا تتداخل مع شبكة المنزل أو المكتب أو ال VPN التي يصل منها المستخدمون.
  • الإعداد الجديد يسري على الشبكات الجديدة فقط، أما الموجودة فتبقى على نطاقها حتى تحذفها وتنشئها من جديد، أي docker compose down ثم up -d لكل مشروع، وأعد إنشاء proxy بعد إيقاف كل من يستخدمها.

ملاحظات IPv6

شبكات bridge التي تنشئها لا تحصل على عناوين IPv6 إلا إذا فعلت ذلك بالخيار --ipv6 أو enable_ipv6: true في Compose، وهذا يكفي في أغلب الحالات، لأن ال Reverse Proxy هو من يستقبل الزائر. ولكن انتبه لحالة واحدة: إذا كان لنطاقك سجل AAAA وشبكة ال Reverse Proxy بدون IPv6، فإن Docker يمرر طلبات IPv6 عبر ال userland-proxy إلى عنوان IPv4 الخاص بال Container، وبالتالي يرى ال Reverse Proxy كل زوار IPv6 قادمين من عنوان داخلي واحد، فتضيع عناوينهم الحقيقية في السجلات وفي قوائم الوصول. والحل إما أن تفعل IPv6 في شبكة proxy نفسها، وإما ألا تضيف سجل AAAA حتى تفعله، وتجد أثر ذلك على التطبيقات في دليل تطبيقك خلف Reverse Proxy.

وماذا عن HAProxy أو Reverse Proxy خارج Docker؟

إذا كان ال Reverse Proxy مثبتاً على السيرفر نفسه وليس في Container، كما في طريقة التثبيت من حزمة Ubuntu التي يذكرها دليل HAProxy بديلاً عن ال Container، أو كان يعمل على سيرفر آخر يوزع الطلبات على عدة سيرفرات، فلا يستطيع أن يستخدم أسماء ال Containers، والسبب أن DNS المدمج يعمل داخل شبكات Docker فقط، وفي هذه الحالة انشر منفذ كل تطبيق على 127.0.0.1 فقط واجعل ال Reverse Proxy يتصل به هناك. وإذا كنت تقارن بين هذه الأدوات فراجع المقارنة بين Nginx Proxy Manager وTraefik وHAProxy.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • المنفذ المنشور بدون عنوان مفتوح على الإنترنت ويتجاوز ufw، فلا تنشر على كل العناوين إلا 80 و443 الخاصين بال Reverse Proxy.
  • الشبكة الافتراضية bridge لا توفر أسماء ولا عزلاً، أما الشبكة التي تنشئها أنت، ومنها شبكة كل مشروع Compose، فتوفر الاثنين.
  • أنشئ شبكة proxy مرة واحدة، وصل بها ال Reverse Proxy والتطبيقات فقط بالإعداد external: true، دون أي منفذ منشور للتطبيقات.
  • ضع قاعدة بيانات كل مشروع على شبكة خاصة به بالإعداد internal: true، ولا تصلها بشبكة proxy أبداً.
  • اجعل الاسم الذي يستخدمه ال Reverse Proxy فريداً على الشبكة المشتركة، وثبت المخرج الافتراضي ب gw_priority عندما يهمك من أين تخرج الحركة.
  • حدد default-address-pools بنطاقات لا تتداخل مع شبكاتك قبل أن تكثر المشاريع على السيرفر.

سجل التحديثات

  • أكتوبر 2026: كتابة الدليل واختبار أمثلته على Docker Engine 29.8.0 وDocker Compose v5.5.1.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.