Load Balancing

مسئله

یک Backend تنها، فقط به اندازه‌ی ظرفیت خودش می‌تواند درخواست سرو کند و اگر همان یکی از کار بیفتد، کل سرویس با آن از دسترس خارج می‌شود — به عبارتی ایجاد SPOF (Single Point of Failure) در بدترین حالت خود. Load Balancer دقیقاً همین مشکل را حل می‌کند: درخواست‌ها را بین چند Backend تقسیم می‌کند تا:

  • Horizontal Scaling داشته باشید — به‌جای یک سرور بزرگ و پرهزینه، چند Backend کوچک‌تر
  • قابلیت اطمینان داشته باشید — اگر یک Backend از دسترس خارج شود، بقیه‌ی سیستم همچنان کار می‌کند
  • نگهداری بدون قطعی ممکن شود — برای Update کردن یک عضو، فقط موقتاً از چرخه بیرونش می‌کشید

در واقع، Load Balancer شبیه یک Reverse Proxy است با چند مقصد به‌جای یکی. در دنیای واقعی همین نقش را NGINX، HAProxy، Load Balancerهای ابری و Kubernetes Serviceها بازی می‌کنند — اگرچه پوسته‌ها فرق دارند، اما ایده‌ی زیرینش یکی است.

الگوریتم‌ها

برای این‌که مشخص شود هر درخواست به کدام Backend برود، چند قاعده‌ی رایج وجود دارد:

  • Round Robin — به‌ترتیب و چرخشی؛ ساده‌ترین حالت و پیش‌فرض NGINX، و وقتی همه‌ی Backendها هم‌ظرفیت باشند به‌خوبی کار می‌کند
  • Least Connections — درخواست بعدی به Backendی می‌رود که در همان لحظه کمترین اتصال فعال را دارد؛ برای وقتی مفید است که زمان پردازش درخواست‌ها یکسان نیست

الگوریتم‌های پیچیده‌تر — وزن‌دهی، IP Hash، سنجش سریع‌ترین پاسخ — همه در نهایت ریشه‌شان به همین دو ایده برمی‌گردد؛ برای آشنایی اولیه فعلا همین دو کافی است.

تمرین: دو Backend و یک Load Balancer

دو Backend ساده روی پورت‌های 8081 و 8082 بالا می‌آوریم که با سرعت‌های متفاوت پاسخ می‌دهند — یکی ۲۰۰ میلی‌ثانیه، دیگری ۵۰۰ میلی‌ثانیه — تا تفاوت الگوریتم‌ها را بهتر ببینیم:

 1mkdir -p ~/lb-lab
 2cd ~/lb-lab
 3
 4cat > backend1.py << 'EOF'
 5from http.server import HTTPServer, BaseHTTPRequestHandler
 6import time
 7
 8class Handler(BaseHTTPRequestHandler):
 9    def do_GET(self):
10        time.sleep(0.2)
11        self.send_response(200)
12        self.send_header('Content-type', 'text/html; charset=utf-8')
13        self.end_headers()
14        self.wfile.write(b'<h1>Backend 8081</h1>\n')
15    
16    def log_message(self, format, *args):
17        pass
18
19HTTPServer(('', 8081), Handler).serve_forever()
20EOF
21
22cat > backend2.py << 'EOF'
23from http.server import HTTPServer, BaseHTTPRequestHandler
24import time
25
26class Handler(BaseHTTPRequestHandler):
27    def do_GET(self):
28        time.sleep(0.5)
29        self.send_response(200)
30        self.send_header('Content-type', 'text/html; charset=utf-8')
31        self.end_headers()
32        self.wfile.write(b'<h1>Backend 8082</h1>\n')
33    
34    def log_message(self, format, *args):
35        pass
36
37HTTPServer(('', 8082), Handler).serve_forever()
38EOF
39
40python3 backend1.py &
41python3 backend2.py

حالا در پیکربندی NGINX یک upstream تعریف می‌کنیم و proxy_pass را به آن اشاره می‌دهیم، نه به یک Backend مشخص. فایل /etc/nginx/sites-available/default را به این شکل تغییر دهید:

 1upstream backend_pool {
 2    server 127.0.0.1:8081;
 3    server 127.0.0.1:8082;
 4}
 5
 6server {
 7    listen 80 default_server;
 8    listen [::]:80 default_server;
 9
10    location / {
11        proxy_pass http://backend_pool;
12    }
13}

تغییرات را اعمال و آزمایش کنید:

1sudo nginx -t
2sudo systemctl reload nginx.service
3sleep 1
4curl -s http://127.0.0.1/
5curl -s http://127.0.0.1/
6curl -s http://127.0.0.1/
7curl -s http://127.0.0.1/

خروجی‌ها یکی‌درمیان Backend 8081 و Backend 8082 را نشان می‌دهند — این یعنی Round Robin دقیقاً همان‌طور که انتظار داشتید کار می‌کند.

تست الگوریتم Least Connections

برای دیدن تفاوت واقعی Least Connections با Round Robin، باید درخواست‌های همزمان بفرستیم تا ببینیم کدام Backend کمتر مشغول است. این اسکریپت کوتاه را بسازید:

 1cat > ~/lb-lab/test-lb.sh << 'EOF'
 2#!/bin/bash
 3echo "Sending 10 requests with 50ms intervals..." >&2
 4for i in {0..9}; do
 5  (
 6    response=$(curl -s http://127.0.0.1/)
 7    echo "Request $i: $response"
 8  ) &
 9  sleep 0.05
10done
11wait
12EOF
13
14chmod +x ~/lb-lab/test-lb.sh

ابتدا با پیکربندی پیش‌فرض (Round Robin) اجرا کنید:

1~/lb-lab/test-lb.sh

خروجی را بررسی کنید — باید دقیقاً یکی‌درمیان Backend 8081 و Backend 8082 را ببینید. حالا least_conn; را به upstream اضافه کنید:

1upstream backend_pool {
2    least_conn;
3    server 127.0.0.1:8081;
4    server 127.0.0.1:8082;
5}

Reload کنید و دوباره تست را اجرا کنید:

1sudo systemctl reload nginx.service
2sleep 1
3~/lb-lab/test-lb.sh

این بار توزیع متفاوت است — چرا؟ به این فکر کنید: Backend اول ۲۰۰ میلی‌ثانیه برای پاسخ زمان می‌برد، دومی ۵۰۰ میلی‌ثانیه. وقتی درخواست‌ها با فاصله‌ی ۵۰ میلی‌ثانیه می‌رسند، چند درخواست همزمان در حال پردازش هستند و کدام Backend زودتر اتصالاتش را آزاد می‌کند؟ Round Robin فقط نوبتی عمل می‌کند، اما Least Connections چه معیاری برای انتخاب Backend دارد؟

یک آزمایش آخر و جذاب: یکی از Backendها را با Ctrl+C خاموش کنید و چند بار curl بزنید. NGINX به‌طور پیش‌فرض وقتی یک Backend جواب نمی‌دهد، خودکار سراغ Backend بعدی می‌رود — این ساده‌ترین و در‌عین‌حال مؤثرترین شکل fault tolerance است که در این سطح می‌بینید.

منابع یادگیری

مستندات و مقالات

ویدیو