Why NextWhy Next
सूची पर लौटें
💻 डेवलपमेंट9 मिनट में पढ़ें

Shutdown Hooks दो बार चल रहे थे - जब SIGTERM के दो मालिक हों

हर डिप्लॉय पर Sentry में Prisma P2028 एरर आ रही थी। शटडाउन सीक्वेंस ठीक करने बैठा तो पता चला कि NestJS का enableShutdownHooks और graceful shutdown लाइब्रेरी दोनों SIGTERM पकड़ रहे थे, जिससे शटडाउन हुक्स हर बार दो बार चल रहे थे।

मुख्य सारांश

डिप्लॉय के दौरान SIGTERM आने पर DB, अभी प्रोसेस हो रहे जॉब से पहले ही बंद हो जाता था, जिससे Prisma P2028 एरर आती थी। वजह यह थी कि वर्कर शटडाउन और DB शटडाउन एक ही लाइफ़साइकल फ़ेज़ में थे - वर्कर को इससे पहले वाले beforeApplicationShutdown में drain करके क्रम ठीक किया गया। लेकिन रिव्यू के दौरान एक और गहरी समस्या सामने आई। enableShutdownHooks() और http-graceful-shutdown दोनों SIGTERM पकड़ रहे थे, जिससे शटडाउन हुक्स HTTP drain से पहले ही दो बार चल रहे थे। idempotent हुक्स की वजह से यह हादसा नहीं बल्कि सिर्फ़ लॉग स्मेल बनकर रह गया। एक लाइन (enableShutdownHooks) हटाकर सिग्नल का एक ही मालिक तय किया, और असली SIGTERM भेजकर नाप कर वेरीफ़ाई किया।

इस पेज पर

हर डिप्लॉय के साथ Sentry में एक ही जैसी एरर आ रही थी। Transaction already closed. यह वह एरर है जो तब आती है जब Prisma किसी ट्रांज़ैक्शन के बंद होने के बाद उस पर क्वेरी चलाने की कोशिश करता है और P2028 के साथ फेल हो जाता है। यूज़र को कोई नुक़सान नहीं हुआ - BullMQ का retry फ़ौरन दूसरे टास्क में जॉब को सफल कर देता था, और कम्प्लीशन लॉग बिना किसी रुकावट के चलते रहे। फिर भी यह एक ऐसा स्ट्रक्चर था जो डिप्लॉय और ट्रैफ़िक ओवरलैप होते ही चुपचाप बार-बार लौट आता था। इस एरर का पीछा करते हुए मुझे पता चला कि हमारे सर्वर के shutdown hooks असल में हर बार दो बार चल रहे थे।

भाग 1: जॉब से पहले ही DB मर जाता है

एरर के टाइमस्टैंप को CloudWatch लॉग से मिलाया तो टाइमलाइन बिल्कुल फ़िट बैठी। rolling deploy पुराने टास्क को SIGTERM भेजता है। 6s बाद app.close() क्लीनअप शुरू करता है, और Prisma इंजन ट्रांज़ैक्शन बंद कर देता है। ठीक उसके बाद, अभी चल रहे asset-processing जॉब का updateMany उसी बंद हो चुके ट्रांज़ैक्शन पर चलता है और P2028 के साथ फेल हो जाता है। यूज़र लगातार ऑडियो अपलोड कर रहा था, इसलिए डिप्लॉय के उस पल एक एक्टिव जॉब मौजूद था, और उसी जॉब की क्वेरी मारी गई।

वजह शटडाउन सीक्वेंस थी। NestJS शटडाउन के वक़्त लाइफ़साइकल हुक्स को एक तय क्रम में कॉल करता है। दिक़्क़त यह थी कि BullMQ वर्कर को बंद करना और Prisma कनेक्शन काटना दोनों एक ही शटडाउन फ़ेज़ (onApplicationShutdown) में थे। एक ही फ़ेज़ के अंदर मॉड्यूल रजिस्ट्रेशन का क्रम ही एग्ज़िक्यूशन क्रम तय करता है, और यहां संयोग से Prisma पहले कट जाता था, जिससे अभी भी जॉब प्रोसेस कर रहे वर्कर की क्वेरी को कहीं जगह नहीं मिलती थी।

फ़िक्स की दिशा साफ़ थी: वर्कर को एक पहले वाले फ़ेज़ में बंद करो। NestJS के शटडाउन हुक्स में एक क्रम होता है, और beforeApplicationShutdown किसी भी मॉड्यूल के onApplicationShutdown से पहले पूरा हो जाता है। मैंने एक ऐसी सर्विस बनाई जो सारे वर्कर ढूंढकर इसी पहले वाले फ़ेज़ में उन्हें drain करती है (यानी एक्टिव जॉब ख़त्म होने तक इंतज़ार करती है)।

@Injectable()
export class WorkerDrainService implements BeforeApplicationShutdown {
  constructor(private readonly discovery: DiscoveryService) {}

  async beforeApplicationShutdown() {
    // रजिस्टर्ड सभी WorkerHost ढूंढो, और एक्टिव जॉब ख़त्म होने तक इंतज़ार करो।
    // यह फ़ेज़ उस onApplicationShutdown से पहले ख़त्म होता है जहां Prisma कटता है।
    const workers = this.discovery
      .getProviders()
      .filter((p) => p.instance instanceof WorkerHost);
    await Promise.all(workers.map((w) => w.instance.worker.close()));
  }
}

Worker.close() को दोबारा कॉल करने पर वही promise वापस मिलता है, इसलिए यह वर्कर को बंद करने वाले मौजूदा कोड से टकराता नहीं, और जहां Redis नहीं है वहां चुपचाप कुछ नहीं करता। अब डिप्लॉय जॉब से ओवरलैप हो तब भी जॉब पहले ख़त्म होता है और उसके बाद DB बंद होता है। यूनिट टेस्ट जोड़कर PR भेज दिया। यहां तक यह एक सामान्य ऑर्डरिंग बग फ़िक्स था।

भाग 2: SIGTERM के दो मालिक थे

मोड़ रिव्यू में आया। इस फ़िक्स को रिव्यू कर रहे रिव्यूअर ने पूरा शटडाउन पाथ देखते हुए एक सवाल पूछा - SIGTERM को असल में हैंडल कौन कर रहा है?

जांचने पर पता चला कि मालिक दो थे। main.ts में ये दो लाइनें अलग-अलग मौजूद थीं।

// ① Nest को ख़ुद शटडाउन सिग्नल सौंप दो (बिना आर्ग्युमेंट कॉल करने पर SIGTERM·SIGINT दोनों रजिस्टर होते हैं)
app.enableShutdownHooks();

// ② http-graceful-shutdown लाइब्रेरी को भी शटडाउन सौंप दो
setupGracefulShutdown(server, {
  onShutdown: async () => {
    await app.close(); // यहां पूरा लाइफ़साइकल हुक चेन चलता है
  },
});

दोनों SIGTERM पकड़ते हैं। Node सिग्नल आने पर रजिस्टर्ड सभी लिसनर्स को क्रम से कॉल करता है, और एक-दूसरे के पूरा होने का इंतज़ार नहीं करता। इसलिए असल में जब SIGTERM आता है तो यह होता है: Nest का रजिस्टर किया हुआ लिसनर app.close() जैसा ही शटडाउन सीक्वेंस HTTP drain का इंतज़ार किए बिना, तुरंत एक बार चलाता है। इससे अलग, http-graceful-shutdown अपने तरीक़े से पहले HTTP कनेक्शंस ख़ाली करता है और फिर onShutdown में app.close() को दोबारा कॉल करता है। यानी शटडाउन हुक्स दो बार चलते हैं।

सिर्फ़ मानसिक तर्क पर भरोसा नहीं कर सका, इसलिए प्रोडक्शन शटडाउन लॉग दोबारा पढ़े। सबूत वहीं मौजूद था। SIGTERM टाइमस्टैंप के 20ms बाद, HTTP drain ख़त्म होने से पहले ही onModuleDestroy शुरू हो गया था, और SpeechService तथा QueueMonitorService के शटडाउन हुक लॉग दोनों दो-दो बार छपे थे। इसके किसी हादसे में न बदलने की इकलौती वजह यह थी कि हर सर्विस के हुक इस तरह लिखे गए थे कि दो बार कॉल होने पर भी सुरक्षित (idempotent) रहें। इसी डिफ़ेंसिव कोडिंग की बदौलत "दो बार चलना" एक इंसिडेंट नहीं बल्कि सिर्फ़ एक लॉग स्मेल बनकर रह गया, और आख़िरकार उसी स्मेल ने बग पकड़वा दिया।

एक लाइन मिटाना

फ़िक्स एक लाइन मिटाने जितना ही था।

// main.ts
- app.enableShutdownHooks();

enableShutdownHooks() हटाते ही SIGTERM का मालिक सिर्फ़ http-graceful-shutdown रह जाता है। कुछ खोना नहीं पड़ता, क्योंकि उसका onShutdown जो app.close() कॉल करता है वह वैसे भी पूरी लाइफ़साइकल हुक चेन चला देता है। बस इतना बदलता है कि हुक्स दो बार नहीं, एक बार चलते हैं - और वह भी HTTP drain ख़त्म होने के बाद।

एक-लाइन फ़िक्स को भी असल में नाप कर देखना ज़रूरी होता है। बिल्ट सर्वर को असली SIGTERM भेजकर सीक्वेंस आंखों से देखा।

Received SIGTERM → health check 503 → HTTP connections closed
→ shutdown hooks run only once, after HTTP close
→ Drained 56/56 BullMQ workers in 211ms → shutdown completed → clean exit

प्रोडक्शन में जो हुक्स पहले HTTP drain से पहले दो बार चलते थे, अब drain के बाद सिर्फ़ एक बार चलते हैं। यह वेरिफ़िकेशन भाग 1 के वर्कर-ड्रेन फ़िक्स के साथ मिलाकर किया गया, तो दोनों फ़िक्स एक साथ चलने का इंटीग्रेटेड बिहेवियर भी वेरीफ़ाई हो गया।

एक अलग गड़बड़ी भी नोट कर ली। NODE_ENV=development में http-graceful-shutdown तुरंत-एग्ज़िट मोड में काम करता है, इसलिए graceful पाथ बिल्कुल भी नहीं चलता। इसलिए यह वेरिफ़िकेशन development मोड में रीप्रोड्यूस नहीं होता। NODE_ENV=staging में डमी सीक्रेट्स डालकर प्रोडक्शन जैसा ही पाथ चलवाने के बाद ही असल में नाप पाया। शटडाउन पाथ वेरीफ़ाई करते वक़्त सबसे पहले यह देखना चाहिए कि वेरिफ़िकेशन एनवायरनमेंट असल में वही पाथ चला भी रहा है या नहीं।

शटडाउन कोड से सीखा गया सबक़

इस कहानी का सबक़ शटडाउन सीक्वेंस के इस छोटे से दायरे से कहीं आगे जाता है।

एक सिग्नल का मालिक एक ही होना चाहिए। अगर graceful shutdown लाइब्रेरी जोड़ी है, तो फ़्रेमवर्क का ख़ुद का शटडाउन हुक ऑटो-रजिस्ट्रेशन साथ में ऑन नहीं रखना चाहिए। दोनों जिस पल एक ही SIGTERM पकड़ते हैं, शटडाउन सीक्वेंस चुपचाप दो बार चल जाता है। कोई नया टूल लाते वक़्त पहले यह देखना चाहिए कि वह किस सिग्नल या इवेंट का मालिक बनता है, और क्या पहले से कोई कोड वही चीज़ पकड़ रहा है।

शटडाउन का भी एक क्रम होता है। जो कोड किसी चीज़ को क्लीनअप करता है, उसे तभी चलना चाहिए जब उस पर निर्भर सारा काम ख़त्म हो चुका हो। DB को उस जॉब के ख़त्म होने के बाद बंद होना चाहिए जो उसे इस्तेमाल कर रहा है, और इसके लिए जॉब को एक पहले वाले फ़ेज़ में ख़ाली करना होगा। फ़्रेमवर्क ने शटडाउन हुक्स को फ़ेज़ में बांटा है, यह सजावट नहीं बल्कि इसी क्रम को व्यक्त करने के लिए है।

डिफ़ेंसिव तरीक़े से लिखा कोड वक़्त ख़रीद देता है। अगर शटडाउन हुक्स दो बार कॉल होने पर भी सुरक्षित रहने के लिए न लिखे गए होते, तो यह बग लॉग स्मेल की जगह डेटा इंसिडेंट के रूप में पहले सामने आता। idempotent लिखे गए हुक्स ने दो बार एग्ज़िक्यूशन झेल लिया, इसलिए हम इसे इंसिडेंट की जगह लॉग में ढूंढ पाए। अगर हुक यह मानकर लिखा गया होता कि वह ज़रूर सिर्फ़ एक ही बार चलेगा, तो कहानी कहीं ज़्यादा ख़राब होती।

अक्सर पूछे जाने वाले सवाल

NestJS ऐप शटडाउन करते वक़्त लाइफ़साइकल हुक्स (onModuleDestroy वगैरह) दो बार चलते हैं। ऐसा क्यों होता है?

बड़ी संभावना है कि SIGTERM हैंडल करने वाला मालिक दो हैं। app.enableShutdownHooks() से Nest ख़ुद शटडाउन सिग्नल लिसनर रजिस्टर करता है, और अगर इसके साथ कोई graceful shutdown लाइब्रेरी (जैसे http-graceful-shutdown) भी इस्तेमाल हो रही है, तो वह भी SIGTERM पकड़कर onShutdown में app.close() कॉल करती है। Node एक सिग्नल पर रजिस्टर्ड सभी लिसनर्स को कॉल करता है, इसलिए शटडाउन सीक्वेंस दो बार चलता है। दोनों में से किसी एक को ही सिग्नल का मालिक रखने से यह ठीक हो जाता है।

graceful shutdown लाइब्रेरी इस्तेमाल करने पर क्या enableShutdownHooks() नहीं बुलाना चाहिए?

अगर लाइब्रेरी onShutdown कॉलबैक में app.close() कॉल करती है, तो app.close() पहले से ही पूरी लाइफ़साइकल हुक चेन चला देता है, इसलिए enableShutdownHooks() डुप्लिकेट बन जाता है। एक सिग्नल का मालिक एक ही होना चाहिए। लाइब्रेरी को सिग्नल का मालिक रखकर enableShutdownHooks() हटाने से हुक्स एक ही बार चलते हैं, और वह भी HTTP drain ख़त्म होने के बाद।

संबंधित लेख