تخطّى إلى المحتوى الرئيسي
الإيقاف الآمن لتطبيقات Node.js وNestJS داخل Docker: إشارة SIGTERM وتصريف الطلبات وترتيب الـ Hooks

الإيقاف الآمن لتطبيقات Node.js وNestJS داخل Docker: إشارة SIGTERM وتصريف الطلبات وترتيب الـ Hooks

October 15, 2026
هندسة الأنظمة الخلفية
8 دقيقة قراءة

الخلاصة: الإيقاف الآمن (graceful shutdown) يعني أن عملية Node.js التي تستقبل إشارة SIGTERM تتوقف عن قبول عمل جديد، وتُكمل ما هو جارٍ، وتغلق اتصالات قاعدة البيانات وRedis، ثم تخرج قبل انتهاء مهلة الإيقاف التي يمنحها المنسّق (orchestrator). في تطبيق NestJS داخل Docker يتطلب ذلك صيغة exec في أمر CMD، واستدعاء app.enableShutdownHooks()، ووضع خطوات التنظيف في الـ lifecycle hook الذي يعمل بعد أن ينتهي خادم HTTP من تصريف الطلبات.

كانت إعادة نشر واجهات NestJS البرمجية التي نشغّلها داخل حاويات تكلّفنا عدداً من أخطاء 502، وأحياناً مهمة في الطابور (queue) تُنفَّذ مرتين. لم تُظهر السجلات أي أخطاء. كان الدليل في الأمر docker ps -a: الحاويات القديمة كانت تنتهي بالحالة Exited (137). رمز الخروج 137 يساوي 128 + 9، أي أن العملية أُنهيت بإشارة SIGKILL ولم يعمل كود الإيقاف لدينا إطلاقاً.

أهم النقاط

• يرسل الأمر docker stop إشارة SIGTERM، وينتظر 10 ثوانٍ افتراضياً، ثم يرسل SIGKILL؛ أما Kubernetes فينتظر terminationGracePeriodSeconds التي تساوي 30 ثانية افتراضياً.

• رمز الخروج 137 (128 + 9) يعني SIGKILL: إما أن العملية تجاوزت مهلة الإيقاف أو قتلها OOM killer.

• صيغة shell في CMD، واستخدام npm start، وتشغيل Node.js بوصفه PID 1، كلٌّ منها قد يمنع وصول SIGTERM إلى الكود؛ استخدم صيغة exec مثل CMD ["node", "dist/main.js"] مع الخيار init: true في Docker.

• يتجاهل NestJS إشارات الإنهاء حتى تستدعي app.enableShutdownHooks()، وبعدها ينفّذ onModuleDestroy ثم beforeApplicationShutdown ثم إغلاق خادم HTTP ثم onApplicationShutdown.

• أغلق مجمّعات اتصالات قاعدة البيانات (pools) وعملاء Redis داخل onApplicationShutdown وليس onModuleDestroy، لأن onModuleDestroy يعمل بينما لا تزال الطلبات جارية.

ماذا يحدث عندما يوقف Docker حاوية Node.js؟

يرسل الأمر docker stop إشارة SIGTERM (أو الإشارة المحددة في STOPSIGNAL داخل الصورة) إلى العملية PID 1 في الحاوية، ثم ينتظر مهلة إيقاف، ثم يرسل SIGKILL. المهلة الافتراضية 10 ثوانٍ، ويمكن تغييرها عبر docker stop --time أو المفتاح stop_grace_period في Compose. لا يمكن التقاط SIGKILL، لذا يُقطع كل ما لم يكتمل: استجابات HTTP المفتوحة، والمعاملات (transactions) نصف المنفّذة، ومهام الطابور التي التُقطت ولم يُؤكَّد إنجازها.

يستخدم Kubernetes العقد نفسه عبر terminationGracePeriodSeconds التي تساوي 30 ثانية افتراضياً، مع حالة سباق إضافية: يُزال الـ pod المتوقف من نقاط نهاية الـ Service بالتوازي مع SIGTERM لا قبلها، لذا قد يستقبل طلبات جديدة لفترة قصيرة بعد بدء الإيقاف.

يخبرك رمز خروج الحاوية بالمسار الذي سلكه الإيقاف:

• رمز الخروج 0: خرجت العملية من تلقاء نفسها، أي أن معالج الإيقاف عمل واستدعى process.exit(0).

• رمز الخروج 143 (128 + 15، أي SIGTERM): انتهت العملية عند SIGTERM؛ ويعيد NestJS إطلاق الإشارة بعد انتهاء الـ hooks، لذا ينتهي الإيقاف النظيف لتطبيق Nest عادةً بهذا الرمز.

• رمز الخروج 137 (128 + 9، أي SIGKILL): انتهت المهلة أو تدخّل OOM killer، وقُطع العمل في منتصفه.

يعرض docker inspect القيمة "OOMKilled": true في حالة نفاد الذاكرة فقط، وبذلك يمكن التمييز بين سببَي رمز الخروج 137.

لماذا لا يستقبل تطبيق Node.js إشارة SIGTERM داخل Docker؟

يرسل Docker إشارة SIGTERM إلى PID 1، وهناك ثلاثة إعدادات شائعة تضع في هذا الموقع شيئاً غير عملية Node.js قادرة على التعامل مع الإشارات:

• صيغة shell في CMD: الأمر CMD node dist/main.js يعمل على شكل /bin/sh -c "node dist/main.js"، فيصبح الـ shell هو PID 1 وقد لا يمرّر SIGTERM إلى Node.js.

• استخدام npm start أمراً للحاوية: يقف npm بين Docker والتطبيق، ويوصي دليل أفضل ممارسات Node.js مع Docker بتشغيل node مباشرة.

• تشغيل Node.js بوصفه PID 1: يتجاهل Linux الإشارات المرسلة إلى PID 1 ما لم تكن العملية قد ثبّتت معالجاً لها، ولم يُصمَّم Node.js ليعمل بوصفه PID 1.

الحل هو صيغة exec في الأمر مع عملية init صغيرة:

في ملف Dockerfile نستخدم صيغة exec، أي CMD ["node", "dist/main.js"]، حتى يشغّل Docker الأمر node مباشرة دون shell وسيط. وفي docker-compose.yml تضبط خدمة api الخيار init: true لتعمل tini بوصفها PID 1، والخيار stop_grace_period: 30s بدلاً من المهلة الافتراضية لـ Docker البالغة 10 ثوانٍ.

الخيار init: true في Compose (أو docker run --init) يشغّل tini بوصفها PID 1، فتمرّر الإشارات إلى Node.js وتتخلّص من العمليات الزومبي. هذا مهم حتى مع وجود معالج، لأن NestJS ينهي الإيقاف بإعادة إطلاق الإشارة، وقد تتجاهل عملية PID 1 إشارة SIGTERM المُعاد إطلاقها أيضاً.

كيف نفعّل الإيقاف الآمن في NestJS؟

لا يستمع NestJS إلى إشارات الإنهاء افتراضياً؛ يجب تفعيل ذلك باستدعاء app.enableShutdownHooks() في ملف main.ts. من دون هذا الاستدعاء لا تعمل onModuleDestroy وbeforeApplicationShutdown وonApplicationShutdown عند SIGTERM، بل فقط عندما يستدعي الكود app.close() بنفسه.

في main.ts نستدعي app.enableShutdownHooks() مباشرة بعد NestFactory.create(AppModule). ونسجّل أيضاً process.once('SIGTERM') مع مؤقّت setTimeout مدته 25 ثانية يسجّل خطأً ويستدعي process.exit(1)، ونستدعي .unref() على هذا المؤقّت حتى لا يُبقي العملية حية بمفرده.

المهلة القصوى داخل التطبيق هي الجزء الذي أغفلناه في المرة الأولى. إذا علقت خطوة تنظيف، كعميل Redis عالق في إعادة الاتصال أو مهمة لا تنتهي، تصل SIGKILL دون أي سطر في السجل يشرح السبب. أما المؤقّت المضبوط على بضع ثوانٍ أقل من مهلة الإيقاف فيخرج برسالة واضحة، ويمنع .unref() المؤقّت نفسه من إبقاء العملية مفتوحة.

يعيد NestJS 11 إطلاق الإشارة الأصلية بعد انتهاء shutdown hooks، ولهذا يخرج الإيقاف النظيف بالرمز 143. وتقبل الإصدارات الحديثة من NestJS 11 أيضاً app.enableShutdownHooks([], { useProcessExit: true })، الذي يستدعي process.exit(0) بدلاً من ذلك، وهو مفيد عندما يحتاج مسجّل غير متزامن مثل Pino transport إلى حدث exit لتفريغ السجلات.

أي lifecycle hook في NestJS يجب أن يغلق اتصال قاعدة البيانات؟

أغلق مجمّعات اتصالات قاعدة البيانات وعملاء Redis داخل onApplicationShutdown، فهو الـ hook الوحيد للإيقاف في NestJS الذي يعمل بعد توقف خادم HTTP. عند وصول إشارة الإنهاء ينفّذ NestJS 11 هذا التسلسل:

1. يعمل onModuleDestroy() فوراً، بينما لا يزال خادم HTTP يخدم الطلبات.

2. يعمل beforeApplicationShutdown(signal) بعد انتهاء جميع معالجات onModuleDestroy().

3. يُغلق خادم HTTP: يستدعي NestJS الدالة server.close() في Node.js عبر محوّل HTTP وينتظر الطلبات الجارية.

4. يعمل onApplicationShutdown(signal) بعد إغلاق الخادم.

معظم أمثلة NestJS تقطع اتصال قاعدة البيانات داخل onModuleDestroy الذي يعمل قبل الخطوة 3، لذا قد يفقد طلب جارٍ قاعدة بياناته في منتصف الاستعلام؛ ومع node-postgres يرمي كل استدعاء pool.query() بعد pool.end() خطأً. لذلك نقلنا كل خطوة إلى الـ hook الذي يناسب توقيتها:

تطبّق خدمة ShutdownService لدينا الـ hooks الثلاثة. يضبط onModuleDestroy العَلَم isShuttingDown. وينتظر beforeApplicationShutdown بالتوازي فترة انتظار مدتها DRAIN_DELAY_MS (القيمة الافتراضية 0) واستدعاء worker.close() في BullMQ. أما onApplicationShutdown فينفّذ Promise.allSettled على pool.end() الخاص بـ node-postgres وredis.quit() الخاص بـ ioredis.

ترمي نقطة النهاية GET /health/ready الاستثناء ServiceUnavailableException بمجرد أن تصبح قيمة isShuttingDown صحيحة، فتتوقف موازنات الأحمال وreadiness probes في Kubernetes عن إرسال حركة جديدة. ويوقف worker.close() في BullMQ التقاط مهام جديدة وينتظر المهام النشطة، وهذا ما أنهى التنفيذ المكرر لدينا، لأن المهمة التي تُقتل بإشارة SIGKILL تُكتشف لاحقاً على أنها متوقفة (stalled) وتُعالَج من جديد. ولا تفيد قيمة DRAIN_DELAY_MS إلا عندما يكون أمام الحاوية شيء يوجّه الحركة بناءً على فحص الصحة؛ أما خلف reverse proxy يتجاهل فحوصات الصحة فنتركها على 0.

لماذا يعلق خادم HTTP في Node.js عند استدعاء close()؟

يوقف server.close() في Node.js قبول الاتصالات وينتظر الاتصالات المفتوحة، وقبل Node.js 19 كان ذلك يشمل اتصالات keep-alive الخاملة التي قد تُبقي الإيقاف معلّقاً حتى انتهاء keepAliveTimeout. منذ Node.js 19 يغلق server.close() الاتصالات الخاملة أيضاً، وقد أضاف Node.js 18.2 الدالتين server.closeIdleConnections() وserver.closeAllConnections() للتحكم اليدوي. ومن دون NestJS، يكفي نمط التصريف كاملاً في بضعة أسطر:

نسجّل process.once('SIGTERM')، ونبدأ مؤقّت المهلة القصوى نفسه بمدة 25 ثانية مع .unref()، ثم نستدعي server.close() مع دالة رد تنتظر pool.end() ثم تستدعي process.exit(0).

يوفّر NestJS أيضاً الخيار NestFactory.create(AppModule, { forceCloseConnections: true }). مع محوّل Express يستدعي هذا الخيار socket.destroy() على كل socket متتبَّع عند الإيقاف، بما فيها التي تحمل طلباً لم يكتمل بعد. على Node.js 24، وهو إصدار LTS الحالي، نعامل forceCloseConnections حلاً أخيراً لا إعداداً افتراضياً.

كم يجب أن تكون مدة مهلة الإيقاف؟

يجب أن تغطي مهلة الإيقاف تأخير التصريف، وأبطأ طلب أو مهمة مستعد لانتظارها، وإغلاق الاتصالات، مع هامش إضافي. نادراً ما تكفي مهلة Docker الافتراضية البالغة 10 ثوانٍ بمجرد وجود worker للطابور، لذا تضبط ملفات Compose لدينا stop_grace_period: 30s مع مهلة قصوى داخل التطبيق قدرها 25 ثانية. وأي مهمة قد تتجاوز مهلة الإيقاف يجب أن تكون قابلة للاستئناف أو idempotent.

يحمي الإيقاف الآمن العمل الجاري بالفعل، لكنه لا يحقق نشراً دون توقف (zero-downtime). مع حاوية واحدة، ستفشل عند الـ proxy الطلبات التي تصل بين توقف الحاوية القديمة وجاهزية الجديدة، لذا يتطلب النشر دون توقف نسخة ثانية تخدم الحركة بينما تُصرّف الأولى طلباتها.

الأسئلة الشائعة

س: لماذا تستغرق حاوية Docker لدي 10 ثوانٍ حتى تتوقف؟

ج: لأن العملية لا تخرج عند SIGTERM، فينتظر Docker مهلته الافتراضية البالغة 10 ثوانٍ ثم يرسل SIGKILL. الأسباب المعتادة هي صيغة shell في CMD، أو استخدام npm start أمراً للحاوية، أو تشغيل Node.js بوصفه PID 1؛ استخدم صيغة exec في CMD مع init: true.

س: ماذا يعني رمز الخروج 137 لحاوية Node.js؟

ج: الرمز 137 يساوي 128 + 9، أي أن العملية قُتلت بإشارة SIGKILL، إما لأن الإيقاف تجاوز مهلته أو لأن OOM killer في النواة أنهاها. ويعرض docker inspect القيمة "OOMKilled": true في الحالة الثانية فقط.

س: هل enableShutdownHooks() مفعّل افتراضياً في NestJS؟

ج: لا. لا يشغّل NestJS الـ lifecycle hooks الخاصة بالإيقاف عند إشارة الإنهاء إلا بعد استدعاء app.enableShutdownHooks()؛ وإلا فإنها تعمل فقط عندما يستدعي الكود app.close().

س: هل يمنحني الإيقاف الآمن نشراً دون توقف؟

ج: لا. يسمح الإيقاف الآمن للطلبات والمهام الجارية بالاكتمال؛ أما النشر دون توقف فيحتاج أيضاً إلى نسخة سليمة أخرى تخدم الحركة الجديدة بينما تُصرّف النسخة القديمة طلباتها.