العزل: ماذا تستطيع العملية أن ترى؟
اللبنة التي يُشتقّ منها معنى «الكونتينر» نفسه. حين تنتهي، تكون قد بنيت عمليةً
تظنّ أنها وحدها في الكون — بلا Docker، بأمر unshare وبضع كتابات.
- النبذة: العدسة البديلة
- اللغز المستفزّ: اهرب من سجنك (chroot)
- الدرس: لماذا فشل chroot، ولماذا وُلدت namespaces
- اللغز الكبير: اصنع كونتينر بيدك العارية
- الخلاصة: العدسة لا تتحكّم في الموارد
النبذة — العدسة البديلة
كل عملية في 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 يعزل بُعداً واحداً (مسار الملفات)، عزلاً ضعيفاً، ويترك كل شيءٍ آخر مكشوفاً:
- المحبوس يرى كل عمليات المضيف (
ps auxكامل، ويستطيعkillأي PID). - يشارك المضيف اسمَه، وواجهات الشبكة، وجدول التركيب، ومعرّفات المستخدمين.
- كـ root يستطيع
mknodلإنشاء عقدة قرص المضيف وقراءته خاماً.
إذاً المطلوب لم يكن «chroot أقوى»، بل تعميم الفكرة: لكل بُعدٍ من أبعاد ما تراه العملية، أعطِها عدسةً قابلة للاستبدال.
ليش سبع عدساتٍ منفصلة، لا واحدة كبيرة
هذا قرار التصميم الأهم، وهو الذي جعل namespaces تنتصر على Solaris Zones و BSD Jails. Zones و Jails كانتا «صندوقاً» واحداً متراصّاً: إمّا داخل السجن بكل أبعاده أو خارجه تماماً. أمّا Linux فاختار التفكيك: سبعة أنواعٍ مستقلة تخلطها كما تشاء.
لماذا انتصر التفكيك؟ لأنه يسمح بتركيباتٍ لا يقدر عليها الصندوق الواحد:
- كونتينر يشارك شبكة المضيف لكن بنظام ملفاتٍ معزول (
--network host). - عدّة عمليات تتشارك net namespace واحداً وتفترق في mnt — وهذا بالضبط ما هو الـ Pod في Kubernetes.
- وضع عملية في net namespace موجود (
--network=container:X).
العزل في Linux ليس حالةً ثنائية (معزول/لا)، بل متّجهٌ من سبعة أبعاد مستقلة، تضبط كل بُعدٍ على حدة.
كيف تُصنع عملياً — ثلاث بوّابات
namespace ليس له «كائن» تنشئه؛ يُولَد ضِمناً حين تطلب من الـ kernel وضع عملية خلف عدسةٍ جديدة:
clone(... CLONE_NEW*)— أنشئ عمليةً ابنة وضعها مباشرةً في namespaces جديدة. هذا ما يستعمله runc.unshare(CLONE_NEW*)— «افصل» العملية الحالية إلى namespaces جديدة دون إنشاء عملية.setns(fd, ...)— اربط عمليةً بـ namespace موجود عبر واصف ملفه. هذا ما يفعلهdocker execوnsenter.
أين «يعيش» الـ 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 مسؤوليتان موروثتان من تصميم يونكس:
- حين يموت أبو عملية، تُعاد لـ PID 1، وعليه «حصادها» (
wait) وإلا بقيت zombie. - الإشارات الافتراضية (
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 في شجرتها، لها اسم مضيفٍ خاص،
وجذرٌ خاص، ومعزولةٌ في الشبكة.
القيود (هي التي تجبرك على فهم البدائيات):
- ممنوع
chroot. استخدمpivot_root. اكتشف لماذا يتطلّب أن يكون الجذر الجديد نقطة تركيبٍ منفصلة — وعالِج ذلك. - لا بد أن
psداخل الكونتينر يُظهر PID 1. هذا يفرض تركيب/procالخاص بالـ PID namespace الجديد. اكتشف لماذا لا يكفي PID namespace وحده، ولماذا/procالموروث يكذب عليك. - عند الخروج، يجب ألا يكون المضيف قد تلوّث: لا تركيباتٍ متسرّبة، اسم المضيف سليم. هذا يفرض فهم تسريب الـ 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 = التحكم في رؤية العملية، مفكّكةً لسبعة أبعاد مستقلة. منها اشتققت معنى «الكونتينر» بلا أي أداة حاويات.
- ربطت
--initوtiniبتصميم PID 1 — اشتقاقاً لا حفظاً. - زرعت user namespace ككيستونٍ أمني → يُحصَد في IV.
- زرعت net namespace المعزول عديم الفائدة → يُحصَد في VI.
- زرعت الـ Pod (مشاركة namespaces) → V / الأوركستريشن.
:(){ :|:& };: — fork bomb. سيسقط المضيف كله، لا
الكونتينر فقط. عزلت ما تراه العملية، لكنك لم تعزل ما تستهلكه.
PID namespace يعطيها ترقيماً خاصاً، لكنه لا يحدّ عدد العمليات — كلها عملياتٌ حقيقية
على المضيف تستهلك ذاكرته وجدوله.
العدسة تتحكّم في الرؤية، لا في الموارد. لعزل الموارد نحتاج بدائيةً مختلفة تماماً.