حدود ذاكرة Docker Compose لمنع OOM وتعطل الخادم
اضبط حدود الذاكرة ووحدة المعالجة في Docker Compose لمنع حاوية واحدة من إسقاط VPS، وتعرّف إلى exit 137 وswap والفرق بين deploy.resources وmem_limit.
ما الذي يفعله حد الذاكرة في Docker Compose
حد الذاكرة في Docker Compose هو سقف صارم يفرضه Linux kernel على cgroup (مجموعة التحكم، وهي ميزة في kernel تقيس موارد مجموعة من العمليات) الخاصة بحاوية واحدة. اضبط deploy.resources.limits.memory على إحدى الخدمات، ولن تتمكن تلك الحاوية من استخدام أكثر من القيمة التي كتبتها. عند محاولة تجاوزها، يقتل kernel إحدى العمليات داخل الحاوية، وعادةً ما تخرج الحاوية بالرمز 137.
يظهر أثر ذلك بوضوح على VPS، حيث تكون ذاكرة RAM ثابتة ولا توجد ذاكرة إضافية في المضيف يمكن استعارتها. قد تستهلك حاوية واحدة بها تسرّب للذاكرة أو استعلام سيئ كل صفحة ذاكرة حرة على خادم بسعة 8GB. عندها يقتل kernel العملية التي يراها الأسوأ، وقد تكون قاعدة بيانات أو جلسة SSH الخاصة بك بدلاً من الحاوية التي سببت المشكلة. تحوّل الحدود تعطل الخادم بالكامل إلى تعطل خدمة واحدة يمكن إعادة تشغيلها.
services:
app:
image: ghcr.io/example/app:1.4
deploy:
resources:
limits:
cpus: "1.5"
memory: 1g
reservations:
memory: 256mطبّق الإعداد وتأكد من أن الحد أصبح فعالاً:
docker compose up -d
docker stats --no-streamيجب أن يعرض العمود MEM USAGE / LIMIT قيمة مثل 142MiB / 1GiB. إذا أظهر عمود الحد كامل ذاكرة RAM الخاصة بالمضيف، فهذا يعني أن الإعداد لم يُطبَّق، ولن يساعدك باقي هذا الدليل قبل تطبيقه. إذا كان ملف Compose جديداً عليك، فتشرح أساسيات Docker Compose لـVPS بنية الملف التي يعتمد عليها هذا الإعداد.
deploy.resources.limits أو mem_limit: أيهما يُطبَّق؟
يوجد شكلان لكتابة الفكرة نفسها، ولذلك يسبب هذا الأمر التباساً.
mem_limit وmem_reservation وmemswap_limit وcpus وcpu_shares هي مفاتيح خدمة من المستوى الأعلى، موروثة من إصدارات Compose القديمة لتنسيق الملفات. أما deploy.resources فجاء من مخطط Swarm، وأصبح الآن جزءاً من Compose Specification، وهو التنسيق الذي يقرأه docker compose حالياً.
يعمل كلا الشكلين على مضيف واحد. يطبّق Compose V2، وهو إضافة docker compose، كلاً من deploy.resources.limits وdeploy.resources.reservations عند تشغيل docker compose up، من دون وجود عنقود Swarm. أما الأجزاء الخاصة بـSwarm في كتلة deploy فهي المفاتيح الأخرى: mode وplacement وupdate_config وendpoint_mode. هذه المفاتيح لها معنى لدى docker stack deploy، ويتجاهلها docker compose up. لذلك فإن النصيحة الشائعة التي تقول إن "deploy يحتاج إلى Swarm" غير صحيحة بالنسبة إلى القسم الفرعي resources، واتباعها يترك خدماتك بلا أي حد.
اختر شكلاً واحداً لكل مشروع. إن كتبت mem_limit: 512m وdeploy.resources.limits.memory: 1g في الخدمة نفسها، فستحصل على ملف يصعب فهمه من النظرة الأولى. بدلاً من التخمين لمعرفة أي الرقمين طُبِّق، اسأل الـdaemon:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1تُقاس قيم الذاكرة بالبايت، لذلك تظهر 1g على شكل 1073741824. وتُقاس وحدة المعالجة المركزية بوحدات nano CPUs، لذلك تظهر 1.5 على شكل 1500000000. تعني قيمة 0 في أي حقل عدم تعيين حد. وأصغر حد للذاكرة يقبله Docker هو 6m، وما دون ذلك يمنع الحاوية من البدء.
ما يحدث عندما يصل container إلى الحد الأقصى
لا يتباطأ container. بل يتوقف.
عندما تطلب عملية صفحة ويكون cgroup قد بلغ بالفعل memory.max، تستعيد النواة أولاً ما يمكنها استعادته داخل cgroup: page cache النظيفة، ثم الصفحات التي يمكنها نقلها إلى swap. إذا لم تُحرّر عملية الاستعادة مساحة كافية، تختار آلية OOM (out of memory) killer الخاصة بـcgroup عمليةً داخل container وترسل إليها SIGKILL. ويؤدي إنهاء PID 1 الخاص بـcontainer إلى إنهاء container. رمز الخروج 137 هو ببساطة 128 مضافاً إليه signal 9، لذلك يُعد 137 بصمةً لأي SIGKILL، وليس دليلاً بحد ذاته على حدوث OOM.
docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1true 137 يعني أن OOM killer أنهى العملية. أما false 137 فيعني أن شيئاً آخر أرسل SIGKILL، والسبب المعتاد هو بلوغ docker compose stop مهلة السماح البالغة عشر ثوانٍ لأن التطبيق تجاهل SIGTERM. يوفر هذا التمييز ساعات من التشخيص، لأن المشكلتين لا علاقة بينهما.
يسجل الحدث أيضاً في موضعين آخرين. راقب daemon مباشرة:
docker events --filter event=oomثم اقرأ سجل النواة، فهو السجل الذي يبقى بعد إعادة التشغيل:
sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'يطبع إنهاء cgroup سطراً يبدأ بـMemory cgroup out of memory: Killed process 24713 (node). أما السطر الذي لا يتضمن البادئة Memory cgroup فيشير إلى OOM على مستوى المضيف، ما يعني أن الجهاز نفسه نفدت منه RAM. هذه هي الحالة التي يُفترض أن تمنعها الحدود، ولذلك يدل ظهورها على أن مجموع حدودك مرتفع جداً، أو أن بعض الخدمات لا تملك أي حد على الإطلاق.
مع restart: unless-stopped، يصعب ملاحظة حلقة OOM، لأن الخدمة تظهر بحالة التشغيل في docker compose ps بعد ثانية من توقفها. تحقق من عمود مدة التشغيل وعدد مرات إعادة التشغيل، واربط الحد بـhealthcheck يُبلغ عن أن التطبيق غير سليم حتى يظهر container الذي يستمر في التوقف دون الحاجة إلى مراقبته.
الحجز تلميح، أما الحد فهو القاعدة
يُعد reservations.memory (وهو mem_reservation الأقدم) حداً أدنى مرناً. يصفه Docker بأنه حد مرن يُفعَّل عندما يكتشف البرنامج الخفي تنافساً على الموارد أو انخفاضاً في ذاكرة المضيف. ولا يمنع الحاوية أبداً من تجاوزه، كما لا يضمن أبداً توفّر الذاكرة عند طلب الحاوية لها. بل يوجّه النواة فقط إلى استعادة الذاكرة أولاً من الحاويات التي تتجاوز قيمة الحجز.
لذلك، لا يحمي الحجز أي شيء بمفرده. استخدمه لتحديد خدمة تريد منحها أولوية نسبية تحت الضغط، واعتمد على الحد من أجل الأمان. اجعل قيمة الحجز أقل من الحد، وإلا فلن تبدأ الحاوية: يرفض Docker الإعداد مع Minimum memory limit can not be less than memory reservation limit.
التعامل مع Swap بواقعية
لا تتضمن معظم صور VPS أي ملف Swap على الإطلاق. شغّل swapon --show وfree -h. إذا كان إجمالي Swap يساوي صفراً، فلن يؤثر أي إعداد متعلق بـSwap أدناه، وسيكون حد الذاكرة لديك حداً خالصاً لذاكرة RAM.
لا تمثل memswap_limit كمية Swap. بل تمثل إجمالي الذاكرة وSwap. باستخدام mem_limit: 1g وmemswap_limit: 2g، تحصل الحاوية على 1GB من RAM و1GB من Swap. يؤدي ضبط القيمتين على القيمة نفسها إلى منع الحاوية من استخدام Swap تماماً. ويتيح ضبط mem_limit وترك memswap_limit من دون تعيين للحاوية استخدام Swap حتى حجم حد الذاكرة مرة أخرى.
يستخدم Ubuntu 24.04 وDebian 13 cgroup v2 افتراضياً، حيث يكون Swap عدّاداً منفصلاً (memory.swap.max)، ويعمل ذلك من دون إعداد إضافي. وتصدر الرسالة القديمة Your kernel does not support swap limit capabilities من المضيفات التي تستخدم cgroup v1 والتي أقلعت من دون swapaccount=1. في هذه المضيفات، يظل حد الذاكرة مطبقاً، بينما يُتجاهل جزء Swap.
كن دقيقاً بشأن الفائدة التي يوفرها Swap. فهو يجعل عملية OOM kill أبطأ، ولا يجعل حدوثها أقل احتمالاً، لأن العملية التي تسرّب الذاكرة تملأ Swap بالسهولة نفسها التي تملأ بها RAM. وفي الوقت نفسه، تؤدي حاوية تستخدم Swap بكثافة على مساحة تخزين VPS مشتركة إلى إبطاء كل خدمة أخرى على الخادم. بالنسبة إلى أي خدمة حساسة لزمن الاستجابة، يفشل الحد الصحيح من دون Swap بسرعة أكبر وبصورة أكثر قابلية للتوقع.
لماذا يبدو استخدام الذاكرة أسوأ مما هو عليه
يتضمن الرقم MEM USAGE في docker stats ذاكرة التخزين المؤقت للصفحات، لذلك يزداد استهلاك حاوية تقرأ ملفات كبيرة حتى يقترب من حدها، ثم يستقر عنده. هذا طبيعي وليس تسرّباً، لأن النظام يستعيد ذاكرة التخزين المؤقت النظيفة قبل استدعاء OOM killer. ستبدو خدمة مثل خادم Jellyfin مستضاف ذاتياً قريبة دائماً من حدها لهذا السبب تحديداً.
قسّم الرقم إلى ذاكرة التخزين المؤقت ومجموعة العمل الفعلية من داخل الحاوية:
docker compose exec app grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat
docker compose exec app cat /sys/fs/cgroup/memory.eventsanon هي الذاكرة المجهولة، أي مجموعة العمل التي لا يمكن إسقاطها. file هي ذاكرة التخزين المؤقت للصفحات، ويمكن إسقاطها. حدّد حد الذاكرة استناداً إلى anon مع هامش، وليس إلى الإجمالي. يحسم ملف memory.events الأمر نهائياً: يشير عدّاد oom_kill الأكبر من الصفر إلى أن النواة قتلت شيئاً في هذه الحاوية منذ تشغيلها، ويعني ارتفاع عدّاد max أن الحاوية تبلغ حدها الآن. يتطلب كلا الأمرين shell وcoreutils داخل الصورة، ولذلك يفشلان في صور distroless أو scratch.
حدود التحجيم على VPS بسعة 8GB
ابدأ من المضيف، لا من التطبيقات. على VPS بسعة 8GB، اترك نحو 1GB للنواة وDocker daemon وsshd وjournald وصدفة تسجيل الدخول الخاصة بك. يتبقى نحو 7GB لتوزيعها، ويجب أن يبقى مجموع حدود جميع الحاويات أقل من ذلك. ينجح الإفراط في التخصيص حتى اليوم الذي تبلغ فيه خدمتان ذروة استهلاكهما في الوقت نفسه.
توزيع عملي على خادم بسعة 8GB:
- Reverse proxy: حد 128m. إنها عملية صغيرة، ويكشف هذا الحد الضيق جداً فوراً عن إعادة تحميل إعدادات خارجة عن السيطرة.
- PostgreSQL: حد 2g، مع ضبط
shared_buffersعلى نحو 512MB في إعدادات قاعدة البيانات. - حاوية التطبيق: حد 1g.
- عامل الخلفية: حد 512m.
- خدمة الوسائط أو الملفات: حد 2g، وسيُستخدم معظمها في ذاكرة التخزين المؤقت للصفحات.
لا تنسخ هذه الأرقام إلى بيئتك الخاصة. شغّل الخدمات تحت حمل فعلي ليوم كامل، وراقب docker stats، وسجّل قيمة الذروة anon لكل حاوية، ثم أضف نحو نصفها مرة أخرى كهامش احتياطي. الحد الضيق جداً أسوأ من عدم وضع حد، لأنه يوقف خدمة سليمة أثناء ارتفاع طبيعي في حركة الشبكة.
تستحق إحدى المشكلات الشائعة ملاحظة مستقلة. يكون الحد غير مرئي لمعظم بيئات التشغيل ما لم تُعرّفها به. سيضبط PostgreSQL shared_buffers وwork_mem بسخاء متجاوزاً حد الحاوية، ثم يتعرض للقتل. يحتاج JVM (Java virtual machine) إلى -XX:MaxRAMPercentage=75 ليضبط ذاكرة heap انطلاقاً من حد cgroup بدلاً من ذاكرة RAM في المضيف. يحتاج Node.js إلى --max-old-space-size بوحدة الميغابايت، مع ضبطه تحت حد الحاوية، وإلا سمح جامع البيانات المهملة لديه بزيادة heap حتى تتدخل النواة. الأمر نفسه ينطبق على Ollama مع خيار مختلف، لأن رفع num_ctx يزيد ذاكرة التخزين المؤقت KV بمئات الميغابايت، فتموت الحاوية في منتصف prompt طويل. لا يتفاوض cgroup. بل يقتل.
تختلف حدود CPU اختلافاً كبيراً
يعني cpus: "1.5" نسبة 150% من نواة واحدة، وتُفرَض باعتبارها حصة CFS (المجدول العادل بالكامل). يحصل الحاوي على 150ms من وقت CPU خلال كل فترة مدتها 100ms، وتُوزَّع هذه الحصة على جميع خيوطه. عندما يستهلك الحاوي هذه الحصة، تجعله النواة ينتظر حتى تبدأ الفترة التالية.
هذا هو الفرق المهم. إذا تجاوز الحاوي حد الذاكرة، يُقتل. أما إذا تجاوز حد CPU، فيُخنق ويستمر في العمل بسرعة أقل. لذلك يمكنك ضبط حد CPU بقيمة مرتفعة نسبياً بأمان، بينما يحتاج حد الذاكرة إلى هامش احتياطي.
cpu_shares أداة مختلفة: إنّه وزن نسبي لا تكون له أهمية إلا عندما تكون وحدات CPU مشبعة فعلياً. إذا كانت حصتا حاويين هما 1024 و512، فسيتقاسمان نواة مشغولة بنسبة تقارب اثنين إلى واحد. أما على جهاز خامل، فلن يُقيَّد أيٌّ منهما. استخدم shares لترتيب الخدمات حسب الأهمية، واستخدم cpus عندما تحتاج إلى حد أعلى فعلي، مثلاً لمنع مهمة تحويل ترميز ليلية من استنفاد موارد web server.
FAQ
هل تعمل deploy.resources.limits من دون Docker Swarm؟
نعم. يطبّق Compose V2 كلاً من deploy.resources.limits وdeploy.resources.reservations عند تشغيل docker compose up على مضيف واحد. تحقّق من ذلك باستخدام docker inspect --format '{{.HostConfig.Memory}}' <container>، الذي يطبع الحد بالبايت ويطبع 0 عند عدم تطبيق أي حد. المفاتيح داخل deploy التي تتطلب Swarm فعلياً هي mode وplacement وupdate_config وendpoint_mode.
ماذا يعني رمز الخروج 137 في Docker Compose؟
يعني أن العملية الرئيسية تلقّت SIGKILL، لأن 137 يساوي 128 مضافاً إليه الإشارة 9. السبب الشائع هو أداة OOM killer في kernel، لكن مهلة الإيقاف تنتج الرمز نفسه عندما يتجاهل التطبيق SIGTERM. شغّل docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container> للتمييز بين الحالتين. يشير true 137 إلى قتل بسبب الذاكرة، أما false 137 فلا يشير إلى ذلك.
هل ينبغي أن أستخدم mem_limit أم deploy.resources.limits.memory؟
يعمل كلاهما مع docker compose. يُعد deploy.resources.limits.memory الصيغة الحالية في Compose Specification، وهو الخيار الافتراضي الأفضل لملف جديد. أبقِ على mem_limit إذا كان باقي ملفك يستخدم المفاتيح القديمة ذات المستوى الأعلى. يؤدي ضبط الخيارين على خدمة واحدة إلى جعل الملف أصعب قراءة فقط، لذا اختر أحدهما وتحقّق من النتيجة باستخدام docker inspect.
لماذا يبلغ الحاوي الحد الأقصى الكامل للذاكرة من دون أن يُقتل؟
تتضمن قيمة الاستخدام في docker stats ذاكرة page cache، التي يحررها kernel عند وجود ضغط بدلاً من تشغيل OOM killer. شغّل docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat واقرأ قيمة anon، فهي تمثل مجموعة العمل التي لا يمكن استعادتها. تشير قيمة file المرتفعة بجانب قيمة anon المنخفضة إلى أن الحاوي ينفّذ إدخالاً وإخراجاً على القرص، وليس أنه على وشك التوقف.
كم يجب أن أترك من RAM غير مخصّصة على VPS بسعة 8GB؟
اترك نحو 1GB لـkernel وDocker daemon وsshd وjournald وshell الخاص بك، ثم أبقِ مجموع حدود جميع الحاويات أقل من 7GB المتبقية. راقب قيمة anon القصوى لكل حاوية تحت حمل فعلي لمدة يوم قبل اعتماد الأرقام، وتعامل مع الإجمالي على أنه ميزانية لا هدفاً ينبغي ملؤه.