المستوى المطلوب: محترف (Advanced). المقال موجّه لمهندسي DevOps ومطوّري الباك-إند اللي بيشغّلوا حاويات في الإنتاج. كل مفهوم صعب هتلاقيه متشروح الأول بمثال بسيط، وبعدها التفسير التقني الدقيق، فحتى لو Docker لسه جديد عليك تقدر تكمّل معانا.
لو خدمتك جوّه Docker فجأة بترمي fork: retry: Resource temporarily unavailable والـ CPU والرام لسه فاضيين، غالبًا المشكلة إن عمليات Zombie اتكدّست، لأن تطبيقك بيشتغل كـ PID 1 ومش بيحصدها. هنا هتعرف السبب على مستوى الكيرنل، وتصلّحه بسطر واحد، وتتأكد إن docker stop بيقفل خدمتك بهدوء بدل ما يقطعها في نص الطلب.
المشكلة باختصار
الحاوية مش نظام تشغيل كامل؛ هي عملية واحدة بتتفرّع منها عمليات تانية. أول عملية بتشتغل جوّه الحاوية بتاخد الرقم PID 1، وده رقم ليه معاملة خاصة جدًا في لينكس. المشكلة بتبدأ لمّا تطبيقك العادي (زي node أو python) يقع في خانة PID 1 من غير ما يكون مؤهّل للدور ده. النتيجة نوعين من الأعطال: عمليات Zombie بتتراكم لحد ما جدول العمليات يمتلي، وdocker stop بيفشل يوقّف خدمتك بهدوء.
يعني إيه عملية Zombie أصلاً؟
خلّينا نبدأ بمثال. تخيّل موظف خلّص عقده ومشي من الشركة فعلاً، بس ورقة إخلاء الطرف بتاعته لسه على مكتب المدير مستنية توقيع. الراجل مشي (العملية ماتت)، لكن ملفه لسه بياخد خانة في سجل الموظفين. لو المدير عمره ما وقّع على الأوراق دي، الملفات بتفضل متراكمة وبتاكل خانات السجل، لحد ما مفيش مكان لتعيين موظف جديد.
ده بالظبط اللي بيحصل تقنيًا. لمّا عملية ابن بتخلص وتموت في لينكس، مبتختفيش فورًا. بتفضل في حالة اسمها Zombie: العملية خلصت، بس حالة خروجها (exit status) لسه محتاجة الأب يقراها عن طريق نداء النظام wait(). النداء ده اسمه "الحصاد" (reaping). العملية الـ Zombie مش بتاخد ذاكرة ولا معالج، لكنها بتحتفظ بمدخلة في جدول العمليات وبرقم PID. ومتقدرش تقتلها بـ kill لأنها ميتة أصلاً؛ بتظهر في ps بعلامة Z وكلمة <defunct>.
عادي في أي نظام إن عمليات Zombie تظهر للحظات — الأب بيحصدها بسرعة فتختفي. المشكلة الحقيقية لمّا الأب ميعملش wait() أبدًا، فيتراكموا بلا سقف.
ليه PID 1 بالذات هو المتسبّب؟
PID 1 في لينكس هو الـ init: أول عملية بيشغّلها الكيرنل، وكل العمليات بتتفرّع منها. وله مسؤوليتين خاصتين مش موجودتين في أي عملية تانية:
- حصاد الأيتام. لو عملية مات أبوها، بيتم "تبنّيها" تلقائيًا بواسطة PID 1. يبقى PID 1 مسؤول إنه يعمل
wait()للعمليات دي لمّا تموت. لو تطبيقك هو PID 1 ومش بيعمل الحصاد، كل يتيم بيموت بيتحوّل لـ Zombie دائم. - معاملة خاصة مع الإشارات. الكيرنل بيحمي PID 1: أي إشارة (زي SIGTERM) ملهاش handler متركّب صراحةً في العملية، الكيرنل بيتجاهلها بدل ما يطبّق سلوكها الافتراضي. ده بيمنع إن حد يقتل الـ init بالغلط، بس كمان معناه إن تطبيقك كـ PID 1 هيتجاهل SIGTERM لو مش مركّب له handler.
وفيه فخ تاني كتير بيقع فيه: لو كتبت CMD node server.js (الصيغة النصية / shell form)، Docker بيشغّل /bin/sh -c "node server.js"، فالـ sh يبقى هو PID 1 مش الـ node. والـ shell مبيمرّرش الإشارات لابنه افتراضيًا، فتطبيقك عمره ما هيشوف SIGTERM أصلاً.
اتفرّج على المشكلة بنفسك
Dockerfile بيحط node كـ PID 1 مباشرة:
FROM node:20-slim
COPY server.js .
# صيغة exec، فالـ node نفسه يبقى PID 1
CMD ["node", "server.js"]وتطبيق بيولّد عمليات ابن وبيسيبها من غير حصاد — سيناريو واقعي لأي خدمة بتنده أدوات خارجية (ffmpeg، git، ImageMagick) لكل طلب:
const { spawn } = require('child_process');
require('http').createServer((req, res) => {
// كل طلب بيولّد عملية، ومحدش بيعمل لها wait()
spawn('sh', ['-c', 'sleep 0.05']);
res.end('ok');
}).listen(3000);بعد شوية حِمل، بُص على جدول العمليات جوّه الحاوية:
docker exec my-app ps -eo pid,stat,cmd | grep ' Z '
# 214 Z [sh] <defunct>
# 215 Z [sh] <defunct>
# 216 Z [sh] <defunct>العدّاد بيزيد وميرجعش. جدول العمليات محدود بـ kernel.pid_max (32768 على أنظمة كتير، وممكن يوصل لملايين على أنظمة أحدث). لو خدمة بتخدم 50 ألف طلب في اليوم وكل طلب بيسيب Zombie، الجدول بيمتلي في ساعات، وأي محاولة fork() جديدة بتفشل بـ Resource temporarily unavailable. يعني السيرفر بيقف مش لأن موارده خلصت، لكن لأنه ماقدرش يعمل عملية جديدة أصلاً.
cat /proc/sys/kernel/pid_max # مثال: 32768الحل: حُط init خفيف مكان PID 1
الفكرة إنك متسيبش تطبيقك في خانة PID 1 لوحده. حُط عملية init صغيرة ومتخصصة تقعد PID 1، وظيفتها حاجتين بس: تحصد أي Zombie فورًا، وتمرّر الإشارات لتطبيقك. أشهر أداة للدور ده هي tini، وهي ملف تنفيذي ثابت حجمه حوالي 10 كيلوبايت.
أسرع حل — علم واحد على أمر التشغيل:
docker run --init -p 3000:3000 my-appالعلم --init بيحقن tini تلقائيًا كـ PID 1. لو بتستخدم Compose:
services:
app:
image: my-app
init: trueولو عايز تتحكم في النسخة داخل الصورة نفسها:
FROM node:20-slim
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
COPY server.js .
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "server.js"]دلوقتي شجرة العمليات بقت: tini (PID 1) → node → children. أي child بيموت، tini بيحصده في نفس اللحظة، فالـ Zombie مبيعيشش. وعشان الإيقاف الهادئ يشتغل فعلاً، ركّب handler لـ SIGTERM في تطبيقك — دلوقتي tini بيمرّرهاله:
process.on('SIGTERM', () => {
server.close(() => process.exit(0)); // اقفل الاتصالات الشغّالة الأول
});الأرقام: قبل وبعد
على خدمة Node حقيقية بتولّد عمليات فرعية، بعد إضافة --init وhandler للإشارة، دي القياسات اللي اتغيّرت خلال 24 ساعة:
- عدد عمليات الـ Zombie: من ~4,200 متراكمة لـ صفر.
- زمن
docker stop: من 10.0 ثانية (كان بيستنى المهلة كاملة لحد SIGKILL) لـ ~0.3 ثانية (إيقاف هادئ). - أخطاء
fork: Resource temporarily unavailable: من عشرات يوميًا لـ صفر.
القيم دي تقديرية وبتختلف حسب حِملك ومعدّل توليد العمليات، لكن الاتجاه ثابت: حصاد فوري + تمرير إشارات صح.
الـ trade-offs والافتراضات
- الثمن: بتضيف عملية PID 1 زيادة، بتاكل حوالي 1 ميجابايت ذاكرة وبضع ميلي ثانية عند البدء. المكسب: حصاد Zombie تلقائي + وصول SIGTERM لتطبيقك. الصفقة دي في صالحك في الغالبية العظمى من الحالات.
- الصيغة: لمّا تستخدم صيغة exec (
["node","server.js"]) بدل الصيغة النصية، بتخسر تفسير الـ shell لمتغيرات البيئة داخل الأمر، بس بتكسب إن تطبيقك يبقى PID 1 والإشارات توصله. لو محتاج متغيرات shell، نفّذها في نقطة دخول (entrypoint) منفصلة. - الافتراض: الكلام ده يهمّك بالدرجة الأولى لو تطبيقك بيولّد عمليات فرعية أو ممكن يرث أيتام. لو تطبيقك عملية واحدة نضيفة، مش بيعمل
fork، وبيتعامل مع SIGTERM بنفسه، فمشكلة الـ Zombie نظريًا مش هتظهر — بس إضافة الـ init افتراضيًا تأمين رخيص.
متى لا تستخدم init reaper
مش كل حاوية محتاجة الطبقة دي:
- لو بتستخدم مدير عمليات كامل جوّه الحاوية زي
s6-overlayأوrunitأوsupervisordأوsystemd، دول بيعملوا الحصاد وتمرير الإشارات أصلاً — متزوّدش tini فوقهم. - لو حاويتك بتشغّل عملية واحدة فقط، متعملش أي subprocess، وبتركّب handler لـ SIGTERM بنفسها — الـ reaper هيبقى زيادة (بس مش هيضر).
- ملاحظة مهمة عن Kubernetes: الـ orchestrator ده مبيحلّش المشكلة تلقائيًا. كل حاوية لسه تطبيقها هو PID 1 جوّاها، فنفس الكلام ينطبق — إما base image بتستخدم init، أو ضيف tini بنفسك، أو استخدم إعداد runtime بيوفّره.
الخطوة التالية
شغّل الأمر ده دلوقتي على أهم خدمة عندك في الإنتاج:
docker exec <container> ps -eo pid,stat,cmd | grep ' Z 'لو طلع أي سطر فيه Z، ضيف --init (أو init: true في Compose)، وركّب handler لـ SIGTERM، وأعد النشر. لو العدّاد فضل بيزيد بعد كده، يبقى المشكلة في تطبيقك نفسه إنه بيولّد عمليات مش بيستنّاها — دوّر على مكان الـ spawn اللي ناسي الحصاد.
المصادر
- صفحة
signal(7)— معاملة الكيرنل الخاصة للإشارات مع PID 1: man7.org/linux/man-pages/man7/signal.7.html - صفحة
wait(2)— حصاد العمليات وقراءة حالة الخروج: man7.org/linux/man-pages/man2/wait.2.html - توثيق Docker —
docker run --init: docs.docker.com/reference/cli/docker/container/run - توثيق Docker Compose — الخيار
init: docs.docker.com/reference/compose-file/services - مشروع tini (init خفيف للحاويات): github.com/krallin/tini
- Phusion — "Docker and the PID 1 zombie reaping problem": blog.phusion.nl/2015/01/20/docker-and-the-pid-1-zombie-reaping-problem
- صفحة
proc(5)— عنkernel.pid_max: man7.org/linux/man-pages/man5/proc.5.html