Docker المتقدم وأمن الحاويات

حدود الموارد ومجموعات التحكم

18 دقيقة الدرس 7 من 28

حدود الموارد ومجموعات التحكم

الحاوية في جوهرها عبارة عن عملية — أو شجرة من العمليات — تعمل على نواة المضيف. وبدون حدود صريحة، يمكن لحاوية واحدة مفلتة من السيطرة أن تستهلك كل دورات المعالج وكل بايت من ذاكرة الوصول العشوائي على المضيف، مما يتسبب في تعطل كل الحاويات الأخرى والعقدة بأكملها. مجموعات التحكم في لينكس (cgroups) هي آلية النواة التي تمنع ذلك: إذ تفرض حدوداً صارمة على المعالج والذاكرة والإدخال/الإخراج وعدد العمليات لكل مجموعة عمليات. كلٌّ من Docker وKubernetes يبنيان نموذجَي تحديد الموارد الخاصَّيْن بهما فوق cgroups.

كيف تعمل مجموعات التحكم داخلياً

عند بدء Docker تشغيل حاوية، ينشئ الخادم الخفي تسلسلاً هرمياً لـ cgroup تحت /sys/fs/cgroup/ ويضع كل عملية حاوية بداخله. تفرض النواة بعد ذلك الحدود التي حددتها على مستوى هذا التسلسل الهرمي — بصرف النظر عن مدى عدوانية الحاوية في محاولة الإفلات. مع الإصدار الثاني من cgroups (الافتراضي على Linux 5.2+ وجميع التوزيعات الحديثة)، يتوحد التسلسل الهرمي في شجرة واحدة ويكون المحاسبة أكثر دقة، لا سيما للذاكرة.

cgroups v1 مقابل v2: يدعم Docker Engine 20.10+ وKubernetes 1.25+ كلاهما cgroups v2. في v2، يحل المفتاح memory.max محل memory.limit_in_bytes من v1، ويُتحكم في المعالج عبر cpu.max بدلاً من cpu.cfs_quota_us. تظل علامات واجهة سطر الأوامر لـ Docker وkubectl كما هي بصرف النظر عن الإصدار — إذ تترجم المحرك تلقائياً.

حدود الذاكرة وسلوك OOM

الذاكرة هي أكثر الموارد خطورة عند إهمالها. عندما تتجاوز حاوية حد ذاكرتها، يُنهي قاتل OOM (نفاد الذاكرة) في النواة عملية داخل cgroup. في Docker، يتجلى ذلك بخروج الحاوية بحالة 137 (SIGKILL من النواة). بدون حد، قد يختار قاتل OOM أي عملية على المضيف — بما في ذلك خادم Docker الخفي أو عملية في حاوية غير ذات صلة تماماً.

# التشغيل مع حد ذاكرة صارم 512 ميجابايت وحد مبادلة 768 ميجابايت # (--memory-swap يشمل الذاكرة الأصلية، لذا 768م - 512م = 256 ميجابايت مبادلة فعلية) docker run -d \ --memory=512m \ --memory-swap=768m \ --name api-server \ myapp:latest # فحص إعداد cgroup الفعلي على المضيف (cgroups v2) cat /sys/fs/cgroup/system.slice/docker-<container-id>.scope/memory.max # مراقبة استخدام الذاكرة المباشر لجميع الحاويات docker stats # الكشف عما إذا كانت الحاوية قد تعرضت لقتل OOM docker inspect api-server --format '{{.State.OOMKilled}}' # النتيجة: true # التحقق من رمز الخروج (137 = SIGKILL من قاتل OOM) docker inspect api-server --format '{{.State.ExitCode}}'
لا تضبط --memory-swap مساوياً لـ --memory. هذا يعطل المبادلة تماماً، وهو ما يبدو آمناً لكنه يتسبب في قتل OOM عند حد الذاكرة دون أي هبوط ناعم. في الإنتاج، إما أن تسمح بقدر صغير من المبادلة (1.5 إلى 2 ضعف حد الذاكرة)، أو تعطل المبادلة على مستوى المضيف بالكامل لأعباء العمل الحساسة لزمن الاستجابة، والاعتماد على قتل OOM السريع كقاطع دائرة.

يمكنك أيضاً تهيئة ما يحدث قبل قتل OOM باستخدام --oom-score-adj. يصنّف قاتل OOM في النواة كل عملية بين -1000 (لا تقتل أبداً) و+1000 (اقتل أولاً). يجعل الضبط على درجة عالية لحاويتك منها الضحية المفضلة، مما يحمي العمليات على مستوى المضيف.

حدود المعالج: الحصص والأوزان والفترات

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

  • --cpus (أو --cpu-quota + --cpu-period) — يضع سقفاً صارماً. --cpus=1.5 تعني أن الحاوية قد تستخدم ما يصل إلى 1.5 ثانية-معالج في الثانية، بصرف النظر عن الطاقة المتاحة. يُنفَّذ كحصة جدول CFS (المُجدوِّل العادل تماماً).
  • --cpu-shares — يضع وزناً نسبياً (الافتراضي 1024). يسري فقط عند التنافس على المعالج. تحصل الحاوية ذات 2048 حصة على ضعف وقت المعالج مقارنة بحاوية ذات 1024 حصة عندما تكون كلتاهما مشغولتين. عندما يكون المضيف خاملاً، لا تهم الحصص — يمكن للحاوية ذات الحصص المنخفضة الانطلاق بحرية.
# حد صارم: لا يمكن للحاوية استخدام أكثر من 2 معالج docker run -d \ --cpus=2 \ --name worker \ myapp:latest # مكافئ باستخدام مفاتيح CFS الخام (فترة=100ms، حصة=200ms = 2 معالج) docker run -d \ --cpu-period=100000 \ --cpu-quota=200000 \ --name worker \ myapp:latest # وزن ناعم: تحصل هذه الحاوية على أولوية معالج ضعفَي الحاويات الافتراضية في حال التنافس docker run -d \ --cpu-shares=2048 \ --name priority-service \ myapp:latest # تثبيت على معالجات محددة (مفيد لأعباء العمل الواعية بـ NUMA على المضيفات الكبيرة) docker run -d \ --cpuset-cpus="0,1" \ --name pinned-service \ myapp:latest
CPU quota vs shares behavior under contention and idle host Host Idle (spare capacity available) Container A shares=512 Container B shares=2048 CPU usage (idle host = both can burst): ~100% ~100% Shares ignored — both burst freely --cpus quota is still enforced. A container with --cpus=1 cannot exceed 1 CPU even on an idle host. Host Contended (all CPUs busy) Container A shares=512 Container B shares=2048 CPU share ratio 1:4 enforced: 20% 80% Shares enforced — A gets only 1/5 Shares alone do NOT protect against bursts on idle hosts. Use --cpus for hard guarantees.
حصص المعالج (ناعمة) مقابل حصة المعالج (صارمة): تهم الحصص فقط عند الإشغال الكامل للمعالجات؛ تُطبَّق الحصص الصارمة دائماً.

ulimits: واصفات الملفات وعداد العمليات

بعيداً عن المعالج والذاكرة، ثمة موردان آخران يتسببان في أعطال إنتاج خفية: واصفات الملفات المفتوحة وعداد العمليات/الخيوط. كلاهما يُتحكم بهما عبر إعدادات ulimit التي يرثها Docker من الإعدادات الافتراضية للخادم الخفي، ويمكن تجاوزها لكل حاوية على حدة.

يمكن لخدمة عالية التزامن (قاعدة بيانات، خادم ويب، وسيط رسائل) استنفاد حدود واصفات الملفات تحت الحمل، مما يتسبب في أخطاء "عدد كبير جداً من الملفات المفتوحة" الغامضة قبل نفاد المعالج أو الذاكرة بوقت طويل. وبالمثل، يمكن للعمليات الفروعية الشرهة أو JVM يسرب الخيوط أن تستنفد جميع معرفات العمليات على المضيف، مما يجعل العقدة غير قادرة على بدء أي عملية جديدة — بما في ذلك نصوص الاسترداد.

# رفع الحد الأدنى والأقصى لواصفات الملفات المفتوحة إلى 65535 docker run -d \ --ulimit nofile=65535:65535 \ --name postgres \ postgres:16 # تقييد الحد الأقصى لعدد العمليات (يمنع قنابل Fork) docker run -d \ --ulimit nproc=512:512 \ --name untrusted-job \ myapp:latest # ضبط الإعدادات الافتراضية على مستوى الخادم الخفي في /etc/docker/daemon.json # (تسري على جميع الحاويات إلا إذا تم تجاوزها في وقت التشغيل) # { # "default-ulimits": { # "nofile": { "Name": "nofile", "Soft": 65535, "Hard": 65535 }, # "nproc": { "Name": "nproc", "Soft": 1024, "Hard": 1024 } # } # } # فحص ulimits الحالية لحاوية قيد التشغيل docker inspect <container> --format '{{json .HostConfig.Ulimits}}'

طلبات الموارد وحدودها في Kubernetes

في Kubernetes، يُهيَّأ المعالج والذاكرة على مستوى الحاوية داخل مواصفة Pod. ثمة مفهومان متميزان: الطلبات (ضمان المُجدوِّل — يجب أن تمتلك العقدة هذا القدر من الطاقة الحرة) والحدود (سقف cgroup — لا يمكن للحاوية تجاوزه). ضبط الحدود فقط دون الطلبات خطأ شائع يؤدي إلى قيام المُجدوِّل بوضع عدد كبير جداً من الحجيرات على عقدة واحدة.

# k8s-deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: api-server spec: replicas: 3 selector: matchLabels: app: api-server template: metadata: labels: app: api-server spec: containers: - name: api image: myapp:latest resources: requests: cpu: "250m" # 0.25 معالج مضمون من المُجدوِّل memory: "256Mi" # 256 ميجابايت مضمونة على العقدة limits: cpu: "1000m" # سقف صارم 1.0 معالج (يُبطَّأ، لا يُقتل) memory: "512Mi" # سقف صارم 512 ميجابايت (يُقتل OOM إذا تجاوزه)
قاعدة الحجم في الإنتاج: اضبط طلب الذاكرة مساوياً لحدها (أو قريباً جداً منه). الذاكرة مورد غير قابل للضغط — بمجرد تخصيصها، لا تستطيع النواة استردادها دون قتل العملية. وجود طلبات أقل بكثير من الحدود يشجع على الجدولة المفرطة، مما يتسبب في موجة من قتلات OOM تحت الحمل. بالنسبة للمعالج، تُعد نسبة 2x-4x بين الطلب والحد معقولة لأعباء العمل المتقطعة، لكن راقب مقاييس التبطيء في Prometheus (container_cpu_cfs_throttled_seconds_total) — التبطيء الشديد مضر بالقدر ذاته للخدمات الحساسة لزمن الاستجابة.

التحقق من الحدود ومراقبتها في الإنتاج

ضبط الحدود هو نصف العمل فقط. يجب أيضاً التحقق من تطبيقها والتنبيه عندما تقترب الحاويات منها. الإشارات الرئيسية للمراقبة:

  • نسبة استخدام الذاكرة — أنشئ تنبيهاً عند 80% من الحد. عند 100% يتم القتل دون أي تحذير.
  • عداد قتل OOMcontainer_oom_events_total في Prometheus؛ أي قيمة فوق الصفر تعني حادثة إنتاج.
  • نسبة تبطيء المعالجcontainer_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total؛ فوق 25% يشير إلى أن حد المعالج منخفض جداً.
  • استخدام واصفات الملفات — قارن عدد /proc/<pid>/fd بالـ ulimit للخدمات طويلة الأمد.
لا تُشغّل الحاويات بدون حدود للموارد في الإنتاج أبداً. هذا هو السبب الأكثر شيوعاً لحالات فشل العقدة المتتالية في مجموعات Kubernetes المشتركة. طبّق هذا على مستوى القبول باستخدام كائن LimitRange في كل مساحة اسم — فهو يحقن تلقائياً الطلبات والحدود الافتراضية لأي حجيرة تحذفها، حتى لا يتمكن نشر مهيأ بشكل خاطئ من تجاوز السياسة.