বড় চাপে Queue আর Worker: একটি serial worker থেকে নিজে নিজে স্কেল হওয়া process pool
burst এলে ব্যাকগ্রাউন্ড জবই আগে ভাঙে। একটি সেটআপের পথ দেখুন: একক serial worker, যেখানে মাত্র ২০–২৬% জব সময়মতো শেষ হয়েছিল, থেকে আলাদা queue আর Horizon process pool, যা কয়েক সেকেন্ডে স্কেল করে।
Author
Anichur Rahaman
2 সপ্তাহ আগে12 min read2 views
একটি পরীক্ষা-platform-এর অন-কল ইঞ্জিনিয়ারের হাতে পরীক্ষার দিন সকাল ৮টা ৫৯-এ আর এক মিনিট বাকি। ঠিক ৯টায় একই মিনিটে ১,০০০ শিক্ষার্থী Submit চাপলেন। ওয়েব server প্রতিটি ক্লিকের জবাব দিচ্ছে মিলিসেকেন্ডে, dashboard সবুজ। ৯টা ৩ মিনিটে সাপোর্টের ইনবক্স ভরতে শুরু করল: শিক্ষার্থীরা screen-এ "Submitted" দেখেছেন, কিন্তু কোনো কনফার্মেশন আসেনি, আর গ্রেডিং টিম হাতে গোনা কয়েকটা উত্তর দেখছে। এটা একটা উদাহরণমূলক দৃশ্য, তবে আমি সত্যিই এমন চাপের জন্য একটা platform তৈরি করেছিলাম।
দোষ ওয়েব টিয়ারের ছিল না। একটিমাত্র ব্যাকগ্রাউন্ড worker সাবমিশনগুলো একটা একটা করে প্রসেস করছিল। queue একটা burst-কে লাইনে বদলে দেয়, তাই worker কেমন, সেটাই ঠিক করে লাইন শেষ হতে তিন সেকেন্ড লাগবে নাকি ষোলো মিনিট।
এই লেখায় দেখাব, এক serial worker থেকে নিজে নিজে বাড়ে-কমে এমন process pool পর্যন্ত আমার পথটা কেমন ছিল, আর কোন job-ডিজাইনের অভ্যাস সেই pool-কে নিরাপদে চালাতে দেয়।
ওয়েব request-এ একজন মানুষ অপেক্ষা করে, তাই ধীর হলে আমরা টের পাই। queue-র জবের জন্য সেই মুহূর্তে কেউ অপেক্ষা করছে না, তাই ব্যাকলগ বিশাল না হওয়া পর্যন্ত ধীরগতি চোখে পড়ে না। তার ওপর queue একটা burst-কে লাইনে পরিণত করে: এক মিনিটে ১,০০০ জব এলে আর প্রতিটির জন্য এক সেকেন্ড লাগলে একটি worker-এর শেষ করতে লাগে ১৬ মিনিটেরও বেশি।
আমার শুরুর অবস্থা ঠিক এটাই ছিল। একটিমাত্র worker, queue:work --queue=retakes,default ধরনের কমান্ডে চালানো, ১,০০০ burst জব একটার পর একটা করছে। আরও খারাপ, কম গুরুত্বের কাজ আর লাইভ সাবমিশন একই লাইনে ছিল; ফলে অগুরুত্বপূর্ণ জবের একটা batch এমন কাজের সামনে দাঁড়িয়ে যেতে পারত, যার জন্য গ্রাহক অপেক্ষা করছেন। সেই peak-এর রিপ্লে test-এ মাত্র ২০ থেকে ২৬ শতাংশ জব সময়মতো শেষ হয়েছে। এগুলো একটা নির্দিষ্ট সেটআপের সংখ্যা, সব জায়গার বেঞ্চমার্ক নয়; কিন্তু ব্যর্থতার ধরনটা খুব চেনা।
database ঠিকই ছিল। ম্যানেজড ইনস্ট্যান্স তার সীমার ধারেকাছেও যায়নি। বাধা ছিল একটা ডিজাইন-সিদ্ধান্ত: একটা process, একটা লাইন, কোনো অগ্রাধিকার নেই।
ধাপ ১: web থেকে worker আলাদা করুন
প্রথম পরিবর্তনটা ছিল ভৌত। web আর worker একই ৮ 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-এ জব হালকা হলে প্রায় ১২টা worker process ১,০০০ জব শেষ করেছে মোটামুটি ৩ সেকেন্ডে, আর ভারী জবে মোটামুটি ১০ সেকেন্ডে। প্রায় ২০টার পর আরও বাড়িয়ে লাভ হয়নি: তারা শুধু database রাইটে একে অপরের পেছনে লাইন ধরেছে।
Worker process
যা দেখেছি (একটি সেটআপ)
১
জব একটা একটা করে চলে; মাত্র ২০–২৬% সময়মতো
প্রায় ১২
১,০০০ হালকা জব ~৩ সেকেন্ডে, ভারী জব ~১০ সেকেন্ডে
~২০-এর বেশি
কোনো লাভ নেই; worker database রাইটের জন্য অপেক্ষা করে
হিসাবটা সহজ অঙ্কেই মেলে। জব-প্রতি এই সময়গুলো ফলাফল থেকে অনুমান করা, তাই উদাহরণমূলক ধরুন: হালকা জব প্রায় ০.০৩ সেকেন্ডের হলে ১,০০০ জবে কাজ ৩০ সেকেন্ডের, আর ১২টা worker সেটা ভাগ করে নিলে লাগে প্রায় ২.৫ সেকেন্ড। ভারী জব প্রায় ০.১২ সেকেন্ডের হলে কাজ ১২০ সেকেন্ডের, আর ১২টা worker তা প্রায় ১০ সেকেন্ডে শেষ করে। একটা worker-এর লাগত পুরো ১২০ সেকেন্ড, ততক্ষণে লাইভ সাবমিশনের সময়সীমা অনেক আগেই পেরিয়ে যেত।
শেষের ক্যাপাসিটি ওয়ার্কশিটের পেছনে এই শিক্ষাই কাজ করছে: সঠিক সংখ্যা "যত বেশি সম্ভব" নয়। সঠিক সংখ্যা সেই বিন্দু, যেখানে পরের worker আর লাইন ছোট করে না। সেটা খুঁজুন লোড test করে, আন্দাজে নয়; আর database সত্যিই সম্পৃক্ত হয়েছে এটা test-এ না দেখা পর্যন্ত database আপগ্রেড করবেন না। data টিয়ার নিয়ে লিখেছি তৃতীয় পর্বে।
মেশিন নয়, process স্কেল করুন
১২টার একটা স্থির pool শান্ত মঙ্গলবারে অপচয়, আবার বড় দিনে হয়তো ছোট। সমাধান autoscaling, তবে কোন স্তর স্কেল করছেন সেটাই আসল। নতুন ভার্চুয়াল মেশিন চালু হতে এক থেকে তিন মিনিট লাগে, আর নির্ধারিত চাপ চূড়ায় পৌঁছায় কয়েক সেকেন্ডে। প্রথম পর্বে বলেছি, জানা event-এ আগেভাগে স্কেল করে রাখতে হয়। 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 থাকে
১
maxProcesses
Horizon সর্বোচ্চ যতগুলো process পর্যন্ত স্কেল করতে পারে
১০
balanceMaxShift
একবারের সমন্বয়ে কয়টা process যোগ বা বাদ হতে পারে
৩
balanceCooldown
দুই সমন্বয়ের মাঝে কত সেকেন্ড অপেক্ষা
৩
Horizon-এর ডকুমেন্টেড ডিফল্ট আরও নরম: প্রতি সমন্বয়ে একটা process, প্রতি তিন সেকেন্ডে। আমি shift বাড়িয়ে তিন করেছি, কারণ burst-এ র্যাম্প-আপে আধ মিনিট লাগা উচিত নয়। ফল: লাইন বড় হলে Horizon কয়েক সেকেন্ডেই আরও worker process fork করেছে; খালি হলে সেগুলো গুটিয়ে নিয়েছে। নতুন মেশিন নেই, cluster নেই, বাড়তি খরচও নেই।
একটি উদাহরণমূলক burst: worker কয়েক সেকেন্ডে queue depth-এর সঙ্গে ওপরে ওঠে, লাইন খালি হলে নেমে আসে।
মেমোরি মাপুন সর্বোচ্চ সংখ্যা ধরে
process-স্তরের অটোস্কেলিংয়ে একটা ফাঁদ আছে। container-কে সর্বোচ্চ সংখ্যা ধারণ করতে হবে, গড় নয়। প্রতিটি জবের peak প্রায় ১২৮ MB আর আপনি ১০টা process অনুমতি দিলে মোট দাঁড়ায় প্রায় ১.৩ GB; তাই আমি container রেখেছি প্রায় ২ GB। শান্ত অবস্থার মাপে container বানালে প্রথম আসল burst-এই out-of-memory kill শুরু হয়, আর কার্নেল জবের মাঝপথে worker মেরে ফেলে।
মেমোরি ধরুন প্রতি জবের হিসেবে, প্রতি worker-এর নয়, কারণ একটা ভারী জব-ধরনই পুরো বাজেট খেয়ে ফেলতে পারে।
হার্ডওয়্যার কেনার আগে ভারী জবকে হালকা করুন
আমার সেটআপে একটা জব peak-এ ২৮৮ MB নিত। সে নিজেই HTTP-তে ছবি আনত আর লোকাল ডিস্ক থেকে ফাইল পড়ত, সবকিছু একসঙ্গে মেমোরিতে ধরে রেখে। ফাইলগুলো object storage থেকে stream করায় peak নেমে এল প্রায় ১২৮ MB-তে। শুধু এই বদলেই মেশিনের পুরো একটা সাইজ-ধাপ বেঁচে গেছে।
র্যাম বাড়ানো বা বড় node নেওয়ার আগে প্রতিটি ভারী জব নিয়ে তিনটা প্রশ্ন করুন:
stream করা যেত, অথচ সে কি পুরো ফাইল মেমোরিতে তুলছে?
যে data payload-এ পেতে পারত বা কাছের কোনো স্টোর থেকে পড়তে পারত, তা কি network-এ টেনে আনছে?
একটা বড় জবকে কি অনেকগুলো ছোট জবে ভাগ করা যায়?
ছোট জব Horizon-এর balancing-এর সঙ্গেও ভালো মেলে, কারণ সে ছোট ছোট ধাপে ক্ষমতা সরাতে পারে।
autoscaling-য়ের বিকল্পগুলো পাশাপাশি
worker স্কেল করার একমাত্র উপায় Horizon নয়। Laravel-ধাঁচের queue-র জন্য চারটি প্রচলিত বিকল্পের তুলনা:
বিকল্প
কী স্কেল হয়
সাড়া দেওয়ার সময়
খরচ ও ঝামেলা
স্থির replica (Compose বা Swarm)
কিছুই না; সংখ্যা আপনি ঠিক করেন
ম্যানুয়াল
সহজ, কিন্তু সবসময় peak-এর ক্ষমতার দাম দিতে হয়
HPA সহ Kubernetes
CPU বা কাস্টম metric অনুযায়ী pod
কয়েক মিনিট
cluster আর metric পাইপলাইন লাগে
KEDA সহ Kubernetes
queue-র দৈর্ঘ্য ধরে pod
কয়েক মিনিট
cluster লাগে; আসল সংকেত ধরে স্কেল করে
Horizon auto-balance
container-এর ভেতরের process
কয়েক সেকেন্ড
বিনা খরচে, cluster ছাড়া, তবে একটা মেশিনের সীমায় বাঁধা
KEDA ভালো প্রজেক্ট। এর ডকুমেন্টেশন অনুযায়ী, Redis list-এর দৈর্ঘ্যের মতো প্রতিটি trigger সে প্রতি polling interval-এ (ডিফল্ট ৩০ সেকেন্ড) যাচাই করে, আর এক replica থেকে অনেক replica পর্যন্ত স্কেল করার কাজটা করে Kubernetes-এর Horizontal Pod Autoscaler। pod বাড়ানো মানে container শিডিউল করা ও চালু করাও। কয়েক সেকেন্ডে চূড়ায় ওঠা নির্ধারিত চাপের জন্য সেটা এখনো ধীর, আর ছোট দলের জন্য একটা cluster সত্যিকারের খরচ।
আমার নিয়ম: দ্রুত সাড়ার জন্য Horizon auto-balance, আর জানা event-এর আগে মেশিন বা minimum আগেভাগে বাড়িয়ে রাখা। প্রতিদিনের কাজের জন্য এক node আর যথেষ্ট না হলে তখন KEDA ভাবুন।
জব এমনভাবে বানান যাতে retry নিরাপদ হয়
দ্রুত worker-এর একটা pool জব লেখার প্রতিটি দুর্বলতা বের করে আনে। এই অভ্যাসগুলো আমার system-কে নিরাপদ রেখেছে:
জবকে idempotent করুন। একটা জব দুবার চলতে পারে: timeout, deploy বা retry-র পর। দুবার চললেও যেন দুটো email না যায় বা দুবার চার্জ না হয়। জবের শুরুতে unique key বা বর্তমান অবস্থার যাচাই রাখুন।
commit-এর পরে dispatch করুন। database ট্রানজ্যাকশনের ভেতরে জব queue-তে দিলে data তৈরি হওয়ার আগেই worker সেটা তুলে নিতে পারে, অথবা ট্রানজ্যাকশন 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 ক্যাপাসিটি ওয়ার্কশিট
পরের চাপের আগে এই ছোট তালিকা মিলিয়ে নিন:
event-এর peak মিনিটে প্রতিটি queue-তে কয়টা জব আসে, মাপুন।
একটা আসল worker-এ প্রতিটি জব-ধরনের রান টাইম আর peak মেমোরি মাপুন।
জবের সংখ্যাকে রান টাইম দিয়ে ভাগ করে হিসাব করুন, লক্ষ্যের মধ্যে peak শেষ করতে কয়টা process লাগে।
সর্বোচ্চ সংখ্যা তার একটু ওপরে রাখুন, আর container-এর মেমোরি ঠিক করুন সর্বোচ্চ process গুণ জবের peak মেমোরি ধরে।
আসল মিশ্রণ দিয়ে লোড test করুন আর database রাইট টাইম দেখুন: সেটা সীমা হয়ে উঠলে worker বাড়ানো থামান।
নিশ্চিত করুন scheduler একবারই চলে, জব idempotent, আর deploy পরিষ্কারভাবে drain হয়; সঙ্গে ঠিক করুন queue depth আর wait time-এর কোন সংখ্যাগুলো নজরে রাখবেন (দেখুন চতুর্থ পর্ব)।
ফিরে যাই পরীক্ষার সকাল ৯টায়। একই ১,০০০ সাবমিশন, কিন্তু এবার 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 আর্কিটেকচার, ডেটার নির্ভুলতা আর নিজের সার্ভারে চালানো সিস্টেমের ওপর।