Foundationপ্রথম নীতি থেকে
LEVEL 12লেসন ১৫/২২কঠিন৫০ মিনিট

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-এ নিরাপদ রাখে — সবকিছুর পেছনে নিজে-চালানো সংখ্যা।

এই লেসন শেষে আপনি পারবেন

  • L4 (transport-layer) আর L7 (application-layer) load balancing-এর মধ্যে নির্ভুল, general সংজ্ঞাগত পার্থক্য — কে ঠিক কোন তথ্য দেখে, আর কেন সেটাই routing-ক্ষমতা আর গতির মধ্যেকার trade-off ঠিক করে দেয় — ব্যাখ্যা করতে পারবেন
  • round-robin, least-connections, আর তাদের weighted variant — প্রতিটার নির্ভুল মেকানিজম আর ঠিক কোন workload-এ কোনটা ভেঙে পড়ে তা যুক্তি দিয়ে তুলনা করতে পারবেন
  • consistent hashing-এর ring-based মেকানিজম — key ও backend দুটোকেই এক circular space-এ hash করা, আর node যোগ/বিয়োগে কেন শুধু সামান্য অংশ remap হয় — গাণিতিকভাবে প্রমাণ করতে পারবেন
  • active আর passive health check-এর পার্থক্য বলে দেখাতে পারবেন কেন এই gate যেকোনো load-balancing অ্যালগরিদমকে প্রকৃতপক্ষে production-এ নিরাপদ করার পূর্বশর্ত
  • নিজে চালানো simulation থেকে প্রকৃত সংখ্যা দিয়ে — heterogeneous workload-এ round-robin বনাম least-connections-এর fairness, আর backend-set বদলে naive modulo hashing বনাম consistent hashing-এর remapping fraction — প্রমাণ করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

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)-এ বসানো হয়:

  1. প্রতিটা backend-কে একটা hash function দিয়ে ring-এর একটা point-এ বসানো হয় (production-এ একটা backend আসলে অনেকগুলো point পায় — vnode, নিচে ব্যাখ্যা)।
  2. প্রতিটা key-ও একই hash function দিয়ে ring-এ একটা point পায়।
  3. একটা 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-স্তরে

L4 balancer দিয়ে একটা request — একটাই connection, শুরু থেকে শেষ
  1. client SYN পাঠায়destination: LB-এর virtual IP; LB শুধু ৫-টাপল দেখে একটা backend বেছে নেয় (round-robin/least-conn/hash)
  2. LB destination rewrite করে, SYN forward করে backend-কেNAT-rewrite — client-এর SYN-এর কোনো byte বদলায়নি, শুধু destination IP:port
  3. backend সরাসরি SYN-ACK পাঠায় (বা LB-র মাধ্যমে rewrite হয়ে ফেরে)client-এর দৃষ্টিতে এটা এখনও একটা সাধারণ three-way handshake — backend-এর ISN-ই দেখছে
  4. client ACK পাঠিয়ে handshake সম্পূর্ণ করেconnection ESTABLISHED — client ও backend-এর মধ্যে সরাসরি, একটাই TCP stream
  5. বাকি সব প্যাকেট শুধু forward হয়LB প্রতিটা প্যাকেটে শুধু IP rewrite করে (বা DSR-এ শুধু এক দিকই ছোঁয়) — HTTP/payload কখনো পার্স হয় না
L7 balancer দিয়ে একটা request — দুইটা connection, চেইন হয়ে
  1. client TCP handshake সম্পূর্ণ করে LB-র সাথেLB নিজেই এখানে TCP endpoint — নিজস্ব ISN, সম্পূর্ণ three-way handshake
  2. client HTTP request পাঠায়, LB পুরোটা receive+decrypt+parse করেTLS termination এখানে ঘটে (যদি HTTPS); path, host, header, cookie সবকিছু এখন visible
  3. LB parsed content দেখে backend বাছে, নতুন connection খোলেসম্পূর্ণ দ্বিতীয় TCP handshake, নতুন ISN — client-এর connection থেকে সম্পূর্ণ স্বাধীন (বা pool থেকে reuse)
  4. LB নতুন-করে-বানানো request backend-কে পাঠায়`X-Forwarded-For` ইত্যাদি header যোগ করে — backend জানেই না আসল client কে, শুধু LB-কে দেখে
  5. 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-এই থেকে যায়।
hash ring৫টা backend, vnode-সহ ring। b4 সরে গেলে শুধু তার arc-এর key-গুলো clockwise-এর পরের backend-এ যায় — বাকি ring অক্ষত।

তুলনায়, mod-hash-এ (hash(key) % 5 → hash(key) % 4) প্রায় প্রতিটা key-র জন্য mod-এর ফলাফলই বদলে যায় — কারণ divisor নিজেই বদলেছে, শুধু একটা backend সরানো হয়নি বরং পুরো mapping-function-টাই বদলে গেছে। পরের experiment section-এ এই দুইটা সংখ্যা — mod-hash বনাম ring — সরাসরি পাশাপাশি মাপা হয়েছে।

নিজে চালিয়ে দেখুন

EXPERIMENT

lb_sim.py — fairness (round-robin বনাম least-connections) আর remapping (mod-hash বনাম ring)

যেকোনো OS (Python 3, dependency নেই) — সত্যিই চালানো, hand-waved সংখ্যা না· ১৫ মিনিট
"""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 করে।

নিজে বানান

BUILD IT

WeightedRouter — smooth weighted round-robin + health-check gate

Python · ●●●○○
  1. একটা WeightedRouter ক্লাস বানান যেখানে প্রতিটা backend-এর static weight আর একটা running "current weight" counter থাকবে
  2. pick() মেথডে প্রতি pick-এ প্রতিটা eligible backend-এর current weight তার নিজের weight দিয়ে বাড়ান, সর্বোচ্চ current-weight-এর backend বাছুন, তারপর তার current weight থেকে মোট weight বিয়োগ করুন — এটাই smooth distribution দেয়, burst না
  3. mark_unhealthy()/mark_healthy() মেথড যোগ করুন যা healthy set আপডেট করে — pick() শুধু এই সেট থেকেই বাছবে, অ্যালগরিদম চলারও আগে
  4. দুই ধাপে চালিয়ে যাচাই করুন: সব 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 ঠিক কী দেখে?স্মরণ
L4 শুধু TCP/UDP header দেখে — source/destination IP আর port, payload অস্পৃশ্য। L7 সম্পূর্ণ connection টার্মিনেট করে HTTP request পার্স করে — path, host, header, cookie সবকিছু দেখতে পায়।
2L7 load balancing L4-এর চেয়ে বেশি latency যোগ করে কেন — নির্দিষ্টভাবে, TCP connection-এর ভাষায় ব্যাখ্যা করুন।যুক্তি
L4-এ client আর backend-এর মধ্যে একটাই TCP connection, একটাই three-way handshake — LB শুধু প্যাকেট rewrite/forward করে। L7-এ LB নিজে client-এর সাথে একটা সম্পূর্ণ connection টার্মিনেট করে (একটা handshake), তারপর backend-এর সাথে সম্পূর্ণ দ্বিতীয়, স্বাধীন connection খোলে (আরেকটা handshake) — দুইটা connection চেইন হয়ে আছে, তাই data দুইবার একটা পূর্ণ TCP stack দিয়ে যায়, একবার না। তার ওপর HTTP parsing আর (HTTPS হলে) TLS termination-এর CPU-খরচ যোগ হয়।
3Request-processing-time-এ ব্যাপক ভিন্নতা থাকলে round-robin কেন ভেঙে পড়ে, আর least-connections কেন সেই একই পরিস্থিতিতে ভালো করে?যুক্তি
Round-robin backend বাছে শুধু একটা counter দিয়ে, request-এর cost বা backend-এর বর্তমান busy-অবস্থা সম্পর্কে কিছুই না জেনে — তাই কয়েকটা ভারী request পরপর একই backend-এ পড়লে (নেহাত পালার কারণে) সে পিছিয়ে পড়ে। Least-connections প্রতিটা সিদ্ধান্তের আগে backend-এর বর্তমান active-connection সংখ্যা দেখে — একটা backend ভারী request নিয়ে ব্যস্ত থাকলে তার active-count বেশি দেখাবে, তাই পরের request স্বাভাবিকভাবেই অন্য কারো কাছে যায়। এটা self-correcting, round-robin না।
4একটা service অনেক দীর্ঘস্থায়ী WebSocket connection পরিবেশন করে, প্রতিটার processing-cost খুব ভিন্ন (কিছু connection প্রায় idle, কিছু ক্রমাগত ভারী message পাঠাচ্ছে)। round-robin না least-connections — কোনটা বেছে নেবেন, আর কোনো সতর্কতা মনে রাখা দরকার?প্রয়োগ
Least-connections, কারণ WebSocket-এর মতো long-lived, ভিন্ন-cost connection-এ round-robin-এর “সমান সংখ্যা = সমান কাজ” অনুমান পুরোপুরি ভাঙে। সতর্কতা: least-connections-ও নিখুঁত না যদি “active connection সংখ্যা” আসল load-এর প্রক্সি না হয় — এখানে একটা backend-এ অনেক connection খোলা থাকলেও বেশিরভাগ idle হতে পারে, তখন connection-count প্রকৃত CPU/bandwidth load-কে ভুলভাবে প্রতিনিধিত্ব করে।
5৮-backend-এর একটা consistent-hash ring থেকে একটা backend সরানো হলো। তত্ত্ব-অনুযায়ী প্রায় কত শতাংশ key remap হবে, আর একই পরিবর্তনে naive hash(key) % N scheme-এ কত শতাংশ remap হতো?প্রয়োগ
Ring-এ প্রায় 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 করতে হবে?ডিজাইন
Consistent hashing দরকার cache-locality আর minimal-remap-এর জন্য — কিন্তু L4-স্তরে HTTP payload অদৃশ্য, তাই hash করার মতো একমাত্র সহজলভ্য তথ্য হলো TCP/UDP header-এর ফিল্ড (client source IP:port), cache-key নিজে না (যেটা payload-এর ভেতরে, L7-only তথ্য)। Compromise: source-IP-based consistent hashing দিয়ে “একই client সবসময় একই backend” নিশ্চিত করা যায় (client-level affinity), কিন্তু “একই cache-key সবসময় একই backend” (key-level affinity, একই client ভিন্ন key চাইলেও) নিশ্চিত করা যাবে না — সেটার জন্য payload পড়তে হবে, যা L4-এর সংজ্ঞা-বহির্ভূত। প্রকৃত key-level consistent hashing চাইলে হয় L7-এ যেতে হবে (CPU-খরচ মেনে নিয়ে), অথবা client নিজেই key-aware routing করে সরাসরি সঠিক backend-এ কানেক্ট করবে এমন একটা design নিতে হবে (LB-কে বাইপাস করে)।

এরপর কী

আজকের লেসন 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-গুলোর প্রামাণ্য উৎস — এই লেসনের অ্যালগরিদম-তুলনার বাস্তব সফটওয়্যার-বাস্তবায়ন