حدود ذاكرة Docker Compose لمنع خطأ OOM
اضبط حدود الذاكرة ووحدة المعالجة في Docker Compose لمنع حاوية واحدة من إسقاط VPS. تعرّف إلى deploy.resources وmem_limit والرمز 137 وswap.
ما الذي يفعله حد الذاكرة في Docker Compose
حد الذاكرة في Docker Compose هو سقف صارم يفرضه Linux على cgroup (مجموعة التحكم، وهي ميزة في النواة تقيس موارد مجموعة من العمليات) الخاصة بحاوية واحدة. اضبط deploy.resources.limits.memory لخدمة ما، ولن تتمكن تلك الحاوية من استخدام أكثر من القيمة التي كتبتها. عندما تحاول تجاوزها، تقتل النواة عملية داخل الحاوية، وعادةً ما تخرج الحاوية بالرمز 137.
يهم ذلك خصوصاً على VPS، حيث تكون ذاكرة RAM ثابتة ولا توجد ذاكرة إضافية على المضيف يمكن استعارتها. قد تستهلك حاوية واحدة فيها تسرّب للذاكرة أو استعلام سيئ كل الصفحات الحرة على خادم بسعة 8GB. عندئذ تقتل النواة العملية التي تراها الأسوأ، وغالباً ما تكون قاعدة بيانات أو جلسة 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 اليوم.
يعمل كلاهما على مضيف واحد. يطبّق Docker Compose، وهو إضافة docker compose، كلاً من deploy.resources.limits وdeploy.resources.reservations عند تشغيل docker compose up، من دون وجود مجموعة Swarm. توجد الإضافة الآن ضمن السلسلة v5، وكان v5.6.0 أحدث إصدار مستقر عند التحقق في 2026-10-10. انتقلت مباشرة من v2 إلى v5، متجاوزةً الإصدارين 3 و4 لتجنب الالتباس مع إصدارات تنسيق ملفات Compose القديمة، ولم يغيّر أي إصدار من v5 طريقة التعامل مع حدود الموارد. أما الأجزاء المرتبطة بـSwarm في كتلة deploy فهي المفاتيح الأخرى، مثل placement وupdate_config وendpoint_mode، التي تصف الجدولة وعمليات النشر التدريجي عبر مجموعة من العقد لصالح docker stack deploy. لذلك فإن النصيحة الشائعة التي تقول إن "deploy يحتاج إلى Swarm" خاطئة بالنسبة إلى قسم الموارد، واتباعها يترك خدماتك من دون أي حد على الإطلاق.
استخدم شكلاً واحداً للكتابة في كل مشروع. إن كتابة mem_limit: 512m وdeploy.resources.limits.memory: 1g للخدمة نفسها ليست صعبة القراءة فحسب؛ إذ تنص Compose Specification على أنه عند ضبط mem_limit يجب أن يكون متوافقاً مع limits.memory، ولا تحدد أي قيمة تسري عند اختلافهما. أياً كان الشكل الذي تستخدمه، تحقّق مما طبّقه 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: ذاكرة التخزين المؤقت النظيفة للصفحات، ثم الصفحات التي يمكنها نقلها إلى swap. إذا لم تحرر عملية الاستعادة مساحة كافية، يختار OOM (نفاد الذاكرة) killer في cgroup عملية داخل container ويرسل إليها SIGKILL. يؤدي إنهاء PID 1 الخاص بـcontainer إلى إنهاء container. رمز الخروج 137 هو ببساطة 128 مضافاً إليه الإشارة 9، لذلك فإن 137 هو بصمة أي SIGKILL، وليس دليلاً بحد ذاته على حدوث OOM.
docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1true 137 يعني أن OOM killer أنهى العملية. أما false 137 فيعني عادةً أن شيئاً آخر أرسل SIGKILL (يمكن لبرنامج أيضاً أن يخرج بالرمز 137 عمداً، لكن ذلك نادر)، والسبب المعتاد هو أن 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). يعني هذا السطر أن container واحداً وصل إلى حده الخاص، ويمكن أن يظهر بينما لا يزال لدى host مقدار كبير من RAM الحرة. أما السطر الذي لا يحتوي على البادئة Memory cgroup فهو OOM على مستوى host، ما يعني أن الجهاز نفسه نفدت منه RAM. هذه هي الحالة التي تهدف الحدود إلى منعها، لذا فإن ظهورها يعني عادةً أن مجموع حدودك مرتفع جداً، أو أن بعض الخدمات لا تملك أي حد، أو أن شيئاً خارج Docker يستخدم مقداراً أكبر من المساحة الاحتياطية التي تركتها.
مع restart: unless-stopped، يصعب اكتشاف حلقة OOM، لأن الخدمة تبدو في حالة التشغيل ضمن docker compose ps بعد ثانية من توقفها. افحص عمود مدة التشغيل وعدد مرات إعادة التشغيل، واربط الحد بـhealthcheck يعرض التطبيق على أنه غير سليم حتى يكون container الذي يتوقف باستمرار مرئياً دون الحاجة إلى مراقبته.
الحجز إشارة، أما الحد فهو القاعدة
reservations.memory (الإصدار الأقدم mem_reservation) هو حد أدنى مرن. يصفه Docker بأنه حد مرن يُفعَّل عندما يكتشف daemon وجود تنافس على الموارد أو انخفاضاً في ذاكرة المضيف. لا يمنع الحاوية مطلقاً من تجاوز هذا الحد، ولا يضمن مطلقاً توفر الذاكرة عندما تطلبها الحاوية. بل يوجّه kernel فقط إلى استرداد الذاكرة أولاً من الحاويات التي تتجاوز قيمة الحجز.
لذلك لا يحمي الحجز أي شيء بمفرده. استخدمه لتحديد خدمة تريد منحها أولوية أفضل عند الضغط، واعتمد على الحد لتحقيق الأمان. اجعل الحجز أقل من الحد، وإلا فلن تبدأ الحاوية: يرفض 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 بسرعة أكبر وبطريقة أكثر قابلية للتنبؤ.
لماذا يبدو استخدام الذاكرة أسوأ مما هو عليه
في Linux، يطرح الرقم MEM USAGE في docker stats ذاكرة الصفحات غير النشطة فقط (inactive_file في cgroup v2)، لذلك تظل ذاكرة التخزين المؤقت المستخدمة حديثاً محسوبة، وتقترب الحاوية التي تقرأ ملفات كبيرة من حدها وتبقى عنده. هذا طبيعي وليس تسرّباً، لأن ذاكرة التخزين المؤقت النظيفة تُستعاد قبل استدعاء 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.eventsتمثل anon الذاكرة المجهولة، وهي مجموعة العمل التي لا يمكن إسقاطها. وتمثل 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، حد cgroup افتراضياً، لكن إصدارات HotSpot مثل OpenJDK تمنح heap نسبة 25 percent منه فقط. لذلك تكون -XX:MaxRAMPercentage=75 الطريقة المعتادة للسماح لـheap باستخدام جزء أكبر من الحد، مع ترك مساحة للذاكرة خارج heap. يضبط Node.js حجم heap الافتراضي اعتماداً على حد cgroup منذ الإصدار 12، بنحو نصف حد الحاوية، ثم يستقر عند نحو 2GB. لذلك اضبط --max-old-space-size بالميجابايت فقط عندما تريد سقفاً مختلفاً، واجعله أقل من حد الحاوية كي يعمل garbage collector قبل تدخل النواة. ينطبق الأمر نفسه على Ollama مع خيار مختلف، لأن رفع num_ctx يزيد ذاكرة KV cache بمئات الميجابايت، فتموت الحاوية في منتصف prompt طويل. لا يتفاوض cgroup. بل ينهي العملية.
تعمل حدود CPU بطريقة مختلفة تماماً
تعني cpus: "1.5" نسبة 150% من نواة واحدة، ويُطبَّق ذلك باعتباره حصة CFS (مجدول عادل تماماً). تحصل الحاوية على 150ms من وقت CPU في كل فترة مدتها 100ms، ويُوزَّع ذلك على جميع سلاسل التنفيذ فيها. وعندما تستهلك هذه الحصة، يجعلها kernel تنتظر حتى تبدأ الفترة التالية.
هذا هو الفرق المهم. تُقتل الحاوية التي تتجاوز حد الذاكرة. أما الحاوية التي تتجاوز حد CPU، فيُفرض عليها الخنق وتستمر في العمل بسرعة أقل. لذلك من الآمن ضبط حد CPU بقيمة مرتفعة نسبياً، بينما يحتاج حد الذاكرة إلى هامش احتياطي.
cpu_shares أداة مختلفة: وهي وزن نسبي لا تكون له أهمية إلا عندما تكون وحدات CPU مشبعة فعلياً. تقسم حاويتان لهما shares بقيمتي 1024 و512 نواة مشغولة بنسبة تقارب اثنين إلى واحد، وعندما يكون الخادم في وضع الخمول لا تُقيَّد أيٌّ منهما. استخدم shares لترتيب الخدمات حسب الأهمية، واستخدم cpus عندما تحتاج إلى حد أقصى فعلي، مثلاً لمنع مهمة تحويل ترميز ليلية من استنزاف موارد خادم الويب.
FAQ
هل يعمل deploy.resources.limits من دون Docker Swarm؟
نعم. يطبّق Docker Compose، وهو المكوّن الإضافي docker compose (الذي أصبح الآن ضمن سلسلة v5)، كلاً من deploy.resources.limits وdeploy.resources.reservations عند تشغيل docker compose up على مضيف واحد. تحقّق من ذلك باستخدام docker inspect --format '{{.HostConfig.Memory}}' <container>، الذي يطبع الحد بالبايت ويطبع 0 عند عدم تطبيق أي حد. المفاتيح داخل deploy المصممة مع وضع Swarm في الاعتبار هي مفاتيح المجموعة، مثل placement وupdate_config وendpoint_mode.
ماذا يعني رمز الخروج 137 في Docker Compose؟
يعني ذلك في الغالب أن العملية الرئيسية تلقّت SIGKILL، لأن 137 يساوي 128 مضافاً إليها الإشارة 9. يمكن لبرنامج ما أن يخرج بالرمز 137 عمداً أيضاً، لكن ذلك نادر. السبب الشائع هو أداة kernel OOM killer، لكن انتهاء مهلة الإيقاف ينتج الرمز نفسه عندما يتجاهل التطبيق 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، وهو الخيار الافتراضي الأفضل للملف الجديد. أبقِ mem_limit إذا كان باقي ملفك يستخدم المفاتيح القديمة في المستوى الأعلى. تتطلب مواصفة Compose اتساق القيمتين عند ضبطهما معاً، لذا اختر واحدة منهما وتحقق من النتيجة باستخدام docker inspect.
لماذا يستخدم حاويتي كامل حد الذاكرة من دون أن تُقتل؟
تظل قيمة الاستخدام في docker stats تحسب ذاكرة التخزين المؤقت للصفحات المستخدمة مؤخراً. يتخلص kernel منها عند وجود ضغط بدلاً من تشغيل عملية OOM kill. شغّل 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 القصوى لكل حاوية تحت حمل فعلي لمدة يوم قبل تثبيت الأرقام، وتعامل مع الإجمالي باعتباره ميزانية، لا هدفاً ينبغي استهلاكه بالكامل.