Load Balancing — L4 বনাম L7 আর অ্যালগরিদমের গণিত
Load Balancing: L4 vs L7 and the Algorithms Behind It
service-discovery lesson backend-দের কোথায় আছে খুঁজে বের করেছিল; আজ প্রশ্নটা তার পরের ধাপ — সেই backend-দের মধ্যে একটা নির্দিষ্ট request ঠিক কোনটার কাছে যাবে। L4 বনাম L7-এর precise পার্থক্য, চারটা classic অ্যালগরিদম, consistent hashing-এর ring-গণিত, আর health check যা এই পুরো সিদ্ধান্তকে production-এ নিরাপদ রাখে — সবকিছুর পেছনে নিজে-চালানো সংখ্যা।
আগে এটা বুঝি
virtualization-cloud/service-discovery-dns-k8s লেসন একটা নির্দিষ্ট প্রশ্নের উত্তর দিয়েছে: একটা service-এর backend-গুলো ঠিক কোথায় আছে — কোন IP:port-এ। DNS lookup হোক বা Kubernetes-এর Endpoints/EndpointSlice API, উত্তরটা সবসময় একটা তালিকা। কিন্তু একটা তালিকা পাওয়া মানেই কাজ শেষ না — একটা নির্দিষ্ট request আসলে সেই তালিকার ঠিক কোনটা পাবে সেটা, সেই সিদ্ধান্তটাই আজকের বিষয়: load balancing।
এই প্রশ্নটা কোনো Kubernetes-নির্দিষ্ট প্রশ্ন না। networking/cdn-load-balancer-proxy লেসন এই ধারণাগুলোর একটা survey ইতিমধ্যে দিয়েছে — CDN edge-এর প্রেক্ষাপটে, round-robin/least-connections/consistent-hashing-এর সংক্ষিপ্ত পরিচয়, L4/L7-এর প্রাথমিক পার্থক্য। আজকের লেসন সেই একই শব্দভাণ্ডার নিয়ে গভীরে যায় — একেবারে নির্ভুল মেকানিজম পর্যন্ত, প্রতিটা দাবির পেছনে সত্যিকারের সংখ্যা নিয়ে। আর এই ধারণাগুলো CDN-এর চেয়েও অনেক বেশি সাধারণ: cloud provider-এর managed load balancer (AWS NLB/ALB), hardware LB (F5), software LB (nginx, HAProxy, IPVS), Kubernetes Service — সবার ভেতরেই ঠিক এই একই তিনটা প্রশ্নের উত্তর লুকিয়ে আছে: কোন layer-এ decision নেওয়া হচ্ছে (L4 না L7), কোন অ্যালগরিদমে backend বাছা হচ্ছে, আর একটা backend “বাছার যোগ্য” কি না তা কীভাবে নির্ধারিত হচ্ছে।
মূল ধারণা
L4 বনাম L7 — কে ঠিক কোন তথ্য দেখে
পার্থক্যটা networking/tcp-handshake লেসনের ভাষায় সবচেয়ে নির্ভুলভাবে বলা যায়, কারণ আসল প্রশ্নটাই TCP connection নিয়ে: connection-টা ঠিক কোথায় terminate হয়?
L4 (transport layer) — connection টার্মিনেট হয়ই না
একটা L4 load balancer শুধু TCP/UDP header দেখে — source IP, source port, destination IP, destination port। এটা payload-এর একটা byte-ও পড়ে না, তাই এটা protocol-agnostic — HTTP, gRPC, raw TCP, MQTT, যেকোনো কিছু balance করতে পারে, কারণ ভেতরের data নিয়ে তার কোনো মাথাব্যথা নেই।
গুরুত্বপূর্ণ অংশ: L4 balancer সাধারণত client-এর সাথে নিজের কোনো TCP connection খোলে না। একটা নতুন connection-এর প্রথম প্যাকেট (SYN) এলে, সেই ৫-টাপল (protocol, src IP, src port, dst IP, dst port)-এর ওপর ভিত্তি করে একটা backend একবার বেছে নেয় — তারপর সেই connection-এর প্রতিটা প্যাকেট শুধু forward করে, দুইটা সাধারণ উপায়ে:
- NAT-rewrite — destination IP:port backend-এর ঠিকানায় বদলে দেয়, backend-এর reply আসলে আবার client-এর ঠিকানায় ফিরিয়ে দেয়। Client-এর TCP stack-এর দৃষ্টিতে এটা এখনও একটাই, একটানা TCP connection — একটাই three-way handshake, client-এর SYN সরাসরি backend পর্যন্ত (rewrite হয়ে) পৌঁছায়, backend-ই SYN-ACK পাঠায় (rewrite হয়ে ফিরে আসে)। LB নিজে কখনও handshake-এ অংশ নেয় না, শুধু packet rewrite করে।
- Direct Server Return (DSR) — কিছু design-এ LB শুধু client→backend দিকের প্যাকেট দেখে; backend-এর reply সরাসরি client-কে পাঠানো হয়, LB সম্পূর্ণ বাইপাস করে return path-এ। এটা LB-র bandwidth-চাপ অনেক কমায় (reply, যা প্রায়ই request-এর চেয়ে বড়, LB দিয়ে যায় না)।
দুই ক্ষেত্রেই মূল কথা এক: client ও backend-এর মধ্যে ঠিক একটাই TCP connection, একটাই three-way handshake, একটাই sequence-number space। LB একটা routing সিদ্ধান্ত নেয় একবার, connection শুরুতে — তারপর সরে দাঁড়ায়।
L7 (application layer) — connection সত্যিই টার্মিনেট হয়
একটা L7 load balancer client-এর সাথে একটা সম্পূর্ণ, স্বাধীন TCP connection টার্মিনেট করে — নিজের ISN দিয়ে, নিজের three-way handshake সম্পূর্ণ করে (networking/tcp-handshake লেসনের SYN/SYN-ACK/ACK ঠিক এখানেই)। এরপর সেই connection-এর ওপর দিয়ে আসা byte stream-কে সে আসলেই পড়ে — HTTP request line, headers, cookies পার্স করে। TLS ব্যবহার হলে decrypt-ও এখানেই ঘটে (TLS termination)।
এই parsed তথ্যের ভিত্তিতে LB একটা backend বেছে নেয় — কিন্তু এরপর client-এর connection-টা backend-কে “পাস” করে দেয় না, কারণ সেটা করার কোনো উপায় নেই (connection ইতিমধ্যে টার্মিনেট হয়ে গেছে)। বরং LB backend-এর সাথে একটা সম্পূর্ণ নতুন, দ্বিতীয় TCP connection খোলে — নিজস্ব নতুন three-way handshake, নতুন ISN, প্রায়ই connection-pooling দিয়ে পুনর্ব্যবহৃত। LB সেই দ্বিতীয় connection-এ একটা নতুন-করে-বানানো HTTP request পাঠায় (headers কিছু যোগ/বদলে — যেমন X-Forwarded-For)।
মূল trade-off-টা একবাক্যে: L4 দ্রুত কিন্তু অন্ধ (content-blind); L7 দৃষ্টিসম্পন্ন কিন্তু ব্যয়বহুল। কোনটা “ভালো” না — কোনটা কাজের জন্য দরকার, সেটাই প্রশ্ন। বেশিরভাগ বড় production stack আসলে দুটোই ব্যবহার করে, স্তরে স্তরে — L4 সামনে (flood-resistant, million-connection scale), তার পেছনে L7 (smart, content-aware routing)। এই লেসনের realworld অংশে এই স্তরায়ন বিস্তারিত দেখা যাবে।
ভেতরে কী ঘটছে
চারটা অ্যালগরিদম — ঠিক কীভাবে একটা backend বাছা হয়
L4 হোক বা L7, একবার backend-এর pool ঠিক হয়ে গেলে, একটা নির্দিষ্ট request/connection ঠিক কোনটা পাবে — সেই সিদ্ধান্তটা কয়েকটা সুপরিচিত অ্যালগরিদমের একটা।
Round-robin — সবচেয়ে সহজ, কিন্তু load-অন্ধ
Backend-দের একটা তালিকা সাজিয়ে, পালা করে একটার পর একটা বেছে নেওয়া — request ১ → backend A, request ২ → backend B, request ৩ → backend C, request ৪ → আবার A। এখানে সিদ্ধান্তটা সম্পূর্ণ stateless (শুধু একটা counter লাগে) আর backend-এর বর্তমান অবস্থা সম্পর্কে কিছুই জানার দরকার নেই।
এই সরলতাই এর দুর্বলতা: round-robin ধরে নেয় প্রতিটা request-এর processing-cost প্রায় সমান। যদি তা না হয় — কিছু request হালকা, কিছু ভারী — round-robin অন্ধভাবে সমান সংখ্যায় ভাগ করে, সমান কাজে না। একটা backend যদি টানা কয়েকটা ভারী request পেয়ে যায় (নেহাত পালার কারণে), সে পিছিয়ে পড়ে আর তার queue জমতে থাকে — বাকিরা তখনও idle-এর কাছাকাছি।
Least-connections — বর্তমান লোড দেখে সিদ্ধান্ত
এখানে LB প্রতিটা backend-এর সক্রিয় (active) connection সংখ্যা ট্র্যাক করে, আর নতুন request-টা যার active-connection সবচেয়ে কম তাকে দেয়। যুক্তিটা সহজ: যদি একটা backend-এ এখন বেশি connection খোলা থাকে, তার মানে সে সম্ভবত ব্যস্ত — নতুন কাজ তার ওপর না চাপিয়ে যে relatively ফাঁকা তাকে দাও।
এই অ্যালগরিদম request-processing-time-এর ভিন্নতাকে self-correct করে, কারণ এটা request-count না, বর্তমান occupancy-র ওপর ভিত্তি করে সিদ্ধান্ত নেয়। একটা backend দীর্ঘ, ভারী request নিয়ে ব্যস্ত থাকলে তার active-connection সংখ্যা বেশি দেখাবে, ফলে পরের request স্বাভাবিকভাবেই অন্য কারো কাছে যাবে — কোনো explicit “cost prediction” ছাড়াই।
Weighted variant — heterogeneous capacity সামলানো
যদি backend-গুলো একরকম না হয় — একটা instance বড়, আরেকটা ছোট — round-robin আর least-connections দুটোরই একটা weighted সংস্করণ আছে। প্রতিটা backend-কে একটা static weight দেওয়া হয় (capacity-অনুপাতে); weighted round-robin সেই weight-অনুপাতে পালা বণ্টন করে (একটা weight-4 backend চারবার পাবে যেখানে একটা weight-1 backend একবার পাবে, কিন্তু bursty না হয়ে ছড়িয়ে — এই লেসনের build অংশে ঠিক এই “smooth” mechanism বাস্তবায়িত হবে), আর weighted least-connections active-connection-কে weight দিয়ে ভাগ করে তুলনা করে (একটা বড় backend “সমান busy” ধরা হবে তখনই যখন তার connection-count তার weight-অনুপাতে বেশি)।
Consistent hashing — একই key সবসময় একই backend-এ
আগের তিনটা অ্যালগরিদম প্রতিটা request-কে স্বাধীনভাবে দেখে — কোন backend পাবে তা request-এর নিজস্ব কোনো বৈশিষ্ট্যের ওপর নির্ভর করে না, শুধু pool-এর বর্তমান অবস্থার ওপর। কিন্তু কখনো কখনো এর উল্টোটা দরকার: একই client বা একই cache-key যেন বারবার একই backend-এ যায় — session affinity (একটা user-এর session state একটা নির্দিষ্ট backend-এ in-memory রাখা আছে) বা cache locality-র জন্য (একটা backend যদি একটা key-র জন্য বারবার cache-miss করে অন্য জায়গায় পাঠানো হয়, cache-hit-rate ভেঙে পড়ে)।
Naive সমাধান মনে হতে পারে: hash(key) % N — key hash করে backend-সংখ্যা দিয়ে mod নাও। সমস্যা: N বদলালেই (একটা backend যোগ বা বাদ) প্রায় প্রতিটা key-র জন্য mod-এর ফলাফল বদলে যায় — কারণ mod অপারেশন পুরো space-টাকে নতুন করে ভাগ করে, N-এর ওপর নির্ভর করে। একটা backend স্কেল-আপ/ডাউন হলেই প্রায় সব session/cache-key ভুল জায়গায় চলে যায় — massive cache-stampede।
Consistent hashing এই সমস্যাটা একটা ভিন্ন জ্যামিতি দিয়ে সমাধান করে (Karger et al., STOC 1997 — এই লেসনের references-এ মূল paper): key আর backend দুটোকেই একটা circular hash space (একটা ring)-এ বসানো হয়:
- প্রতিটা backend-কে একটা hash function দিয়ে ring-এর একটা point-এ বসানো হয় (production-এ একটা backend আসলে অনেকগুলো point পায় — vnode, নিচে ব্যাখ্যা)।
- প্রতিটা key-ও একই hash function দিয়ে ring-এ একটা point পায়।
- একটা key-র owner backend হলো — ring-এ সেই key-র point থেকে clockwise-এ প্রথম যে backend-point পাওয়া যায়, সেটা।
ring: b1 ●───● key ───● b3 ───● b2 ───(ঘুরে আবার b1)
key-র owner = clockwise-এর পরের প্রথম backend-point = b3এখন প্রশ্ন — একটা backend যোগ বা বাদ দিলে কেন শুধু কিছু key সরে, প্রায় সব না? কারণটা জ্যামিতিতেই লুকানো: একটা backend-point সরালে বা যোগ করলে, ring-এর শুধু সেই একটা point আর তার আগের backend-point-এর মধ্যকার arc-টার owner বদলায় — বাকি পুরো ring-এর প্রতিটা point-এর “clockwise-এর পরের backend” আগের মতোই থেকে যায়, কারণ তাদের ring-geometry-তে কিছুই বদলায়নি। একটা N-backend ring-এ একটা backend যোগ/বাদ দিলে গড়ে শুধু 1/N অংশ key remap হয় — mod-hash-এর প্রায়-১০০%-এর তুলনায় নাটকীয়ভাবে কম। (distributed-systems/sharding-at-scale লেসন এই একই ring-গণিত database sharding-এর প্রেক্ষাপটে ব্যবহার করেছে — মেকানিজমটা হুবহু এক, শুধু “key” এখানে database row না, request/session।)
Consistent hashing-এর নিজস্ব দুর্বলতাও আছে — যদি একটা single key-র traffic বাকি সব key-র মিলিত traffic-এর চেয়ে বড় হয় (একটা “celebrity”/hot key), সেই একটা key-ই একটা backend-এ আটকে থাকে, কোনো rebalancing তাকে ভাগ করতে পারে না। এই সমস্যার সমাধান (key salting) distributed-systems/sharding-at-scale লেসনের বিষয় — আজকের লেসনের scope-এর বাইরে।
Health checking — অ্যালগরিদম চলার আগের গেট
উপরের চারটা অ্যালগরিদমের একটাও নিরাপদ না, যদি pool-এ মৃত backend থাকে — round-robin অন্ধভাবে তার পালাতেও request পাঠাবে, least-connections তাকেও “কম busy” ভাবতে পারে (কারণ সে কোনো request-ই সম্পন্ন করছে না, connection-count কৃত্রিমভাবে কম থাকতে পারে)। তাই প্রতিটা production LB-তে অ্যালগরিদম চলারও আগে একটা গেট থাকে: health checking — যেটা ঠিক করে দেয় pool-এ আদৌ কোন backend-গুলো এখন eligible।
দুই ধরনের health check:
- Active health check — LB নিজে থেকে, নিয়মিত বিরতিতে (যেমন প্রতি ২ সেকেন্ডে), প্রতিটা backend-এর একটা নির্দিষ্ট health endpoint-এ (
GET /healthz) probe পাঠায়। পরপর কয়েকটা probe fail করলে backend-কে pool থেকে সরিয়ে ফেলা হয় (evict), পরপর কয়েকটা success এলে আবার ফেরানো হয়। - Passive health check — কোনো আলাদা probe নেই; আসল client traffic-ই test হিসেবে কাজ করে। একটা backend যদি real request-এ বারবার timeout বা 5xx দেয়, LB নিজেই সেটাকে সাময়িকভাবে বাদ দেয় — বাড়তি traffic খরচ ছাড়াই, কিন্তু ব্যয়টা হলো: কিছু real request একটা মৃতপ্রায় backend-এ গিয়েই fail করে, তারপরই detection ঘটে।
উদাহরণ
একটা request-এর যাত্রা — L4 বনাম L7, packet-স্তরে
- client SYN পাঠায়destination: LB-এর virtual IP; LB শুধু ৫-টাপল দেখে একটা backend বেছে নেয় (round-robin/least-conn/hash)
- LB destination rewrite করে, SYN forward করে backend-কেNAT-rewrite — client-এর SYN-এর কোনো byte বদলায়নি, শুধু destination IP:port
- backend সরাসরি SYN-ACK পাঠায় (বা LB-র মাধ্যমে rewrite হয়ে ফেরে)client-এর দৃষ্টিতে এটা এখনও একটা সাধারণ three-way handshake — backend-এর ISN-ই দেখছে
- client ACK পাঠিয়ে handshake সম্পূর্ণ করেconnection ESTABLISHED — client ও backend-এর মধ্যে সরাসরি, একটাই TCP stream
- বাকি সব প্যাকেট শুধু forward হয়LB প্রতিটা প্যাকেটে শুধু IP rewrite করে (বা DSR-এ শুধু এক দিকই ছোঁয়) — HTTP/payload কখনো পার্স হয় না
- client TCP handshake সম্পূর্ণ করে LB-র সাথেLB নিজেই এখানে TCP endpoint — নিজস্ব ISN, সম্পূর্ণ three-way handshake
- client HTTP request পাঠায়, LB পুরোটা receive+decrypt+parse করেTLS termination এখানে ঘটে (যদি HTTPS); path, host, header, cookie সবকিছু এখন visible
- LB parsed content দেখে backend বাছে, নতুন connection খোলেসম্পূর্ণ দ্বিতীয় TCP handshake, নতুন ISN — client-এর connection থেকে সম্পূর্ণ স্বাধীন (বা pool থেকে reuse)
- LB নতুন-করে-বানানো request backend-কে পাঠায়`X-Forwarded-For` ইত্যাদি header যোগ করে — backend জানেই না আসল client কে, শুধু LB-কে দেখে
- backend-এর response আবার LB হয়ে client-কে ফেরেদুইটা connection-এর মাঝে LB byte-by-byte relay করে, প্রয়োজনে re-encrypt
লক্ষ্য করুন প্রথম trace-এ কোথাও “parse” শব্দটা নেই — LB শুধু প্যাকেট rewrite/forward করে, কোনো HTTP বোঝে না। দ্বিতীয় trace-এ here চিহ্নিত ধাপটাই — client-এর পুরো request receive করে বোঝা — এই পুরো ক্ষমতা ও খরচের উৎস।
ছবিতে
Ring-এ consistent hashing — একটা backend সরানোর আগে ও পরে
AGE (৫টা backend) b4 সরানোর পরে (৪টা backend)
b1 b1
●────● ●────●
b5 b2 b5 b2
● ● ● ●
| ring | | ring |
● ● ● ●
b4 b3 (ফাঁকা) b3
●────● ●────●
(মাঝখানে key ছড়ানো) b4-র arc-এর key এখন b1-এ যায়
(clockwise-এর পরের point)
শুধু b4-এর arc-এর key সরল — b1/b2/b3/b5-এর নিজস্ব arc-এর কোনো key-ই সরেনি।
গড়ে remap হয় ~1/৫ = ২০% key, বাকি ~৮০% একই backend-এই থেকে যায়।তুলনায়, mod-hash-এ (hash(key) % 5 → hash(key) % 4) প্রায় প্রতিটা key-র জন্য mod-এর ফলাফলই বদলে যায় — কারণ divisor নিজেই বদলেছে, শুধু একটা backend সরানো হয়নি বরং পুরো mapping-function-টাই বদলে গেছে। পরের experiment section-এ এই দুইটা সংখ্যা — mod-hash বনাম ring — সরাসরি পাশাপাশি মাপা হয়েছে।
নিজে চালিয়ে দেখুন
lb_sim.py — fairness (round-robin বনাম least-connections) আর remapping (mod-hash বনাম ring)
"""lb_sim.py -- round-robin vs least-connections vs consistent-hashing,
under a heterogeneous workload and a backend-removal event.
Two independent measurements:
(1) load-distribution fairness: round-robin vs least-connections when
request processing times vary significantly.
(2) remapping fraction on a backend-set change: naive modulo hashing
vs consistent (ring) hashing.
Every number below is computed by actually running this script.
"""
import hashlib
import random
import statistics
random.seed(7)
# ---------------------------------------------------------------------
# Part 1: load-distribution fairness under heterogeneous request times
# ---------------------------------------------------------------------
BACKENDS = ["b1", "b2", "b3", "b4", "b5"]
N_REQUESTS = 4000
# 80% of requests are "light" (short processing time), 20% are "heavy"
# (much longer) -- this is what makes round-robin's blindness to load
# visible: it hands out requests strictly in turn, with zero regard for
# how long each backend's *current* work will take.
def draw_duration():
if random.random() < 0.8:
return random.uniform(1.0, 3.0) # light request
return random.uniform(30.0, 50.0) # heavy request
arrivals = [i * 0.5 for i in range(N_REQUESTS)]
durations = [draw_duration() for _ in range(N_REQUESTS)]
# --- round robin: pick backend purely by request index, ignore load ---
rr_busy = {b: 0.0 for b in BACKENDS}
for i, dur in enumerate(durations):
b = BACKENDS[i % len(BACKENDS)]
rr_busy[b] += dur
# --- least connections: an event-driven simulation ------------------
# active[b] = end-times for requests currently "in flight" on backend b.
# At each arrival, count how many end-times are still in the future
# (= active connections right now) and pick the fewest; tie-break on
# cumulative busy-time so far.
active = {b: [] for b in BACKENDS}
lc_busy = {b: 0.0 for b in BACKENDS}
for t, dur in zip(arrivals, durations):
for b in BACKENDS:
active[b] = [e for e in active[b] if e > t]
b = min(BACKENDS, key=lambda x: (len(active[x]), lc_busy[x]))
active[b].append(t + dur)
lc_busy[b] += dur
def fairness_report(name, busy):
vals = list(busy.values())
mean = statistics.mean(vals)
spread = (max(vals) - min(vals)) / mean * 100
stdev = statistics.pstdev(vals)
print(f"{name}")
for b in BACKENDS:
print(f" {b}: total busy time = {busy[b]:8.1f} ({busy[b]/mean*100:6.1f}% of mean)")
print(f" mean={mean:.1f} stdev={stdev:.1f} max-min spread={spread:.1f}% of mean")
print()
print("=== (1) load fairness: round-robin vs least-connections ===")
print(f"{N_REQUESTS} requests, 80% light (1-3 units), 20% heavy (30-50 units), {len(BACKENDS)} backends\n")
fairness_report("round-robin", rr_busy)
fairness_report("least-connections", lc_busy)
# ---------------------------------------------------------------------
# Part 2: remapping cost on backend removal -- naive modulo vs ring
# ---------------------------------------------------------------------
def h(s):
return int(hashlib.md5(s.encode()).hexdigest(), 16)
N_KEYS = 20000
KEYS = [f"client-{i}" for i in range(N_KEYS)]
def modulo_map(backends, keys):
n = len(backends)
return {k: backends[h(k) % n] for k in keys}
VNODES = 100
def ring_map(backends, keys):
ring = sorted((h(f"{b}#{v}"), b) for b in backends for v in range(VNODES))
points = [p for p, _ in ring]
import bisect
out = {}
for k in keys:
i = bisect.bisect(points, h(k)) % len(ring)
out[k] = ring[i][1]
return out
BEFORE = ["b1", "b2", "b3", "b4", "b5"]
AFTER = ["b1", "b2", "b3", "b5"] # b4 removed mid-simulation
mod_before = modulo_map(BEFORE, KEYS)
mod_after = modulo_map(AFTER, KEYS)
ring_before = ring_map(BEFORE, KEYS)
ring_after = ring_map(AFTER, KEYS)
mod_moved = sum(1 for k in KEYS if mod_before[k] != mod_after[k]) / N_KEYS * 100
ring_moved = sum(1 for k in KEYS if ring_before[k] != ring_after[k]) / N_KEYS * 100
print("=== (2) remapping cost when a backend is removed (5 -> 4 backends) ===")
print(f"{N_KEYS} keys, naive modulo hash(key) % N vs consistent-hash ring ({VNODES} vnodes/backend)\n")
print(f"naive modulo hashing : {mod_moved:5.1f}% of keys remapped to a different backend")
print(f"consistent hash ring : {ring_moved:5.1f}% of keys remapped to a different backend")
print(f"theory check : ring remap should be close to 1/{len(BEFORE)} = {100/len(BEFORE):.1f}%")প্রকৃত চালিয়ে পাওয়া output (deterministic — random.seed(7)-এর কারণে হুবহু এটাই আসবে):
=== (1) load fairness: round-robin vs least-connections ===
4000 requests, 80% light (1-3 units), 20% heavy (30-50 units), 5 backends
round-robin
b1: total busy time = 7539.1 ( 99.0% of mean)
b2: total busy time = 7119.7 ( 93.5% of mean)
b3: total busy time = 8438.6 ( 110.8% of mean)
b4: total busy time = 7978.0 ( 104.8% of mean)
b5: total busy time = 6996.5 ( 91.9% of mean)
mean=7614.4 stdev=537.4 max-min spread=18.9% of mean
least-connections
b1: total busy time = 7597.3 ( 99.8% of mean)
b2: total busy time = 7622.8 ( 100.1% of mean)
b3: total busy time = 7601.6 ( 99.8% of mean)
b4: total busy time = 7625.9 ( 100.2% of mean)
b5: total busy time = 7624.4 ( 100.1% of mean)
mean=7614.4 stdev=12.3 max-min spread=0.4% of mean
=== (2) remapping cost when a backend is removed (5 -> 4 backends) ===
20000 keys, naive modulo hash(key) % N vs consistent-hash ring (100 vnodes/backend)
naive modulo hashing : 79.8% of keys remapped to a different backend
consistent hash ring : 21.0% of keys remapped to a different backend
theory check : ring remap should be close to 1/5 = 20.0%দুইটা ফলাফলই এই লেসনের কেন্দ্রীয় দাবি সংখ্যায় প্রমাণ করে। প্রথম অংশে: গড়-busy-time দুই অ্যালগরিদমেই সমান (৭৬১৪.৪, কারণ মোট কাজের পরিমাণ একই), কিন্তু spread-এ আকাশ-পাতাল ফারাক — round-robin-এ stdev ৫৩৭.৪ (max-min spread প্রায় ১৮.৯%), least-connections-এ stdev মাত্র ১২.৩ (spread ০.৪%, প্রায় নিখুঁত সমান)। round-robin ঠিক ততটাই request সংখ্যায় সমান ভাগ করেছে যতটা তার কথা — কিন্তু heavy/light request কোন backend-এ পড়বে সেটা নিছক পালার কপাল, তাই busy-time অসম। দ্বিতীয় অংশে: naive mod-hash ৭৯.৮% key সরিয়েছে একটা মাত্র backend সরানোয়, আর ring মাত্র ২১.০% সরিয়েছে — theory-র প্রত্যাশিত ১/৫ = ২০%-এর প্রায় হুবহু কাছাকাছি (vnode-placement-এর randomness-এর কারণে সামান্য ওঠানামা স্বাভাবিক)।
(১) request-processing-time ভিন্ন ভিন্ন হলে round-robin-এর backend-দের মধ্যে লোড উল্লেখযোগ্যভাবে অসম হয়ে যায়, যেখানে least-connections প্রায় নিখুঁতভাবে সমান রাখে। (২) একটা backend সরালে naive modulo hashing প্রায় সব key remap করে, consistent hash ring তত্ত্ব-অনুযায়ী মাত্র ~1/N remap করে।
নিজে বানান
WeightedRouter — smooth weighted round-robin + health-check gate
- একটা WeightedRouter ক্লাস বানান যেখানে প্রতিটা backend-এর static weight আর একটা running "current weight" counter থাকবে
- pick() মেথডে প্রতি pick-এ প্রতিটা eligible backend-এর current weight তার নিজের weight দিয়ে বাড়ান, সর্বোচ্চ current-weight-এর backend বাছুন, তারপর তার current weight থেকে মোট weight বিয়োগ করুন — এটাই smooth distribution দেয়, burst না
- mark_unhealthy()/mark_healthy() মেথড যোগ করুন যা healthy set আপডেট করে — pick() শুধু এই সেট থেকেই বাছবে, অ্যালগরিদম চলারও আগে
- দুই ধাপে চালিয়ে যাচাই করুন: সব backend সুস্থ থাকা অবস্থায় weight-অনুপাত মেলে কি না, আর একটা backend অসুস্থ হলে সে সম্পূর্ণ বাদ যায় কি না
লক্ষ্য: এই লেসনের দুইটা দাবি একসাথে একটা ছোট ক্লাসে বাস্তবায়ন করা — (১) weighted round-robin heterogeneous-capacity backend-দের নিয়ে কীভাবে ছড়িয়ে (burst না করে) কাজ ভাগ করে, আর (২) health check gate কীভাবে অ্যালগরিদম চলারও আগে backend-কে বাদ দেয়, অ্যালগরিদমের নিজের কোনো “health”-সচেতনতা ছাড়াই।
"""weighted_lb.py -- WeightedRouter: smooth weighted round-robin +
an active-health-check gate that removes unhealthy backends from the
rotation set BEFORE the algorithm ever sees them.
"""
class WeightedRouter:
"""Smooth weighted round-robin (the same algorithm nginx's
`weight=` directive and many hardware LBs use): each backend has a
weight and a running 'current weight' counter. Every pick, every
backend's current weight increases by its own weight; the backend
with the highest current weight is chosen and then has the total
weight subtracted. This spreads picks evenly across a cycle instead
of bursting all of one backend's share together (unlike naive
'repeat backend N times in a row' weighting)."""
def __init__(self, weights):
self.weights = dict(weights) # backend -> static weight
self.current = {b: 0 for b in weights} # backend -> running counter
self.healthy = set(weights) # gate: only healthy backends are eligible
def mark_unhealthy(self, backend):
self.healthy.discard(backend)
def mark_healthy(self, backend):
if backend in self.weights:
self.healthy.add(backend)
def pick(self):
pool = self.healthy
if not pool:
raise RuntimeError("no healthy backend available")
total_weight = sum(self.weights[b] for b in pool)
for b in pool:
self.current[b] += self.weights[b]
chosen = max(pool, key=lambda b: self.current[b])
self.current[chosen] -= total_weight
return chosen
if __name__ == "__main__":
# b1 has 2x the capacity of b2/b3 -- e.g. a bigger instance type
router = WeightedRouter({"b1": 4, "b2": 2, "b3": 2})
print("=== 16 picks, all backends healthy ===")
picks = [router.pick() for _ in range(16)]
print(" ".join(picks))
counts = {b: picks.count(b) for b in ["b1", "b2", "b3"]}
print(f"counts: {counts} (expected ratio b1:b2:b3 = 4:2:2 = 2:1:1)")
print()
print("=== health check fails b1 mid-run -> gate removes it before picking ===")
router.mark_unhealthy("b1")
picks2 = [router.pick() for _ in range(8)]
print(" ".join(picks2))
counts2 = {b: picks2.count(b) for b in ["b2", "b3"]}
print(f"counts: {counts2} (b1 excluded entirely -- gate ran before the algorithm, not after)")প্রকৃত চালিয়ে পাওয়া output (deterministic — হুবহু এটাই আসবে):
=== 16 picks, all backends healthy ===
b1 b2 b3 b1 b1 b2 b3 b1 b1 b2 b3 b1 b1 b2 b3 b1
counts: {'b1': 8, 'b2': 4, 'b3': 4} (expected ratio b1:b2:b3 = 4:2:2 = 2:1:1)
=== health check fails b1 mid-run -> gate removes it before picking ===
b2 b3 b2 b3 b2 b3 b2 b3
counts: {'b2': 4, 'b3': 4} (b1 excluded entirely -- gate ran before the algorithm, not after)লক্ষ্য করুন প্রথম output-এর pattern — b1 b2 b3 b1 b1 b2 b3 b1 ... — b1 কখনো টানা চারবার আসেনি, প্রতি চক্রে ছড়িয়ে আছে। এটাই “smooth” weighted round-robin-এর বৈশিষ্ট্য: naive হলে হয়তো b1 b1 b1 b1 b2 b2 b3 b3 আসত — একই মোট অনুপাত, কিন্তু burst-এ, যা backend-এর instantaneous load-এ spike তৈরি করত। দ্বিতীয় অংশে b1 সম্পূর্ণ অনুপস্থিত — কারণ pick()-এর প্রথম লাইনই self.healthy থেকে pool নেয়, weight/current-counter-এর কোনো লজিক b1-কে “কম করে” বাছেনি, সে আদৌ eligible-ই ছিল না।
বাস্তব সিস্টেমে
বাস্তব প্রোডাক্টে — AWS NLB/ALB, nginx, আর Kubernetes
এই লেসনের সবকিছু বিমূর্ত মনে হতে পারে, কিন্তু প্রতিটা বড় production LB প্রোডাক্ট ঠিক এই একই সিদ্ধান্তগুলোর ওপর দাঁড়িয়ে।
AWS Network Load Balancer (NLB) — খাঁটি L4। NLB flow (৫-টাপল) দেখে একটা target বেছে নেয়, তারপর connection-level-এ forward করে — payload কখনো ছোঁয় না, TLS pass-through সমর্থন করে (backend নিজেই TLS terminate করতে পারে)। এর ডিজাইন-লক্ষ্যই million-scale concurrent connection, ultra-low latency — ঠিক এই লেসনের “L4 দ্রুত কিন্তু অন্ধ” দাবির বাস্তব প্রমাণ।
AWS Application Load Balancer (ALB) — খাঁটি L7। ALB client-এর TCP/TLS connection টার্মিনেট করে, HTTP request পার্স করে, path/host/header-based routing rule দিয়ে ভিন্ন target group-এ পাঠায় — একই ALB-তে /api/* একটা microservice-এ, /static/* আরেকটায়। এই routing ক্ষমতাই তার মূল বিক্রয়-প্রস্তাব, বিনিময়ে NLB-র চেয়ে বেশি latency।
nginx-এর upstream block। least_conn; directive দিলে least-connections, ডিফল্ট round-robin, server app1:8080 weight=2; দিলে weighted, আর hash $request_uri consistent; দিলে consistent hashing — এই লেসনের চারটা অ্যালগরিদমই এক ফাইলে configuration-directive হিসেবে দাঁড়িয়ে আছে (ngx_http_upstream_module — এই লেসনের references-এ)।
Kubernetes — দুই স্তরে দুই layer, ঠিক যেমন এই লেসনের L4/L7 স্তরায়ন-দাবি। service-discovery-dns-k8s লেসন যা খুঁজে বের করেছিল — একটা Service-এর Endpoints/EndpointSlice — তার ওপর দিয়েই এই দুই স্তরের LB আসলে চলে:
- একটা plain
Service(ClusterIP/NodePort/LoadBalancer) L4-স্তরে কাজ করে —kube-proxy(iptables বা IPVS mode-এ) প্রতিটা connection-কে Endpoints তালিকার একটা IP:port-এ NAT-rewrite করে পাঠায়, প্রায় round-robin-এর কাছাকাছি একটা সিদ্ধান্তে। এটা HTTP path/host দেখে না — দেখতেই পারে না, কারণ এটা L4। - একটা
Ingress(nginx-ingress, বা Envoy-ভিত্তিক কিছু) L7-স্তরে কাজ করে — host/path rule পার্স করে ভিন্ন Service-এ route করে, ঠিক ALB-র মতো। এটাই সেই স্তর যেখানে এই লেসনের সব অ্যালগরিদম (least-conn, consistent-hashing session affinity) configuration-অপশন হিসেবে ফিরে আসে।
যে ভুলগুলো সবাই করে
“L7 সবসময় ভালো, কারণ এটা 'বেশি বুদ্ধিমান' — L4 শুধু পুরনো/সীমিত technology”
L7-এর content-aware routing-ক্ষমতা বেশি, কিন্তু “বেশি ক্ষমতা” মানে “সবসময় ভালো” না। TLS termination আর HTTP parsing-এর CPU-খরচ, আর দুইটা সম্পূর্ণ TCP connection চেইন হওয়ার extra hop-latency — এগুলো real cost, illusion না। যেখানে HTTP-awareness দরকার নেই (raw TCP database traffic, non-HTTP protocol, বা শুধু million-connection-scale flood-resistant front door দরকার), সেখানে L4-ই সঠিক পছন্দ — এই কারণেই AWS আলাদা NLB (L4) আর ALB (L7) প্রোডাক্ট রাখে, একটা অন্যটাকে প্রতিস্থাপন করেনি।
“Load balancing মানেই round-robin — এটাই default, তাই এটাই যথেষ্ট বেশিরভাগ সময়”
Round-robin request-সংখ্যা সমান ভাগ করে, request-কাজ সমান ভাগ করে না। এই লেসনের experiment-এ যখন request-processing-time-এ ৮০/২০ heterogeneity ছিল (৮০% হালকা, ২০% ভারী), round-robin-এর backend-দের busy-time-এ প্রায় ১৯% spread দেখা গেছে, যেখানে least-connections-এ মাত্র ০.৪%। “Default” হওয়া মানে “সবসময় সঠিক পছন্দ” না — round-robin শুধু তখনই fair যখন request-cost প্রায় সমান।
“Consistent hashing মানে backend যোগ/বাদ দিলেও কোনো key-ই সরে না”
Consistent hashing minimize করে, eliminate করে না। এই লেসনের experiment-এ একটা backend সরানোয় ring-hashing-ও ২১% key সরিয়েছে — শূন্য না, কিন্তু naive mod-hash-এর ৮০%-এর তুলনায় বহুগুণ কম। সঠিক দাবিটা: “শুধু সেই arc-এর key সরে যেটা সরানো backend-এর মালিকানায় ছিল, বাকি সব key-র owner অপরিবর্তিত থাকে” — এটা ~1/N, শূন্য না।
“একটা backend health check-এ fail করলে সাথে সাথেই আর কখনো traffic পাবে না, যতক্ষণ না manually ঠিক করা হয়”
বাস্তব health-check ডিজাইন দুই-দিকেই symmetric — শুধু fail-threshold-এ evict না, success-threshold ফিরে এলে আবার pool-এ ফেরানোও দরকার (এই লেসনের build-এর mark_healthy() ঠিক এই কাজ করে)। এখানে একটা বাস্তব বিপদ আছে: threshold খুব সংবেদনশীল হলে একটা borderline backend join/evict-এর মধ্যে দোলে (flapping) — তাই production system-এ join-threshold কড়া আর evict-threshold শিথিল রাখা হয় (hysteresis), যাতে সাময়িক blip-এ পুরো pool অস্থির না হয়ে যায়।
বুঝেছেন কি না দেখুন
1এক বাক্যে — L4 load balancer ঠিক কী দেখে, আর L7 load balancer ঠিক কী দেখে?স্মরণ
2L7 load balancing L4-এর চেয়ে বেশি latency যোগ করে কেন — নির্দিষ্টভাবে, TCP connection-এর ভাষায় ব্যাখ্যা করুন।যুক্তি
3Request-processing-time-এ ব্যাপক ভিন্নতা থাকলে round-robin কেন ভেঙে পড়ে, আর least-connections কেন সেই একই পরিস্থিতিতে ভালো করে?যুক্তি
4একটা service অনেক দীর্ঘস্থায়ী WebSocket connection পরিবেশন করে, প্রতিটার processing-cost খুব ভিন্ন (কিছু connection প্রায় idle, কিছু ক্রমাগত ভারী message পাঠাচ্ছে)। round-robin না least-connections — কোনটা বেছে নেবেন, আর কোনো সতর্কতা মনে রাখা দরকার?প্রয়োগ
5৮-backend-এর একটা consistent-hash ring থেকে একটা backend সরানো হলো। তত্ত্ব-অনুযায়ী প্রায় কত শতাংশ key remap হবে, আর একই পরিবর্তনে naive hash(key) % N scheme-এ কত শতাংশ remap হতো?প্রয়োগ
1/8 = ১২.৫% key remap হবে — শুধু সরানো backend-এর arc-এর key-গুলো, বাকি সব backend-এর arc অক্ষত থাকে। Naive mod-hash-এ N=8 থেকে N=7-এ বদলালে divisor নিজেই বদলে যাওয়ায় প্রায় সব key-র জন্যই hash(key) % N-এর ফলাফল বদলে যায় — বাস্তবে প্রায় ৭/৮ ≈ ৮৭.৫% বা তার কাছাকাছি key remap হয়।6আপনাকে একটা global, high-throughput in-memory cache fleet-এর সামনে একটা load balancer ডিজাইন করতে হবে। প্রতি সেকেন্ডে লক্ষ লক্ষ request আসে, তাই HTTP parsing-এর CPU-খরচ afford করা যাবে না — L4-স্পিডে চলতে হবে। কিন্তু cache-locality বজায় রাখার জন্য একই cache-key-র request যেন সবসময় একই backend-এ যায়, আর backend স্কেল-আপ/ডাউন-এ cache-stampede এড়াতে হবে। কোন অ্যালগরিদম বেছে নেবেন, আর L4-এর সীমাবদ্ধতার কারণে ঠিক কী compromise করতে হবে?ডিজাইন
এরপর কী
আজকের লেসন load-balancing অ্যালগরিদম-এর ওপর মনোযোগ দিয়েছে — round-robin, least-connections, consistent hashing, health check — কিন্তু L7-এর “connection টার্মিনেট করে, parse করে, নতুন connection খোলে” এই পুরো মেকানিজমটা আসলে একটা অনেক বেশি সাধারণ software component-এর একটা feature মাত্র: reverse proxy। TLS termination, caching, header rewriting, WebSocket upgrade — এই সবকিছুই একই component-এর ভেতরে বাস করে, load-balancing অ্যালগরিদম যার একটা অংশ।
পরের লেসন, virtualization-cloud/reverse-proxy-nginx-envoy, ঠিক সেই software-টাই খুলে দেখাবে — nginx আর Envoy আসলে ভেতরে কীভাবে কাজ করে, আর কীভাবে এই একই reverse-proxy ধারণা modern service-mesh architecture-এ একটা sidecar-এ পরিণত হয়েছে, প্রতিটা service-এর পাশে বসে থাকা একটা মিনি-proxy হিসেবে। আজকের অ্যালগরিদম-vocabulary (L4/L7, health check, consistent hashing) সেখানে অপরিবর্তিত থেকে যাবে — শুধু “কে এটা বাস্তবায়ন করছে” প্রশ্নের উত্তর গভীর হবে।
আরও পড়ুন
- Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web — David Karger, Eric Lehman, Tom Leighton, Rina Panigrahy, Matthew Levine, Daniel Lewin — MIT (ACM STOC, 1997) · Consistent hashing-এর মূল, original paper — ring-based mapping আর node-change-এ minimal remapping-এর গাণিতিক প্রমাণ এখান থেকেই আসে
- How Network Load Balancer works — AWS Elastic Load Balancing Documentation · একটা বাস্তব, production L4 load balancer-এর documentation — flow hashing, connection-level (packet নয়, connection) routing, TLS pass-through
- How Application Load Balancer works — AWS Elastic Load Balancing Documentation · একটা বাস্তব, production L7 load balancer-এর documentation — path/host-based routing rule, TLS termination, listener/target-group মডেল
- ngx_http_upstream_module — nginx Documentation · round-robin (default), `weight=`, `least_conn`, `ip_hash` directive-গুলোর প্রামাণ্য উৎস — এই লেসনের অ্যালগরিদম-তুলনার বাস্তব সফটওয়্যার-বাস্তবায়ন