حدود ذاكرة Docker Compose لمنع خطأ OOM
اضبط حدود الذاكرة ووحدة المعالجة في Docker Compose لمنع حاوية شرهة من إسقاط VPS، وتعرّف إلى deploy.resources وmem_limit وExit 137 وswap وحجم الحد المناسب.
ما الذي يفعله حد الذاكرة في 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، وهي التنسيق الذي يقرأه 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 في الخدمة نفسها، فستحصل على ملف يصعب فهمه من النظرة الأولى. بدلًا من التخمين بشأن الرقم الذي طُبِّق، اسأل البرنامج الخفي:
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1تُقاس قيم الذاكرة بالبايت، لذلك تُظهر 1g القيمة 1073741824. وتُقاس وحدة المعالجة المركزية بوحدات nano CPUs، لذلك تُظهر 1.5 القيمة 1500000000. تعني القيمة 0 في أي حقل عدم تعيين حد. وأصغر حد للذاكرة يقبله Docker هو 6m، وتحت هذا الحد يرفض الحاوي بدء التشغيل.
ماذا يحدث عندما يصل حاوي إلى الحد
لا تتباطأ الحاوية. بل تتوقف.
عندما تطلب عملية صفحة ويكون cgroup قد بلغ بالفعل memory.max، تستعيد النواة أولًا ما يمكنها استعادته داخل cgroup: ذاكرة التخزين المؤقت للصفحات النظيفة، ثم الصفحات التي يمكنها تبديلها إلى مساحة التبديل. إذا لم تُحرر عملية الاستعادة مساحة كافية، يختار قاتل نفاد الذاكرة في cgroup عمليةً داخل الحاوية ويرسل إليها SIGKILL. يؤدي إنهاء PID 1 الخاص بالحاوية إلى إنهاء الحاوية. رمز الخروج 137 يساوي ببساطة 128 زائد الإشارة 9، لذلك فإن 137 هو بصمة أي SIGKILL، وليس دليلًا بحد ذاته على نفاد الذاكرة.
docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1true 137 يعني أن عملية نفاد الذاكرة أنهت العملية. أما false 137 فيعني أن شيئًا آخر أرسل SIGKILL، والسبب المعتاد هو بلوغ docker compose stop مهلة السماح البالغة عشر ثوانٍ لأن التطبيق تجاهل SIGTERM. يوفر هذا التمييز ساعات من التشخيص، لأن المشكلتين لا علاقة بينهما.
يسجل الحدث أيضًا في موضعين آخرين. راقب الخدمة الخفية مباشرة:
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 فهو نفاد ذاكرة على المضيف، ما يعني أن الجهاز نفسه نفدت منه ذاكرة RAM. هذه هي المشكلة التي يُفترض أن تمنعها الحدود، ولذلك فإن ظهورها يدل على أن مجموع حدودك مرتفع جدًا، أو أن بعض الخدمات لا تملك أي حد على الإطلاق.
مع restart: unless-stopped، يصعب اكتشاف حلقة نفاد الذاكرة، لأن الخدمة تظهر بحالة التشغيل في docker compose ps بعد ثانية من توقفها. تحقق من عمود مدة التشغيل وعدد مرات إعادة التشغيل، واربط الحد بـ فحص صحة يبلّغ عن أن التطبيق غير سليم حتى تكون الحاوية التي تتوقف باستمرار ظاهرةً من دون أن تراقبها بنفسك.
الحجز تلميح، والحد هو القاعدة
reservations.memory (الإصدار الأقدم mem_reservation) هو حد أدنى مرن. يصفه Docker بأنه حد مرن يُفعَّل عندما يكتشف البرنامج الخفي وجود تنافس على الموارد أو انخفاضًا في الذاكرة على المضيف. لا يمنع الحاوية أبدًا من تجاوزه، ولا يضمن أبدًا توفر الذاكرة عند طلب الحاوية لها. وهو يوجّه النواة فقط إلى استرداد الذاكرة أولًا من الحاويات التي تتجاوز قيمة الحجز.
لذلك، لا يوفر الحجز أي حماية بمفرده. استخدمه لتحديد خدمة تريد معاملتها بأولوية عند الضغط، واعتمد على الحد لتحقيق الأمان. أبقِ الحجز أقل من الحد، وإلا فلن تبدأ الحاوية: يرفض Docker الإعداد مع Minimum memory limit can not be less than memory reservation limit.
احتساب مساحة التبديل بصدق
تأتي معظم صور VPS من دون ملف تبديل على الإطلاق. شغّل swapon --show وfree -h. إذا كان إجمالي مساحة التبديل يساوي صفرًا، فلن تؤثر أي من الإعدادات المتعلقة بمساحة التبديل أدناه، ويصبح حد الذاكرة لديك حدًا خالصًا لذاكرة RAM.
لا تمثل memswap_limit مقدار مساحة التبديل. بل تمثل إجمالي الذاكرة ومساحة التبديل معًا. باستخدام mem_limit: 1g وmemswap_limit: 2g، تحصل الحاوية على 1GB من ذاكرة RAM و1GB من مساحة التبديل. يؤدي ضبط القيمتين على القيمة نفسها إلى منع الحاوية من استخدام مساحة التبديل تمامًا. يؤدي ضبط mem_limit وترك memswap_limit من دون تعيين إلى السماح للحاوية باستخدام مساحة التبديل حتى حجم حد الذاكرة مجددًا.
يستخدم Ubuntu 24.04 وDebian 13 cgroup v2 افتراضيًا، حيث تكون مساحة التبديل عدادًا منفصلًا (memory.swap.max)، ويعمل ذلك من دون إعداد إضافي. تظهر الرسالة القديمة Your kernel does not support swap limit capabilities في المضيفات التي تستخدم cgroup v1 والتي تم تشغيلها من دون swapaccount=1. في هذه المضيفات، يظل حد الذاكرة ساريًا، بينما يتم تجاهل جزء مساحة التبديل.
كن صريحًا بشأن الفائدة التي تقدمها مساحة التبديل. فهي تجعل عملية OOM kill أبطأ، ولا تجعل حدوثها أقل احتمالًا، لأن العملية التي تتسرب منها الذاكرة تملأ مساحة التبديل بالسهولة نفسها التي تملأ بها ذاكرة RAM. وفي الوقت نفسه، تؤدي حاوية تفرط في استخدام مساحة التبديل على وحدة تخزين VPS مشتركة إلى إبطاء كل خدمة أخرى على الخادم. بالنسبة إلى أي خدمة حساسة لزمن الاستجابة، يفشل الحد الصحيح من دون مساحة تبديل بسرعة أكبر وبطريقة أكثر قابلية للتنبؤ.
لماذا يبدو استخدام الذاكرة أسوأ مما هو عليه
يتضمن الرقم MEM USAGE في docker stats ذاكرة التخزين المؤقت للصفحات، لذلك يزداد استهلاك الحاوية التي تقرأ ملفات كبيرة حتى يقترب من حدها ثم يستقر هناك. هذا طبيعي، وليس تسرّبًا، لأن النواة تستعيد ذاكرة التخزين المؤقت النظيفة قبل استدعاء قاتل نفاد الذاكرة OOM. ستبدو خدمة مثل خادم وسائط 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:
- الوكيل العكسي: حد قدره 128m. هذه عملية صغيرة، ويكشف هذا الحد الضيق جدًا إعادة تحميل إعدادات خارجة عن السيطرة فورًا.
- PostgreSQL: حد قدره 2g، مع ضبط
shared_buffersعلى نحو 512MB في إعدادات قاعدة البيانات. - حاوية التطبيق: حد قدره 1g.
- العامل في الخلفية: حد قدره 512m.
- خدمة الوسائط أو الملفات: حد قدره 2g، وسيكون معظمها ذاكرة التخزين المؤقت للصفحات.
لا تنسخ هذه الأرقام إلى مكدسك الخاص. شغّل الخدمات تحت حمل فعلي لمدة يوم، وراقب docker stats، وسجّل قيمة anon القصوى لكل حاوية، ثم أضف نحو نصفها مرة أخرى كهامش احتياطي. الحد الضيق جدًا أسوأ من عدم وضع حد، لأنه يوقف خدمة سليمة أثناء زيادة طبيعية في حركة الشبكة.
هناك مشكلة شائعة تستحق ملاحظة مستقلة. يكون الحد غير مرئي لمعظم بيئات التشغيل ما لم تُخبرها به. سيضبط PostgreSQL shared_buffers وwork_mem بسعة تتجاوز حد الحاوية، ثم يتعرض للقتل. تحتاج JVM (آلة Java الافتراضية) إلى -XX:MaxRAMPercentage=75 لضبط الكومة استنادًا إلى حد cgroup بدلًا من ذاكرة RAM في المضيف. يحتاج Node.js إلى --max-old-space-size بالميغابايت، مع ضبطه على قيمة أقل من حد الحاوية، وإلا سمح جامع البيانات المهملة لديه بنمو الكومة حتى تتدخل النواة. لا يتفاوض cgroup. بل يقتل.
تعمل حدود CPU بشكل مختلف تمامًا
cpus: "1.5" تعني 150% من نواة واحدة، وتُفرض باعتبارها حصة من CFS (المجدول العادل تمامًا). يحصل الحاوي على 150ms من وقت CPU في كل فترة مدتها 100ms، ويُوزَّع ذلك بين جميع خيوطه. عند استهلاك هذه الحصة، تجعله النواة ينتظر حتى تبدأ الفترة التالية.
هذا هو الفرق المهم. يُقتل الحاوي عند تجاوز حد الذاكرة. أما عند تجاوز حد CPU، فيُقيَّد ويستمر في العمل بوتيرة أبطأ. لذلك يمكنك ضبط حد CPU بقوة، بينما يحتاج حد الذاكرة إلى هامش احتياطي.
cpu_shares أداة مختلفة: إنها وزن نسبي لا تكون له أهمية إلا عند تشبّع CPUs فعليًا. يقسّم حاويان لهما shares بقيمتي 1024 و512 نواة مشغولة بنسبة تقارب اثنين إلى واحد، ولا يُقيَّد أيٌّ منهما على جهاز خامل. استخدم shares لترتيب الخدمات حسب الأهمية، واستخدم cpus عندما تحتاج إلى حد أقصى فعلي، مثل منع مهمة تحويل الترميز الليلية من استنزاف موارد خادم الويب لديك.
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 في النواة هو السبب الشائع، لكن مهلة الإيقاف تنتج الرمز نفسه عندما يتجاهل التطبيق 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 إذا كان باقي ملفك يستخدم المفاتيح القديمة ذات المستوى الأعلى. ضبط الخيارين للخدمة نفسها يجعل الملف أصعب قراءة فقط، لذلك اختر أحدهما وتحقّق من النتيجة باستخدام docker inspect.
لماذا يبقى حاويتي عند حد الذاكرة الكامل من دون إنهائها؟
يتضمن رقم الاستخدام في docker stats ذاكرة التخزين المؤقت للصفحات، التي تحررها النواة تحت الضغط بدلًا من تشغيل إنهاء OOM. شغّل docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat واقرأ قيمة anon، فهي مجموعة العمل التي لا يمكن استردادها. تشير قيمة file المرتفعة بجانب قيمة anon المنخفضة إلى أن الحاوية تنفّذ إدخالًا وإخراجًا للقرص، لا إلى أنها على وشك التوقف.
كم يجب أن أترك من ذاكرة RAM غير مخصصة على VPS بسعة 8GB؟
اترك نحو 1GB للنواة وDocker daemon وsshd وjournald وصدفة الأوامر الخاصة بك، ثم أبقِ مجموع حدود جميع الحاويات أقل من 7GB المتبقية. راقب قيمة anon القصوى لكل حاوية تحت حمل حقيقي لمدة يوم قبل اعتماد الأرقام، وتعامل مع الإجمالي باعتباره ميزانية لا هدفًا ينبغي استنفاده.