बड़े लोड में Queue और Worker: एक serial worker से अपने आप स्केल होने वाले process pool तक
burst में बैकग्राउंड जॉब सबसे पहले टूटते हैं। एक सेटअप का सफ़र देखिए: अकेला serial worker, जिसमें सिर्फ़ 20–26% जॉब समय पर पूरे हुए, से अलग queue और Horizon process pool तक, जो सेकंडों में स्केल होता है।
Author
Anichur Rahaman
2 सप्ताह पहले12 min read2 views
परीक्षा वाली सुबह 8:59 पर एक परीक्षा-प्लैटफ़ॉर्म के ऑन-कॉल इंजीनियर के पास बस एक मिनट बचा है। ठीक 9:00 बजे एक ही मिनट में 1,000 छात्र Submit दबाते हैं। वेब server हर क्लिक का जवाब मिलीसेकंड में दे रहे हैं और dashboard हरे हैं। 9:03 तक सपोर्ट इनबॉक्स भरने लगता है: छात्रों ने screen पर "Submitted" देखा, पर कोई कन्फ़र्मेशन नहीं आया, और ग्रेडिंग टीम को गिनती के कुछ जवाब ही दिख रहे हैं। यह एक उदाहरण-दृश्य है, पर मैंने सचमुच ऐसी ही भीड़ के लिए एक प्लैटफ़ॉर्म तैयार किया था।
वजह वेब टियर नहीं थी। एक अकेला बैकग्राउंड worker सबमिशन एक-एक करके प्रोसेस कर रहा था। queue भीड़ को कतार में बदल देती है, इसलिए आपके worker कैसे हैं, यही तय करता है कि कतार तीन सेकंड में निपटेगी या सोलह मिनट में।
यह लेख उस रास्ते का ब्योरा है जो मैंने एक serial worker से ऐसे process pool तक तय किया जो अपने आप बढ़ता-घटता है, साथ में जॉब डिज़ाइन की वे आदतें जो इस pool को सुरक्षित चलाने देती हैं।
वेब request में एक इंसान इंतज़ार करता है, इसलिए धीमा होने पर हमें पता चल जाता है। queue में पड़े जॉब का उस पल कोई इंतज़ार नहीं कर रहा होता, इसलिए बैकलॉग बहुत बड़ा होने तक धीमापन छिपा रहता है। ऊपर से queue भीड़ को एक कतार में बदल देती है: एक मिनट में 1,000 जॉब आएँ और हर एक में एक सेकंड लगे, तो अकेले worker को पूरा करने में 16 मिनट से ज़्यादा लगेंगे।
मेरी शुरुआत ठीक यही थी। एक अकेला worker, queue:work --queue=retakes,default जैसे कमांड से चलता हुआ, 1,000 burst जॉब एक-एक करके निपटा रहा था। बदतर बात यह कि कम अहमियत वाला काम और लाइव सबमिशन एक ही कतार में थे, इसलिए बेकार जॉब का एक batch ऐसे काम के आगे खड़ा हो सकता था जिसके लिए ग्राहक इंतज़ार कर रहा हो। उस peak के रीप्ले में सिर्फ़ 20 से 26 प्रतिशत जॉब समय पर पूरे हुए। ये एक ख़ास सेटअप के आँकड़े हैं, कोई सर्वव्यापी बेंचमार्क नहीं, लेकिन नाकामी का ढर्रा बहुत आम है।
database ठीक था। मैनेज्ड इंस्टेंस अपनी सीमा के आसपास भी नहीं था। रुकावट एक डिज़ाइन फ़ैसला थी: एक process, एक कतार, कोई प्राथमिकता नहीं।
पहला क़दम: worker को web से अलग करें
पहला बदलाव भौतिक था। web और worker एक ही 8 GB मशीन साझा करते थे, और worker के व्यस्त होते ही PHP-FPM के हिस्से का CPU और मेमोरी घट जाती थी। जिन जॉब को साइट ने ख़ुद queue में डाला था, उन्हीं की वजह से साइट धीमी हो जाती थी।
worker अपने अलग node पर चले गए। अब व्यस्त queue जितने चाहे core ले सकती है, किसी पेज लोड को छुए बिना; और वेब में दिक़्क़त आए तब भी queue का ख़ाली होना नहीं रुकता। इससे हर मशीन को उसके अपने काम के हिसाब से नापा जा सकता है: web node बहुत सारी छोटी request के लिए, worker node मेमोरी-भारी जॉब के लिए।
पहले: एक मशीन, एक कतार। बाद में: web और worker अलग, हर तरह के काम की अपनी queue।
दूसरा क़दम: प्राथमिकता और प्रकार के हिसाब से अलग queue
दूसरा बदलाव था सब कुछ एक कतार में मिलाना बंद करना। ग्राहक के लिए काम का मतलब क्या है, इसके आधार पर मैंने अलग queue बनाईं:
Submissions: लाइव, ग्राहक से जुड़ा काम, जो कुछ सेकंड में पूरा होना चाहिए।
Notifications: email, SMS और push मैसेज। ज़रूरी हैं, पर एक मिनट की देरी चल जाती है।
Image processing: भारी, मेमोरी खाने वाला, कभी जल्दबाज़ी का नहीं।
Reports: धीमे एक्सपोर्ट और सारांश, जो भीड़ ख़त्म होने तक रुक सकते हैं।
अलग queue का मतलब है हर एक के अपने worker, अपना timeout और अपने retry नियम। चार मिनट चलने वाला report जॉब अब payment कन्फ़र्मेशन को रोक नहीं सकता। मोटा नियम: अगर दो तरह के कामों की तात्कालिकता, मेमोरी की ज़रूरत या फ़ेल होने का तरीक़ा अलग है, तो वे अलग queue में रहें।
एक worker में queue को प्राथमिकता के क्रम में रखना (पहली हमेशा जीतती है) मिली-जुली कतार से बेहतर है, पर वह भी एक ही process है। हर queue के लिए अलग process ही आगे निकल जाने की समस्या को जड़ से मिटाते हैं।
तीसरा क़दम: कितने worker चाहिए?
worker बढ़ाइए तो कतार जल्दी ख़ाली होती है, पर एक हद तक। मेरे test में जॉब हल्के हों तो लगभग 12 worker process ने 1,000 जॉब करीब 3 सेकंड में निपटा दिए, और भारी जॉब में करीब 10 सेकंड में। लगभग 20 के बाद और बढ़ाने से कुछ नहीं मिला: वे बस database राइट पर एक-दूसरे के पीछे लाइन लगाते रहे।
Worker process
मैंने क्या देखा (एक सेटअप)
1
जॉब एक-एक करके चलते हैं; सिर्फ़ 20–26% समय पर
लगभग 12
1,000 हल्के जॉब ~3 सेकंड में, भारी ~10 सेकंड में
~20 से ऊपर
कोई फ़ायदा नहीं; worker database राइट का इंतज़ार करते हैं
हिसाब सीधे अंकगणित से बैठ जाता है। जॉब-प्रति ये समय नतीजों से निकाले गए अनुमान हैं, इसलिए इन्हें उदाहरण मानिए: लगभग 0.03 सेकंड का हल्का जॉब हो तो 1,000 जॉब का काम 30 सेकंड का है, और 12 worker उसे बाँटकर करीब 2.5 सेकंड में निपटा देते हैं। लगभग 0.12 सेकंड का भारी जॉब हो तो काम 120 सेकंड का है, और 12 worker उसे करीब 10 सेकंड में पूरा करते हैं। एक worker को पूरे 120 सेकंड लगते, और तब तक लाइव सबमिशन की समय-सीमा कब की निकल चुकी होती।
अंत की कैपेसिटी वर्कशीट के पीछे यही सबक़ है: सही संख्या "जितनी हो सके उतनी" नहीं है। सही संख्या वह बिंदु है जहाँ अगला worker कतार को छोटा करना बंद कर देता है। उसे लोड test से खोजिए, अंदाज़े से नहीं, और database सचमुच भर चुका है यह test में दिखे बिना उसे अपग्रेड मत कीजिए। data टियर की बात मैंने तीसरे भाग में की है।
मशीन नहीं, process स्केल कीजिए
12 का एक तय pool शांत मंगलवार को फ़िज़ूलख़र्ची है और बड़े दिन शायद छोटा। जवाब autoscaling है, पर यह मायने रखता है कि आप किस परत को स्केल कर रहे हैं। नई वर्चुअल मशीन चालू होने में एक से तीन मिनट लगते हैं, और तय भीड़ कुछ ही सेकंड में अपने शिखर पर पहुँच जाती है। पहले भाग में मैंने बताया था कि जानी-पहचानी घटना के लिए पहले से स्केल कर लेना चाहिए। queue के लिए एक बेहतर रास्ता है: जो मशीन पहले से चल रही है, उसके अंदर के process स्केल कीजिए।
Laravel Horizon यह अपनी balance सेटिंग से करता है। Laravel के आधिकारिक डॉक्यूमेंटेशन के मुताबिक़ Horizon में तीन रणनीतियाँ हैं: simple आने वाले जॉब को सभी worker process में बराबर बाँटती है, auto हर queue के मौजूदा काम के बोझ के हिसाब से उस queue के process की संख्या बदलती है, और false balancing बंद रखती है। auto के साथ कुछ विकल्प तय करते हैं कि यह कितनी तेज़ी से प्रतिक्रिया देगा।
सेटिंग
क्या नियंत्रित करती है
submit queue के लिए मेरा मान
balance
रणनीति: auto, simple या false
auto
minProcesses
ख़ाली रहने पर भी हर queue में जितने process रहते हैं
1
maxProcesses
Horizon अधिकतम जितने process तक स्केल कर सकता है
10
balanceMaxShift
एक समायोजन में कितने process जुड़ या घट सकते हैं
3
balanceCooldown
दो समायोजनों के बीच कितने सेकंड रुकना है
3
Horizon के दस्तावेज़ी डिफ़ॉल्ट ज़्यादा नरम हैं: हर समायोजन में एक process, हर तीन सेकंड पर। मैंने shift बढ़ाकर तीन किया, क्योंकि burst में रैंप-अप को आधा मिनट नहीं लगना चाहिए। नतीजा: कतार बढ़ने पर Horizon ने कुछ ही सेकंड में और worker process fork कर दिए; ख़ाली होने पर उन्हें समेट लिया। न नई मशीन, न क्लस्टर, न कोई अतिरिक्त ख़र्च।
एक उदाहरण burst: worker कुछ सेकंड में queue depth के साथ ऊपर चढ़ते हैं और कतार ख़ाली होने पर नीचे उतर आते हैं।
मेमोरी अधिकतम के हिसाब से तय कीजिए
process-स्तर की autoscaling में एक जाल है। container को अधिकतम संख्या सँभालनी होगी, औसत नहीं। अगर हर जॉब का peak करीब 128 MB है और आप 10 process की इजाज़त देते हैं, तो कुल लगभग 1.3 GB बनता है; इसलिए मैंने container करीब 2 GB का रखा। शांत हालत के हिसाब से container बनाया तो पहली असली भीड़ में ही out-of-memory kill शुरू हो जाते हैं, और कर्नेल जॉब के बीच में worker को मार देता है।
मेमोरी प्रति जॉब के हिसाब से जाँचना ठीक रहता है, प्रति worker के नहीं, क्योंकि एक भारी जॉब-प्रकार पूरा बजट खा सकता है।
हार्डवेयर ख़रीदने से पहले भारी जॉब को हल्का कीजिए
मेरे सेटअप में एक जॉब का peak 288 MB था। वह ख़ुद HTTP से तस्वीरें खींचता था और लोकल डिस्क से फ़ाइलें पढ़ता था, सब कुछ एक साथ मेमोरी में रखकर। फ़ाइलों को object storage से stream करने पर peak घटकर करीब 128 MB रह गया। अकेले इसी बदलाव से मशीन का एक पूरा साइज़-स्तर बच गया।
RAM बढ़ाने या बड़ा node लेने से पहले हर भारी जॉब के बारे में तीन सवाल पूछिए:
क्या वह पूरी फ़ाइल मेमोरी में लोड कर रहा है, जबकि stream कर सकता था?
क्या वह ऐसा data network से खींच रहा है जो payload में मिल सकता था या पास के किसी स्टोर से पढ़ा जा सकता था?
क्या उसे एक बड़े जॉब की जगह कई छोटे जॉब में बाँटा जा सकता है?
छोटे जॉब Horizon की balancing से भी बेहतर बैठते हैं, क्योंकि वह क्षमता को छोटे-छोटे क़दमों में खिसका सकता है।
autoscaling के विकल्पों की तुलना
worker स्केल करने का Horizon अकेला तरीक़ा नहीं है। Laravel जैसी queue के लिए चार आम विकल्प इस तरह तुलना में आते हैं।
विकल्प
क्या स्केल होता है
प्रतिक्रिया का समय
ख़र्च और मेहनत
स्थिर replica (Compose या Swarm)
कुछ नहीं; संख्या आप तय करते हैं
मैन्युअल
आसान, पर peak की क्षमता का दाम हमेशा देना पड़ता है
HPA के साथ Kubernetes
CPU या कस्टम metric के हिसाब से pod
कुछ मिनट
क्लस्टर और metric पाइपलाइन चाहिए
KEDA के साथ Kubernetes
queue की लंबाई के हिसाब से pod
कुछ मिनट
क्लस्टर चाहिए; असली संकेत पर स्केल करता है
Horizon auto-balance
container के अंदर के process
कुछ सेकंड
मुफ़्त, बिना क्लस्टर, पर एक मशीन की सीमा में बँधा
KEDA अच्छा प्रोजेक्ट है। इसके डॉक्यूमेंटेशन के मुताबिक़ यह Redis list की लंबाई जैसे हर trigger को हर polling interval (डिफ़ॉल्ट 30 सेकंड) पर जाँचता है, और एक replica से कई replica तक स्केल करने का काम Kubernetes का Horizontal Pod Autoscaler सँभालता है। pod बढ़ाने का मतलब container शेड्यूल करना और चालू करना भी है। कुछ सेकंड में शिखर छूने वाली तय भीड़ के लिए यह अब भी धीमा है, और छोटी टीम के लिए क्लस्टर एक असली ख़र्च है।
मेरा नियम: तेज़ प्रतिक्रिया के लिए Horizon auto-balance, और जिस घटना का पता है उससे पहले मशीन या minimum पहले से बढ़ा देना। जब रोज़ का काम एक node में न समाए, तब KEDA पर विचार कीजिए।
जॉब ऐसे बनाइए कि retry सुरक्षित रहे
तेज़ worker का pool जॉब लिखने की हर कमज़ोरी को उजागर कर देता है। इन आदतों ने मेरे system को सुरक्षित रखा:
जॉब को idempotent बनाइए। जॉब दो बार चल सकता है: timeout, deploy या retry के बाद। दो बार चलने पर भी दो email न जाएँ और दो बार चार्ज न हो। जॉब की शुरुआत में unique key या मौजूदा स्थिति की जाँच रखिए।
commit के बाद dispatch कीजिए। database ट्रांज़ैक्शन के अंदर जॉब queue में डालने पर worker data बनने से पहले उसे उठा सकता है, या ट्रांज़ैक्शन rollback हो जाए और बेकार जॉब पड़ा रह जाए। commit सफल होने के बाद ही dispatch कीजिए।
निजी जानकारी वाले payload को एन्क्रिप्ट कीजिए। एन्क्रिप्ट न करें तो queue के payload Redis में पढ़ने लायक़ रूप में पड़े रहते हैं। जहाँ हो सके सिर्फ़ आईडी भेजिए, बाक़ी एन्क्रिप्ट कीजिए।
timeout और retry सोच-समझकर तय कीजिए। जॉब का timeout queue की retry विंडो से छोटा होना चाहिए, कोशिशों के बीच backoff हो, और failed-jobs टेबल ऐसी हो जिसे आप सचमुच देखते हों।
एक जॉब, हर अंजाम: डुप्लिकेट मानकर छोड़ा गया, पूरा हुआ, देरी के बाद retry हुआ, या failed टेबल में रखा गया जहाँ कोई इंसान उसे देख सके।
scheduler एक singleton है
cron जैसे तय काम ठीक एक node पर चलने चाहिए। अगर आप web या worker node बढ़ाएँ और हर एक पर scheduler चले, तो हर तय report कई बार बनेगी, हर रिमाइंडर दो बार जाएगा और हर क्लीनअप अपने से ही टकराएगा।
एक node चुनिए, उसे लिख रखिए और autoscaling ग्रुप से बाहर रखिए। बैकग्राउंड कमांड को भी वही ध्यान चाहिए जो वेब request को मिलता है। एक self-hosted सेटअप में scheduler हर छोटे कमांड के लिए पूरा फ़्रेमवर्क बूट करता था; CLI OPcache नहीं था और मेमोरी की सीमाएँ कड़ी थीं। नतीजा सैकड़ों out-of-memory kill। relay को एक लंबे चलने वाले process के अंदर चलाकर और CLI OPcache चालू करके यह ठीक हुआ।
हर deploy पर queue को drain कीजिए
worker लंबे समय तक चलने वाला process है, इसलिए रीस्टार्ट किए बिना वह पुराना कोड मेमोरी में रखता है, और अचानक रीस्टार्ट करने से चलता हुआ जॉब मर जाता है। सुरक्षित क्रम यह है: नए जॉब लेना बंद कीजिए, चल रहे जॉब को पूरा होने दीजिए, फिर नए worker चालू कीजिए।
Horizon का terminate कमांड (horizon:terminate) चलाइए, ताकि worker मौजूदा जॉब पूरा करके बाहर निकलें।
container को उदार stop grace period दीजिए, आपके सबसे लंबे जॉब से ज़्यादा, ताकि orchestrator उसे समय से पहले न मार दे।
नए release पर नए worker चालू कीजिए।
scheduler सबसे आख़िर में चालू कीजिए, ताकि आधे-deploy system पर कोई तय काम न चल पड़े।
blue/green और rollback समेत पूरा deploy क्रम मैंने पाँचवें भाग में समझाया है।
worker कैपेसिटी वर्कशीट
अगली भीड़ से पहले यह छोटी सूची जाँच लीजिए:
घटना के peak मिनट में हर queue में कितने जॉब बनते हैं, नापिए।
एक असली worker पर हर जॉब-प्रकार का रन टाइम और peak मेमोरी नापिए।
जॉब की संख्या को रन टाइम से भाग देकर अंदाज़ा लगाइए कि लक्ष्य के भीतर peak निपटाने को कितने process चाहिए।
अधिकतम संख्या उससे थोड़ी ऊपर रखिए, और container की मेमोरी अधिकतम process गुणा जॉब की peak मेमोरी के हिसाब से तय कीजिए।
असली मिश्रण के साथ लोड test कीजिए और database राइट टाइम पर नज़र रखिए: जब वह सीमा बन जाए, worker बढ़ाना रोक दीजिए।
पक्का कीजिए कि scheduler एक ही बार चलता है, जॉब idempotent हैं और deploy साफ़ drain होता है, और तय कीजिए कि queue depth और wait time के कौन-से आँकड़े आप देखेंगे (देखिए चौथा भाग)।
अब लौटते हैं परीक्षा की सुबह 9:00 बजे पर। वही 1,000 सबमिशन, पर इस बार submissions queue के अपने process हैं और उसके आगे कोई दूसरा काम खड़ा नहीं है। Horizon करीब दस सेकंड में एक process से दस तक पहुँच जाता है, burst उतनी देर में निपट जाता है जितनी देर में कोई छात्र screen की ओर वापस देखे, और पहला सपोर्ट email लिखे जाने से पहले कन्फ़र्मेशन निकल चुके होते हैं। reports और image जॉब इंतज़ार करते हैं, जैसा उन्हें करना चाहिए।
अगला भाग, भार में data टियर, देखेगा कि जब worker इतने तेज़ हो जाएँ कि database पर दबाव डालने लगें तो क्या होता है: connection पूलिंग, Redis की भूमिकाएँ और AI embedding के लिए अलग स्टोर।
मुख्य बातें
burst में बैकग्राउंड जॉब आमतौर पर सबसे पहले टूटते हैं, और बैकलॉग बड़ा होने तक धीमापन छिपा रहता है।
worker को web node से हटाइए और हर प्राथमिकता या काम के प्रकार को अपनी queue दीजिए।
सेकंडों में प्रतिक्रिया के लिए Horizon auto-balance से मशीन के अंदर worker process स्केल कीजिए, और मेमोरी अधिकतम के हिसाब से तय कीजिए।
हार्डवेयर ख़रीदने से पहले भारी जॉब हल्के कीजिए, और जब database राइट सीमा बन जाए तो worker बढ़ाना रोक दीजिए।
जॉब को idempotent रखिए, commit के बाद dispatch कीजिए, payload में निजी जानकारी एन्क्रिप्ट कीजिए और scheduler सिर्फ़ एक node पर चलाइए।
हर deploy पर उदार grace period के साथ queue को drain कीजिए।
अनिचुर रहमान सॉफ़्टवेयर आर्किटेक्ट और StoreConsole के संस्थापक हैं। वे बढ़ते कारोबारों के लिए कॉमर्स और ERP सिस्टम डिज़ाइन करते हैं — ख़ास ध्यान event-driven आर्किटेक्चर, डेटा की शुद्धता और अपने सर्वर पर चलने वाले सिस्टम पर।