DOCKER::ROOT الإقليم I · namespaces · عزل الرؤية kernel live
REGION I البدائية الأولى unshare · clone · setns

العزل: ماذا تستطيع العملية أن ترى؟

اللبنة التي يُشتقّ منها معنى «الكونتينر» نفسه. حين تنتهي، تكون قد بنيت عمليةً تظنّ أنها وحدها في الكون — بلا Docker، بأمر unshare وبضع كتابات.

مسار الإقليم
  1. النبذة: العدسة البديلة
  2. اللغز المستفزّ: اهرب من سجنك (chroot)
  3. الدرس: لماذا فشل chroot، ولماذا وُلدت namespaces
  4. اللغز الكبير: اصنع كونتينر بيدك العارية
  5. الخلاصة: العدسة لا تتحكّم في الموارد

النبذة — العدسة البديلة

كل عملية في Linux تنظر إلى «العالم» عبر مجموعة عدسات. ما قائمة العمليات؟ ما نظام الملفات؟ ما اسم المضيف؟ ما واجهات الشبكة؟ في النظام العادي كل العمليات تنظر عبر نفس العدسات فترى نفس العالم. الـ namespace ببساطة: عدسةٌ بديلة. تضع عمليةً خلف عدسة PID بديلة فترى قائمة عمليات مختلفة — قائمتها وحدها. هذا كل شيء. «الكونتينر» عمليةٌ وضعناها خلف مجموعةٍ كاملة من العدسات البديلة دفعةً واحدة.

لكن لماذا اخترع الناس هذه الفكرة؟ ولماذا بهذا الشكل — سبع عدساتٍ منفصلة بدل واحدةٍ كبيرة؟ هذا ما نفهمه قبل أي أمرٍ نكتبه.

اللغز المستفزّ — اهرب من سجنك

قبل namespaces بعقود، كانت أداة العزل الوحيدة هي chroot (منذ 1979). فكرتها: غيّر ما تعتبره العملية «الجذر» /. تقول لها «جذرك الآن /home/jail»، فحين تطلب /etc/passwd تحصل على /home/jail/etc/passwd. تبدو عزلاً مثالياً للملفات.

مهمتك — على Linux، الآن:

١. ابنِ بيئة chroot فيها bash ومكتباته:

bash jail
$ mkdir -p ~/jail/{bin,lib,lib64} $ cp /bin/bash ~/jail/bin/ $ ldd /bin/bash # ← اقرأ المخرجات، انسخ كل .so للمكان المطابق $ sudo chroot ~/jail /bin/bash

الآن أنت «داخل السجن». ls / يُظهر ما وضعته فقط. يبدو أنك محبوس.

٢. التحدي الحقيقي:

وأنت داخل الـ chroot (كـ root)، اكتب برنامج C صغيراً يخرج من السجن ويقرأ ملفاً من نظام ملفات المضيف خارج ~/jail. ممنوع أي ثغرة kernel؛ استخدم فقط ما يمنحك إياه chroot من تصميمه.

السؤال الذي يقضّ مضجعك: إن كان chroot يغيّر معنى الجذر، فهل يمنع الإشارة لمكانٍ «فوق» الجذر؟ وماذا يحدث لو نقلتُ جذري مرتين؟

تلميح على طبيعة المشكلة لا حلّها

الـ chroot يغيّر مؤشّر الجذر، لكنه لا يفحص «هل الجذر الجديد داخل القديم؟». فكّر فيما يعنيه هذا حين تطلب chroot وأنت داخل chroot، وعملك الحالي (cwd) خارجه.

الدرس — لماذا فشل chroot، ولماذا وُلدت namespaces

ليش وُجد chroot أصلاً — ثم انحرف

chroot لم يُخترع للأمان. اخترعه Bell Labs ليبنوا ويختبروا توزيعةً جديدة داخل نظامٍ قائم — بيئةُ بناءٍ نظيفة، لا سجنٌ أمني. ولهذا لم يُصمَّم ليصمد أمام عمليةٍ تحاول الهرب.

القاعدة المتكرّرة في كل تاريخ العزل

الأداة تُصمَّم لغرض، ثم يُساء استعمالها لغرضٍ أمني لم تُبنَ له، فتنكسر. سترى هذا النمط في chroot، وcgroups، والطبقات، والـ shim.

ثغرة الهرب (التي يفترض أنك اكتشفتها): chroot لا «يحبس» بل يعيد تعريف /. لكن عملية root تستطيع استدعاء chroot مرةً أخرى على مجلدٍ فرعي بينما cwd لا يزال خارج الجذر الجديد. فجأةً يصعد .. فوق الجذر، لأن الـ kernel تقليدياً لم يثبّت العلاقة بين cwd والجذر الجديد. تصعد بـ cd .. حتى تبلغ جذر المضيف الحقيقي.

الدرس الأعمق: chroot يعزل بُعداً واحداً (مسار الملفات)، عزلاً ضعيفاً، ويترك كل شيءٍ آخر مكشوفاً:

إذاً المطلوب لم يكن «chroot أقوى»، بل تعميم الفكرة: لكل بُعدٍ من أبعاد ما تراه العملية، أعطِها عدسةً قابلة للاستبدال.

ليش سبع عدساتٍ منفصلة، لا واحدة كبيرة

هذا قرار التصميم الأهم، وهو الذي جعل namespaces تنتصر على Solaris Zones و BSD Jails. Zones و Jails كانتا «صندوقاً» واحداً متراصّاً: إمّا داخل السجن بكل أبعاده أو خارجه تماماً. أمّا Linux فاختار التفكيك: سبعة أنواعٍ مستقلة تخلطها كما تشاء.

Mount (mnt) — جدول التركيب: ما الذي يُركَّب وأين PID (pid) — شجرة العمليات: أي PIDs تراها وترقيمها Network (net) — واجهات الشبكة، التوجيه، الـ ports UTS (uts) — اسم المضيف واسم النطاق IPC (ipc) — أدوات الاتصال بين العمليات User (user) — تعيين المعرّفات والصلاحيات ← الكيستون Cgroup (cgroup) — رؤية شجرة الـ cgroups Time (time) — الساعة الأحادية والإقلاع (أحدثها)

لماذا انتصر التفكيك؟ لأنه يسمح بتركيباتٍ لا يقدر عليها الصندوق الواحد:

الدرس الذي يفسّر نصف علامات Docker

العزل في Linux ليس حالةً ثنائية (معزول/لا)، بل متّجهٌ من سبعة أبعاد مستقلة، تضبط كل بُعدٍ على حدة.

كيف تُصنع عملياً — ثلاث بوّابات

namespace ليس له «كائن» تنشئه؛ يُولَد ضِمناً حين تطلب من الـ kernel وضع عملية خلف عدسةٍ جديدة:

أين «يعيش» الـ namespace؟ كملفاتٍ تحت /proc/<pid>/ns/:

bash
$ ls -l /proc/self/ns/ lrwxrwxrwx ... mnt -> 'mnt:[4026531840]' lrwxrwxrwx ... net -> 'net:[4026531992]' # الرقم = inode = هوية الـ namespace

عمليتان لهما نفس الرقم لـ net = تتشاركان نفس namespace = تريان نفس الواجهات. هذه أداة تشخيصك مدى الحياة: لتعرف حقاً هل عمليتان معزولتان، قارن أرقام الـ inode. الأمر lsns يلخّص هذا.

الطبقتان الأهم، وليش

PID namespace — وسلوك PID 1

حين تنشئ عمليةً في PID namespace جديد، تصبح هي PID 1 داخله — كـ init. ولـ PID 1 مسؤوليتان موروثتان من تصميم يونكس:

  1. حين يموت أبو عملية، تُعاد لـ PID 1، وعليه «حصادها» (wait) وإلا بقيت zombie.
  2. الإشارات الافتراضية (SIGTERM) لا تقتل PID 1 ما لم يسجّل لها معالِجاً صراحةً.
ليش — تفسير --init بلا حفظ

كونتينرٌ يشغّل عمليةً لا تحصد أبناءها يتراكم فيه zombies؛ وdocker stop أحياناً يتجاهل SIGTERM ويُجبَر على SIGKILL — لأن عمليتك صارت PID 1 ولا تتصرّف كـ init. هنا يأتي tini كـ PID 1 خفيف يحصد الأبناء؛ docker run --init يحقنه. فهمت العلامة من تصميم يونكس لـ PID 1، لا من حفظ.

والعملية نفسها لها رقمان: 1 من الداخل، ربما 24531 من الخارج. لا يوجد كونتينر؛ توجد عمليةٌ واحدة لها عدّة عناوين بحسب العدسة.

User namespace — الكيستون (يُحصَد في الإقليم IV)

أخطرها وأعمقها. يسمح بأن يكون المستخدم root (UID 0) داخله بينما هو مستخدمٌ عادي بلا أي صلاحية على المضيف. «الجذر داخل الكونتينر» يمكن أن يكون كذبةً كاملة — صلاحيةً وهمية معزولة. وهو الـ namespace الوحيد الذي يستطيع مستخدمٌ غير root إنشاؤه، ومنه تُشتقّ «rootless containers» كلها. علّق هذا؛ سنعود في الإقليم IV.

اللغز الكبير — اصنع كونتينر بيدك العارية

معلَم الإقليم. لا Docker، لا runc. فقط unshare وmount وكتابة على نظام الملفات. الهدف: قشرةٌ تظنّ أنها PID 1 في شجرتها، لها اسم مضيفٍ خاص، وجذرٌ خاص، ومعزولةٌ في الشبكة.

القيود (هي التي تجبرك على فهم البدائيات):

  1. ممنوع chroot. استخدم pivot_root. اكتشف لماذا يتطلّب أن يكون الجذر الجديد نقطة تركيبٍ منفصلة — وعالِج ذلك.
  2. لا بد أن ps داخل الكونتينر يُظهر PID 1. هذا يفرض تركيب /proc الخاص بالـ PID namespace الجديد. اكتشف لماذا لا يكفي PID namespace وحده، ولماذا /proc الموروث يكذب عليك.
  3. عند الخروج، يجب ألا يكون المضيف قد تلوّث: لا تركيباتٍ متسرّبة، اسم المضيف سليم. هذا يفرض فهم تسريب الـ mount propagation.

نقطة البداية الوحيدة المسموحة:

bash
$ sudo unshare --pid --uts --net --mount --fork --mount-proc=??? bash # ^^^ اكتشف القيمة الصحيحة بنفسك

أسئلةٌ يجب أن تجيب عليها بنفسك بالتجريب وman unshare وman 7 pid_namespaces وman 2 pivot_root:

  • لماذا --fork؟ احذفها وراقب: من يصير PID 1؟ ولماذا أول عمليةٍ تموت تُسقط الـ namespace كله؟
  • لماذا net namespace الجديد لا يرى إلا lo، ولماذا هو DOWN؟ (تمهيدٌ للإقليم VI.)
  • بعد pivot_root، كيف تفكّ الجذر القديم وتخفيه؟ ولماذا قبل أن تثق بالعزل؟
حين تعلَق

اقرأ man 7 mount_namespaces قسم «Shared subtrees»، وافهم الفرق بين private وshared وslave؛ المشكلة كلها هناك. غالباً ستحتاج mount --make-rprivate / بعد دخول الـ mount namespace — وافهم لماذا قبل أن تكتبها.

حين تكتب ps aux داخل قشرتك فلا ترى إلا PID 1 bash و ps، توقّف وتأمّل. صنعت «كونتينر». الـ kernel لم يفعل شيئاً «خاصاً»؛ ركّبتَ أنت أربع عدساتٍ على عمليةٍ عادية. هذا كل ما يفعله docker run في جوهره.

الخلاصة — العدسة لا تتحكّم في الموارد

أضفت العقدة الجذرية الأولى: namespaces = التحكم في رؤية العملية، مفكّكةً لسبعة أبعاد مستقلة. منها اشتققت معنى «الكونتينر» بلا أي أداة حاويات.

جرّب هذا داخل كونتينرك اليدوي (في VM فقط)

:(){ :|:& };: — fork bomb. سيسقط المضيف كله، لا الكونتينر فقط. عزلت ما تراه العملية، لكنك لم تعزل ما تستهلكه. PID namespace يعطيها ترقيماً خاصاً، لكنه لا يحدّ عدد العمليات — كلها عملياتٌ حقيقية على المضيف تستهلك ذاكرته وجدوله.

العدسة تتحكّم في الرؤية، لا في الموارد. لعزل الموارد نحتاج بدائيةً مختلفة تماماً.