القاعدة التي كتبتها، والقاعدة التي كتبها Docker
هناك برنامجان يعدّلان جدار الحماية نفسه انطلاقًا من افتراضين مختلفين، ولا يخبرك بذلك سوى أحدهما. يكتب ufw قواعده في سلسلة INPUT من جدول filter — وهو المسار الذي تسلكه الحزمة حين تكون وجهتها المضيف نفسه. أما Docker فيكتب في جدول nat وفي FORWARD — وهو المسار الذي تسلكه الحزمة حين تكون وجهتها مكانًا آخر.
تتبّع حزمة واحدة وستتضح الفجوة على الفور. يفتح شخص في بلد آخر اتصالًا بعنوانك على المنفذ 5432. تصل الحزمة، وأول ما تقابله هو nat PREROUTING، الذي يرسلها إلى سلسلة Docker المسمّاة DOCKER. هناك تعيد قاعدة DNAT كتابة الوجهة إلى 172.17.0.2:5432 — أي الحاوية. لم تعد الحزمة موجَّهة إلى خادمك، فتقوم النواة بتوجيهها بدلاً من تسليمها محليًا: تمر عبر FORWARD، حيث ركّب Docker مسبقًا قاعدة ACCEPT لحركة المرور المتجهة إلى منفذ منشور. لا تمر الحزمة في أي لحظة من تلك الرحلة عبر INPUT، وهي السلسلة الوحيدة التي يُصفّيها ufw. قاعدتك لم تكن خاطئة أبدًا. لم تُستشَر أبدًا.
وهذا أيضًا سبب كون العرض مربكًا إلى هذا الحد. يستمر ufw status verbose في الإبلاغ عن Default: deny (incoming)، مع عدم السماح بشيء سوى 80 و443 ومنفذ SSH الخاص بك، بينما يحتفظ ماسح في قارة أخرى بجلسة مفتوحة إلى قاعدة بياناتك. العبارتان صحيحتان معًا. جدار الحماية يفعل بالضبط ما ضبطته، على حركة المرور التي أُعطيت له، والحركة التي تهمّ لا تُعطى له أصلاً.
لا شيء من هذا عِلّة برمجية، ولا شيء منه إهمالًا من Docker. على أي محرّك حاويات أن يبرمج قواعد NAT والتمرير وإلا تعذّر على الحاويات الوصول إلى الشبكة أصلاً، ولا يمكنه أن يخمّن بأمان أيّ قواعد جدار حماية مضيفك كانت مقصودة لتنطبق عليها. فيفعل الأمر الصادق: يدير سلاسله الخاصة، ويمنحك سلسلة مخصّصة — DOCKER-USER، تُقيَّم قبل كل شيء آخر في FORWARD — يعِد ألا يكتب فوقها أبدًا. الفجوة ليست في وجود الآلية. الفجوة أن -p 5432:5432 تُقرأ وكأنها "إتاحة هذا" بينما تعني "نشر هذا على كل عنوان يجيب عليه هذا الجهاز، متجاوزًا جدار الحماية الذي أمضيت ساعة تكتبه".
كيف يبدو المنفذ المكشوف من الخارج
ينتبه الإنترنت أسرع مما يتوقع الناس. تُمسح مساحات عناوين الاستضافة باستمرار وبالكامل — لا من شخص أبدى اهتمامًا بك تحديدًا، بل من زواحف تجارية ومشاريع بحثية وروبوتات انتهازية تجتاح كل عنوان قابل للتوجيه على كل منفذ يثير اهتمامها، ثم تنشر النتائج أو تبيعها. الفاصل الزمني بين تنفيذ docker compose up -d وأول اتصال غير مرغوب فيه بمنفذ قاعدة بيانات نُشر للتو يُقاس بالساعات. لم يكن على أحد تخمين اسم مضيفك. لم يكن على أحد معرفة اسمك. كان عنوانك ضمن تلك المساحة المفحوصة.
ما يجدونه يعتمد كليًا على ما نشرته، والحالات الشائعة كئيبة. حاوية PostgreSQL أو MySQL بدأت من مقتطف بدء سريع في صورة جاهزة، مع بقاء كلمة المرور التافهة من ذلك المقتطف كما هي. نسخة Elasticsearch أو MongoDB لم تُضبط قط لتطلب مصادقة، لأنها لم تكن مفترَضة لتكون قابلة للوصول إلا من حاوية التطبيق. خدمة memcached تجيب أي أحد — وهذا ليس تسريب بيانات فحسب بل مُضخِّم يمكن توجيهه نحو شخص آخر، ما يحوّل خادمك إلى مشارك في نوع الفيضان الموصوف في دليل الساعة الأولى لهجمات DDoS لدينا. لوحة إدارة أو لوحة تحكم لطابور رسائل افترضت وجود شبكة خاصة. نقطة نهاية مقاييس (metrics) تسرد بهدوء كل خدمة داخلية واسم مضيف وإصدار تُشغّله.
يستحق الضرر من الدرجة الثانية أن يُذكر، لأن الضرر من الدرجة الأولى ليس الفاتورة كاملة. قاعدة بيانات يمكن الوصول إليها من الإنترنت ليست قابلة للقراءة فحسب — ففي معظم المحرّكات هي قابلة أيضًا للكتابة، ما يعني أن الدخيل لا يحتاج إلى استغلال إضافي (exploit) كي يُرسّخ وجوده، ويمكن إقناع عدة محرّكات بكتابة ملفات أو تنفيذ أوامر على المضيف انطلاقًا من جلسة ذات امتيازات. وعلى جهاز اخترته تحديدًا لأنه لا يحمل هوية، يصبح موطئ القدم غير المُصادَق عليه أيضًا رابطًا يعود إلى كل ما يلامسه ذلك الجهاز: هدف النسخ الاحتياطي الذي يستطيع بلوغه، والمفاتيح في متغيرات بيئته، والحاويات الأخرى على شبكة bridge الخاصة به.
الجزء المزعج أن شيئًا من هذا لا يُعلن عن نفسه. لا يوجد سطر سجلّ يقول "تم تجاوز جدار حمايتك". الخدمة تعمل، والتطبيق يتصل، والموقع يعمل، والإشارة الخارجية الوحيدة هي عدّاد اتصالات لا يراقبه أحد. يُكتشَف هذا الانكشاف إمّا بواسطتك أنت، عن قصد، خلال العشر دقائق التالية — أو بواسطة شخص آخر، في الوقت الذي يناسبه هو.
SP·03اقرأ ما تنشره فعليًا
ابدأ بما يظن المحرّك أنه يفعله. يطبع docker ps عمود PORTS، والتمييز الوارد فيه هو موضوع هذا الدليل بأكمله: 0.0.0.0:5432->5432/tcp يعني كل عنوان على الجهاز، و127.0.0.1:5432->5432/tcp يعني loopback فقط، أما 5432/tcp المجرّد بلا سهم فيعني أن المنفذ مكشوف لحاويات أخرى ولم يُنشر في أي مكان. اقرأ كل سطر من ذلك العمود على كل حاوية قبل أن تغيّر أي شيء.
بعد ذلك انظر إلى الـ sockets عبر ss -tulpen. في تثبيت افتراضي سترى docker-proxy ممسكًا بالمنافذ المنشورة، لأن Docker لا يزال يشغّل عملية صغيرة في مساحة المستخدم (userland) لكل منفذ منشور. وهنا الفخ الذي يكلّف الناس بعد ظهر كامل: فإذا كان userland-proxy معطَّلًا في خدمتك (daemon) — وهو تغيير ضبط شائع، وبعض التوزيعات تُشحن به افتراضيًا — فلا يوجد أي socket مستمع على المضيف على الإطلاق. لا يُظهر ss شيئًا، ولا يُظهر lsof شيئًا، والمنفذ يبقى مفتوحًا على مصراعيه، لأن قاعدة DNAT في النواة تؤدي العمل دون أن تحتاج أي عملية إلى الإمساك بالعنوان. إخراج ss الصامت ليس دليلًا على أن المنفذ مغلق.
لذا اقرأ القواعد نفسها. يسرد iptables -t nat -S DOCKER سطر DNAT واحدًا لكل منفذ منشور، ويحمل كل سطر الإجابة التي تريدها: قاعدة تحمل -d 127.0.0.1/32 هي نشر على loopback، وقاعدة بلا قيد على الوجهة تنطبق على كل عنوان يملكه الجهاز. افعل الأمر نفسه مع ip6tables، لأن العائلتين تُضبطان بشكل مستقل، وقد يكون الجهاز محكمًا في واحدة ومفتوحًا في الأخرى.
وأخيرًا — وهذه هي الخطوة الوحيدة التي تثبت شيئًا فعليًا — انظر إلى الجهاز من مكان آخر. كل أمر مما سبق يعمل على المضيف ويرث نظرة المضيف نفسه إلى شبكته. حركة مرور loopback تتخطى السلاسل التي تهمّ، لذا فإن نجاح curl 127.0.0.1:5432 لا يخبرك بشيء عن قدرة شخص غريب على فعل الأمر نفسه، وفشله يخبرك بأقل من ذلك حتى. الاختبار الحاسم هو مسح من جهاز مختلف على شبكة مختلفة، على كلتا عائلتي IP. كل ما قبله مجرد فرضية.
الربط بـ loopback، والفرق بين ports وexpose
أصغر إصلاح مفيد طوله أحد عشر حرفًا. تخبر -p 127.0.0.1:5432:5432 برنامج Docker أن يكتب قاعدة DNAT الخاصة به بقيد على الوجهة، بحيث لا تنطبق إعادة الكتابة إلا على حركة مرور كانت محلية أصلاً. حزمة بعيدة موجَّهة إلى عنوانك العام لم تعد تطابق القاعدة، ولا تُمرَّر إلى الحاوية، وتصل أخيرًا إلى حيث افترضت دومًا أنها ستصل: INPUT، حيث يرفضها ufw. في ملف Compose الأمر نفسه هو ports: ["127.0.0.1:5432:5432"]، وعلامات الاقتباس هنا مهمة — فقيمة غير مقتبَسة تحتوي نقطتين رأسيتين حادثُ تحليل (parsing) ينتظر أن يقع.
لكن السؤال الأفضل هو لماذا نُشر المنفذ أصلاً. الحاويات المرتبطة بالشبكة المعرَّفة من المستخدم نفسها تصل إحداها إلى الأخرى مباشرة، باسم الخدمة، على منفذ الحاوية نفسه، دون أي نشر من أي نوع. تطبيقك لا يتصل بـ127.0.0.1:5432؛ بل يتصل بـpostgres:5432، الذي يحلّه DNS المدمج في Docker إلى عنوان على شبكة bridge الخاصة. لا تحتاج قاعدة بيانات في هذا الترتيب إلى سطر ports: — لا نسخة loopback منه، ولا أي نسخة. أكثر منفذ منشور أمانًا هو المنفذ الذي حذفته. أبقِ على ports: للخدمة أو الخدمتين اللتين تواجهان العامة فعلاً، ودع كل شيء آخر يتحدث على الشبكة الخاصة.
هنا أيضًا يُساء فهم expose:. فهو لا ينشر شيئًا ولا يفتح شيئًا؛ إنه توثيق يسجّل على أي منفذ تستمع خدمة ما، وليس له أي أثر على جدار الحماية في أي من الاتجاهين. يضيفه الناس أملاً في أنه النسخة الآمنة من ports:، وهو كذلك فعلاً — بالمعنى نفسه الذي يكون فيه التعليق النسخة الآمنة من الشيفرة. إن أردت خدمة يصل إليها جيرانها فقط، فأنت لا تحتاج إلى expose:؛ بل تحتاج إلى غياب ports:.
حدّان صادقان للربط بـ loopback. أولًا، هو يحمي حدود المضيف لا الحيّ المحيط به: الحاويات على شبكة bridge نفسها لا تزال تصل إحداها إلى الأخرى بحرّية، فحاوية واجهة أمامية مخترَقة أمامها مسار واضح إلى قاعدة بيانات لا تنشر شيئًا. قسّم الخدمات على شبكات منفصلة وضع علامة internal: true على شبكة الطرف الخلفي حين يهمّ نطاق الضرر. ثانيًا، 127.0.0.1 عنوان IPv4 ولا يقيّد إلا IPv4؛ فإذا كان لدى المضيف /64 قابل للتوجيه — وكل خطة هنا تأتي بواحد — ففكّر في IPv6 بشكل منفصل، واختبره بشكل منفصل.
DOCKER-USER: السلسلة الموجودة لهذا بالتحديد
الربط بـ loopback يُصلح الحاويات التي تتذكّرها. أما DOCKER-USER فهو كيف تتوقف عن أن تكون على بُعد -p واحدة من الحادثة التالية. يُثبّته Docker كأول قفزة في FORWARD، قبل قواعد القبول الخاصة به، و — خلافًا لكل شيء آخر في سلاسله — يترك محتوياته دون مساس عبر عمليات إعادة التشغيل والترقيات والحاويات الجديدة. إنها المكان المعتمَد للسياسة التي لا يستطيع المحرّك استنتاجها: أي المصادر يُسمح لها بالوصول إلى حاويات هذا المضيف على الإطلاق.
النمط هو ثلاث قواعد على الواجهة العامة، والترتيب هو كل شيء هنا. أولًا، أعِد (RETURN) حركة المرور المؤسَّسة والمرتبطة، حتى تستمر ردود الاتصالات التي فتحتها حاوياتك في التدفق. ثانيًا، أعِد المصادر التي تريد السماح لها فعلاً بالدخول — عنوان المكتب، مضيف المراقبة، خادم نظير. ثالثًا، أسقِط كل ما تبقى قادمًا من الإنترنت. الخطأ الكلاسيكي هنا هو ضبط قاعدة conntrack بشكل معكوس: ضَع DROP أولًا وسيموت كل اتصال صادر من كل حاوية عند حزمة العودة، وهو ما يظهر على شكل "Docker عطّل DNS وتثبيت الحزم" ويرسل الناس للبحث في المكان الخطأ تمامًا.
تفصيلان تشغيليان يقرران ما إذا كان هذا سينجو من ملامسة الواقع. يجب حصره بالواجهة العامة بالاسم — -i eth0، أو أيًا كان ما يُبلغ عنه ip route get 1.1.1.1 على جهازك — وإلا ستُسقِط أيضًا حركة المرور بين جسور (bridges) جهازك نفسه. ويجب إعادة تطبيقه بعد كل إعادة تشغيل، بعد أن تكون خدمة Docker قد أنشأت السلسلة. وحدة systemd من نوع oneshot مرتَّبة بـAfter=docker.service هي الصيغة التي يُعتمَد عليها؛ كما يصلح الحفظ عبر netfilter-persistent، طالما تقبل أن استعادة مجموعة قواعد كاملة بينما يعيد Docker بناءها في الوقت نفسه سباقٌ ينبغي أن تتحقق منه لا أن تفترضه.
ثم نصف IPv6. إذا طبع ip6tables -S DOCKER-USER سلسلة، انسخ كل قاعدة إليها بصورة مطابقة. وإذا أعطى خطأً، فإن خدمتك (daemon) لا تدير قواعد IPv6 إطلاقًا — وهذا لا يخبرك بشيء عن وصول حركة مرور IPv6 إلى حاوياتك من عدمه، بل فقط أن Docker لا يُصفّيها. لا تحاول التوصل إلى إجابة بالاستنتاج هناك؛ فالمسارات تختلف باختلاف الإصدار، وإعدادات الخدمة، والتوزيعة، والاستنتاج الخاطئ الواثق أسوأ من غيابه. امسح نفسك بـnmap -6 وصدِّق النتيجة.
شيء واحد يجب ألّا تفعله: اللجوء إلى "iptables": false في /etc/docker/daemon.json. فهذا يوقف Docker عن لمس جدار الحماية، ويوقف أيضًا ضبط NAT الخاص بالحاويات والتمويه (masquerading) الصادر والعزل بين الشبكات من قِبل أي شيء. أنت بذلك لم تُزل المشكلة، بل ورثت المهمة — يدويًا، لكل حاوية، إلى الأبد. على VPS واحد، الخيار الصادق هو ترك Docker يدير سلاسله بنفسه وأن تتولى أنت DOCKER-USER.
الباب الآخر: الـ socket، وما الذي يعمل بصلاحيات root
كل ما سبق كان عن الحزم الواصلة. هذا القسم عمّا تجده تلك الحزم، وهناك عنصر واحد أسوأ بكثير من البقية لدرجة أنه يستحق أن يُذكر بمفرده: تركيب /var/run/docker.sock داخل حاوية يعادل منح تلك الحاوية صلاحيات root على المضيف. ليس "قريبًا من" ذلك. بل مُعادِلًا له تمامًا. أي شيء يستطيع التخاطب مع ذلك الـ socket يمكنه تشغيل حاوية جديدة ذات امتيازات مع تركيب نظام ملفات المضيف بأكمله داخلها، ومن هناك قراءة كل مفتاح، وكتابة كل ملف، وتثبيت أي شيء يريده. كثير من الصور المريحة تطلب هذا الوصول — لوحات تحكم، وأدوات تحديث تلقائي، ووسطاء عكسيون بخاصية اكتشاف الخدمات. عامل ذلك الطلب على أنه قرار بالوثوق بالصورة بقدر ثقتك بصَدفة root الخاصة بك، وطبّق الحذر نفسه مع واجهة Docker البرمجية عبر TCP: خدمة (daemon) غير موثَّقة على المنفذ 2375 هي الباب نفسه، مفتوحًا للإنترنت كله.
بعد ذلك، تمنحك بيئة تشغيل الحاويات أربعة تخفيضات رخيصة، ولا يتطلب أي منها إعادة هندسة أي شيء. اعمل بمستخدم غير root عبر user: "1000:1000"، لأن الافتراضي هو root داخل الـ namespace وتلك نقطة انطلاق كل إفلات (escape). فعِّل security_opt: ["no-new-privileges:true"]، الذي يمنع أي ملف تنفيذي setuid داخل الصورة من اكتساب صلاحيات أكثر مما بدأت به العملية أبدًا. أسقِط كل القدرات (capabilities) عبر cap_drop: [ALL] وأعد فقط ما تحتاجه الخدمة إثباتًا — معظم تطبيقات الويب لا تحتاج إلى شيء منها. ثبِّت نظام الملفات الجذر بـread_only: true وامنحه tmpfs صغيرًا للاستخدام المؤقت، وهو ما يحوّل "وضع صَدفة ويب في مجلد التطبيق" من خطوة إلى طريق مسدود.
ينطبق المنطق نفسه على ما تُسلَّمه الحاوية. الأسرار المُمرَّرة كمتغيرات بيئة مرئية لأي شيء يستطيع قراءة بيئة العملية، وتُنسَخ بأمانة إلى إخراج docker inspect وإلى أي سجل أو تقرير أعطال يُفرغ الإعدادات؛ أما ملف مُركَّب للقراءة فقط في مسار معروف فأقل راحة وأقل تسرّبًا بكثير. والحاوية لا تحتاج شبكة أوسع من عملها: عامل (worker) لا يتحدث إلا مع قاعدة البيانات لا شأن له بالقدرة على فتح اتصالات بالإنترنت على الإطلاق.
إذا شعرت أن كل هذا مقاومة للإعداد الافتراضي، فهذه قراءة منصفة، وهي الحجة لصالح Docker أو Podman بلا صلاحيات جذرية (rootless) — حيث تعمل الخدمة (daemon) والحاويات بصفتها مستخدمًا بلا امتيازات، ويضعك إفلات من حاوية بصفتك ذلك المستخدم لا بصفتك root، وتُمسَك المنافذ المنشورة بعملية عادية في مساحة المستخدم على المضيف، ما يعني أن قواعد ufw لديك تنطبق عليها بالطريقة المعتادة. المقايضة حقيقية: بعض القدرات، وبعض برامج تشغيل التخزين، وبعض حيل الشبكات تتصرف بشكل مختلف أو لا تعمل إطلاقًا. يستحق الأمر أن تعرف أن هذا الخيار موجود وما الذي يمنحه، بدلاً من أن تكتشف بعد وقوع حادثة أن الإعداد الافتراضي كان خيارًا هو الآخر.
SP·07صور لم تكتبها أنت، على جهاز لا يمكنك تحمّل خسارته
صورة الحاوية هي نظام ملفات شخص آخر، يعمل على جهازك، مُجمَّع من طبقات لم تقرأها. هذا ليس حجة ضد استخدامها — بل حجة لمعرفة أيّ الصور تُشغّلها وما عمرها. الإخفاقان اللذان يحدثان فعليًا على البنية التحتية الصغيرة كلاهما مبتذل: إما أن الصورة لم تكن موثوقة قط، أو كانت موثوقة في مارس ولم يُعد أحد بناءها منذ ذلك الحين.
الإخفاق الأول يُحَل غالبًا بالانضباط بشأن مصدر الصور. فضِّل مستودعات رسمية أو تنشرها الجهة المطوِّرة على نسخة متفرّعة (fork) مريحة ذات ثلاث نجوم، وكن متشككًا خصوصًا في الصور التي تكمن جاذبيتها في حزمها ست خدمات في سطر واحد من YAML. حيثما يهمّ الأمر، ثبِّت الإصدار عبر البصمة (digest) لا عبر الوسم (tag): فـpostgres:17 هدف متحرك يمكن أن يتغيّر تحتك بين تنفيذين لـdocker compose pull، بينما postgres@sha256:… هو نظام الملفات ذاته الذي اختبرته. التثبيت بالبصمة يقايض الإصلاحات التلقائية بإمكانية إعادة الإنتاج، وهي المقايضة الصحيحة حين تملك عادة إعادة البناء والخاطئة حين لا تملكها.
الإخفاق الثاني هو الذي يلدغ بصمت. التحديثات التلقائية غير المُراقَبة لا تلمس حاوياتك. تحديثات الأمان التلقائية التي ضبطتها في الساعة الأولى ترقّع حزم المضيف وليست لديها أي رؤية لما بداخل الصورة من مساحة مستخدم — فقد يُشغّل جهاز يُبلغ عن نفسه بأنه مرقَّع بالكامل خادم ويب من صورة أساس فيها ثغرات غير مُصلَحة عمرها سنة. الحاويات لا تُحدَّث، بل تُستبدَل: اسحب، وأعد الإنشاء، واحذف ما لم يعد مُشارًا إليه، بوتيرة تلتزم بها فعلاً. مرة في الشهر، مكتوبة، تتفوق على نية كاملة.
وهذا ما يجعل ملف Compose أثمن كائن على الخادم. إنه الوصف الكامل الوحيد لماهية ذلك الجهاز، وينبغي أن تكون إعادة البناء منه عملية روتينية لا مشروع تنقيب أثري — احتفظ به في نظام ضبط الإصدارات، واحتفظ بملفات البيئة إلى جانبه، واحتفظ بكليهما في النسخة الاحتياطية المشفّرة خارج الخادم التي تحمل بياناتك. اختبار مضيف الحاويات ليس ما إذا كان يعمل. بل ما إذا كنت تستطيع تشغيله من جديد، مطابقًا تمامًا، على VPS جديد خلال نحو 15 min.
SP·08السجلات، ووحدات التخزين (volumes)، والحالة التي تعيش أطول من الحاوية
يُروَّج للحاويات على أنها قابلة للتخلص منها، وهذا صحيح بالنسبة للعملية وخاطئ بالنسبة لكل ما تتركه وراءها. نوعان من الحالة يتراكمان على مضيف Docker، وكلاهما يفاجئ الناس في أسوأ لحظة.
الأول هو السجلات. برنامج التشغيل الافتراضي json-file يلتقط كل سطر تكتبه حاوياتك إلى stdout وstderr، و — ما لم تخبره بغير ذلك — لا يُدوِّرها أبدًا. يمكن لتطبيق ثرثار أن يملأ قرصًا بهذه الطريقة خلال أسابيع، والقرص الممتلئ على مضيف قاعدة بيانات حادثة بذاتها. اضبط سقفًا مرة واحدة، في /etc/docker/daemon.json، بحيث ينطبق على كل ما تُشغّله لاحقًا.
لهذا الإعداد نفسه قراءة تتعلق بالخصوصية، وعلى خادم اخترته من أجل الخصوصية يمكن القول إنها الأهم. سجل وصول غير مُدوَّر هو سجلّ دائم وغير مفهرَس لعنوان IP كل زائر ووكيل المستخدم (user agent) ومسار الطلب، جالسًا على قرص لا تتحكم فيه ماديًا. أنت لم تقرر الاحتفاظ بذلك. الإعداد الافتراضي قرر نيابة عنك. أن تقرر بتعمّد يعني تحديد سقف للاحتفاظ، والأفضل من ذلك، تقليم ما يُكتَب من الأساس: صيغة سجلّ في الوسيط العكسي تحذف عناوين العملاء أو تقتصّها تحافظ على القيمة التشغيلية وتُسقط المسؤولية القانونية. البيانات التي لم تكتبها قط لا يمكن مصادرتها ولا استدعاؤها قضائيًا ولا تسريبها، وهو المنطق نفسه الذي يدفع الناس إلى إبقاء اسمهم بعيدًا عن الخادم من الأساس.
الثاني هو وحدات التخزين، والحافة الحادة هنا علامة واحدة. docker compose down يوقف الحاويات ويزيلها ويترك وحدات التخزين المُسمَّاة كما هي؛ أما docker compose down -v فيحذفها، نهائيًا، بالثقة نفسها. قواعد البيانات تعيش في تلك الوحدات. وكذلك أي شيء آخر ستفتقده. احتفظ ببياناتك في وحدات تخزين مُسمَّاة لا في وحدات مجهولة، واعرف أي أمر تكتبه في الثانية صباحًا، وتذكّر أن docker system prune موجود لاستعادة المساحة وسعيد باستعادة مساحتك أنت.
يبقى النسخ الاحتياطي نفسه، حيث تُضلِّل تجريدة الحاوية مرة أخيرة. نسخ مجلد وحدة تخزين قاعدة بيانات حيّة ليس نسخًا احتياطيًا؛ إنه نسخ لملفات كانت تُكتَب أثناء قراءتك لها، ويُستعاد كقاعدة بيانات تالفة بالضبط حين تحتاجه أكثر ما تكون. خذ النسخة الاحتياطية عبر المحرّك — pg_dump، أو mysqldump، أو واجهة اللقطات (snapshot) الخاصة بالمحرّك نفسه — ثم شفّر الناتج وأرسله إلى مكان لا يستطيع خادمك بلوغه. تلك الجملة الأخيرة هي التي تهمّ حين يكون الدخيل داخل الحاوية بالفعل: نسخة احتياطية يستطيع مضيفك المخترَق حذفها ليست نسخة احتياطية أيضًا.
خطوة بخطوة
-
01
احصِ ما ينشره الجهاز الآن فعليًا
قبل أن تغيّر أي شيء، دوِّن الحالة الراهنة — فستحتاجها للمقارنة لاحقًا. اقرأ عمود
PORTSلكل حاوية، ثم اقرأ قواعد NAT التي ركّبها Docker فعليًا، على كلتا عائلتي IP. أي شيء يُطبَع بلا وجهة127.0.0.1يمكن الوصول إليه من الإنترنت، أيًا كان ما يدّعيهufw status.docker ps --format 'table {{.Names}}\t{{.Ports}}' ss -tulpen | grep -E 'docker|LISTEN' sudo iptables -t nat -S DOCKER # -d 127.0.0.1/32 = loopback only sudo ip6tables -t nat -S DOCKER # may not exist; that is an answer too sudo ufw status verbose # what you believed was trueلاحظ اسم الواجهة العامة وأنت هنا — فقواعد جدار الحماية لاحقًا تحتاجه، وهو ليس
eth0في كل صورة.ip route get 1.1.1.1 | awk '{print $5; exit}' -
02
امسح نفسك من مكان آخر
لا يستطيع المضيف تدقيق نفسه بنفسه: حركة مرور loopback لا تعبر أبدًا السلاسل التي تحاول اختبارها. شغّل هذا من جهاز مختلف على شبكة مختلفة — VPS ثانٍ ابتداءً من $8.00/شهريًا في منطقة أخرى هو منصة الاختبار الصادقة، ويبقى مفيدًا بعد ذلك أيضًا. امسح العائلتين كلتيهما، لأنهما تُضبطان بشكل منفصل.
# from ANOTHER machine, against your server nmap -Pn -sS -p- --min-rate 1000 203.0.113.10 nmap -Pn -6 -p 22,80,443,3306,5432,6379,9200,27017 2001:db8::1 # no nmap? one port at a time is enough to settle an argument nc -zv 203.0.113.10 5432
كل منفذ مفتوح ليس 80 أو 443 أو منفذ SSH الخاص بك هو ملاحظة تستحق التسجيل. احتفظ بالإخراج؛ فهو نصف "قبل" من الإثبات الذي ستحتاجه في النهاية.
-
03
توقف عن نشر ما لا يحتاج إلى أن يكون عامًا
الآن أصلحها من المصدر، في ملف Compose. خدمات الطرف الخلفي تنتقل إلى شبكة خاصة وتفقد سطر
ports:الخاص بها كليًا — يصل إليها التطبيق باسم الخدمة. أي شيء يجب أن يكون قابلاً للوصول من المضيف نفسه يحصل على ربط loopback صريح بدلاً من منفذ مجرَّد. ضع على شبكة الطرف الخلفي علامةinternalحتى لا يستطيع شيء عليها التحدث إلى الإنترنت دون إذن.services: db: image: postgres:17 # ports: ["5432:5432"] # deleted: the app reaches it as db:5432 networks: [back] volumes: [dbdata:/var/lib/postgresql/data] app: image: myapp:1.4 environment: DATABASE_URL: postgres://app@db:5432/app ports: ["127.0.0.1:8080:8080"] # loopback only; proxy sits in front networks: [back, front] networks: front: {} back: internal: true volumes: dbdata: {}أعد إنشاء المجموعة (stack) وتأكد أن شكل قواعد NAT تغيّر — سطر DNAT الخاص بالتطبيق ينبغي أن يحمل الآن وجهة loopback، وينبغي ألّا يكون لقاعدة البيانات أي سطر على الإطلاق.
docker compose up -d docker ps --format 'table {{.Names}}\t{{.Ports}}' sudo iptables -t nat -S DOCKER -
04
ضع شيئًا واحدًا بالضبط أمام الجمهور
مع وجود كل شيء على loopback، يصبح وسيط عكسي واحد هو السطح العام الوحيد — المكان الوحيد الذي يُنهى فيه TLS، والمكان الوحيد الذي يُرى فيه عنوان العميل على الإطلاق، والمكان الوحيد الذي تُقرَّر فيه صيغة السجلّ. شغّله على المضيف أو في حاوية تنشر 80 و443 فقط ولا شيء غيرهما؛ فإن كان يعمل في حاوية، فإنه ينضم إلى شبكة
frontويُوسِّط إلى أسماء الخدمات بدلاً من loopback.# in the http{} block of /etc/nginx/nginx.conf — no client address recorded log_format privacy '- - [$time_local] "$request" $status $body_bytes_sent'; # /etc/nginx/conf.d/app.conf (proxy on the host) server { listen 443 ssl; listen [::]:443 ssl; server_name example.com; access_log /var/log/nginx/app.log privacy; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } }إذا كانت غاية التمرين ألّا يعرف أحد أين يقع الجهاز، فإن مكان الوسيط العكسي على جهاز مختلف تمامًا — وهذا هو الإعداد الموصوف في إخفاء عنوان IP الأصلي خلف وسيط عكسي، وهو يتوافق مع كل شيء هنا.
-
05
أغلق الفجوة بـ DOCKER-USER، على كلتا العائلتين
ربط loopback يُصلح حاويات اليوم؛ وهذا يُصلح الحاويات التي لم تكتبها بعد. ثلاث قواعد على الواجهة العامة، أولًا conntrack حتى يستمر خروج (egress) الحاويات بالعمل، ثم مصادرك المسموح بها، ثم إسقاط. اختبر اتصال حاوية بالخارج فور تطبيق القواعد — فهذه القاعدة التي يعكسها الناس خطأً.
PUB=$(ip route get 1.1.1.1 | awk '{print $5; exit}') sudo iptables -I DOCKER-USER 1 -i "$PUB" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN sudo iptables -I DOCKER-USER 2 -i "$PUB" -s 203.0.113.7/32 -j RETURN # your admin address sudo iptables -I DOCKER-USER 3 -i "$PUB" -j DROP sudo iptables -S DOCKER-USER docker run --rm alpine sh -c 'apk update' >/dev/null && echo 'egress OK'انسخها بصورة مطابقة من أجل IPv6 إذا كانت الخدمة (daemon) تدير قواعد IPv6، ثم اجعلها تنجو من إعادة التشغيل بوحدة تعمل بعد أن يكون Docker قد أعاد بناء سلاسله.
sudo ip6tables -S DOCKER-USER || echo 'no v6 chain: verify v6 reachability by scanning' # /etc/systemd/system/docker-user-rules.service [Unit] After=docker.service Requires=docker.service [Service] Type=oneshot ExecStart=/usr/local/sbin/docker-user-rules.sh RemainAfterExit=yes [Install] WantedBy=multi-user.target
-
06
أسقِط الامتيازات داخل الحاويات، وحدِّد سقف السجلات
قلِّل ما تستطيع صورة مخترَقة فعله، وأوقف امتلاء المضيف بسجلّ لم يختر أحد الاحتفاظ به. إعدادات الحاوية الأربعة لا تكلّف شيئًا على خدمة ويب عادية؛ وسقف السجلّ ينطبق على كل حاوية تُشغَّل بعد إعادة تحميل الخدمة (daemon).
services: app: image: myapp:1.4 user: "1000:1000" read_only: true tmpfs: [/tmp] cap_drop: [ALL] security_opt: ["no-new-privileges:true"] # never: volumes: ["/var/run/docker.sock:/var/run/docker.sock"]# /etc/docker/daemon.json { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }sudo systemctl reload docker docker inspect --format '{{.HostConfig.LogConfig}}' app -
07
تحقّق من الخارج، ثم دوِّن دليل التشغيل (runbook)
أعد التشغيل عن قصد، في وقت لا يعتمد فيه شيء على الإجابة، وأثبِت أن الحالة التي بنيتها هي الحالة التي تعود. ثم كرّر المسح الخارجي من الخطوة الثانية وقارنه (diff) بالإخراج الذي احتفظت به — ذلك الفرق هو الناتج المطلوب، لا الأوامر.
sudo reboot # after it returns: sudo iptables -S DOCKER-USER # rules reapplied? docker ps --format 'table {{.Names}}\t{{.Ports}}' # from ANOTHER machine again: nmap -Pn -p- 203.0.113.10 nmap -Pn -6 -p- 2001:db8::1دوِّن خمسة أسطر في مكان ستجدها فيه: أي واجهة تربطها القواعد، وأين تعيش الوحدة، وأي الخدمات عامة عن قصد، ومتى أعدت بناء الصور آخر مرة، وكيف تستعيد وحدات التخزين. مضيف الحاويات لا يساوي إلا بقدر الوصف الذي يتيح لك إعادة بنائه بعد أسوأ يوم.


