رفتن به محتوا
LoopX

load balancing

توی این درس یاد می‌گیری به‌جای یک نسخه، چند نسخه از اپ را پشت Nginx بگذاری و درخواست‌ها را بینشان پخش کنی (load balancing، «متعادل‌سازی بار»). با بلوک upstream کار می‌کنی، روش‌های پخش (round robin، least_conn، ip_hash و hash) را روی سه اپ واقعی آزمایش می‌کنی، می‌بینی وقتی یکی از سرورها می‌میرد Nginx چطور با max_fails و fail_timeout کنارش می‌گذارد، با backup و down دیپلوی بدون قطعی انجام می‌دهی، و آخر سر سه نسخه از یک اپ را با Docker Compose بالا می‌آوری و پشت Nginx متعادل می‌کنی.

مسئله: یک سرور کافی نیست

Section titled “مسئله: یک سرور کافی نیست”

تا اینجا هر سایت یک اپ داشت: Nginx جلو، اپ پشتش. دو مشکل این مدل:

  1. ظرفیت: یک پروسه‌ی Node یا Python فقط تا حدی درخواست جواب می‌دهد. وقتی کاربرها زیاد می‌شوند، صف درخواست‌ها بلند می‌شود و سایت کند.
  2. نقطه‌ی شکست: اگر همان یک اپ crash کند یا برای دیپلوی ری‌استارت شود، سایت قطع است (همان 502 درس reverse proxy).

راه‌حل: چند نسخه‌ی یکسان از اپ (روی یک سرور با پورت‌های مختلف، یا روی چند سرور) و Nginx که درخواست‌ها را بینشان پخش کند و نسخه‌ی خراب را کنار بگذارد.

تشبیه: صف بانک با چند باجه

Section titled “تشبیه: صف بانک با چند باجه”

یک بانک با چند باجه را تصور کن. مشتری‌ها در یک صف می‌ایستند و مسئول نوبت‌دهی (Nginx) هر مشتری را به یک باجه (اپ) می‌فرستد:

  • round robin: به ترتیب: باجه‌ی ۱، ۲، ۳، دوباره ۱.
  • weight: باجه‌دار باتجربه سه برابر بقیه مشتری می‌گیرد.
  • least_conn: به باجه‌ای که الان کمترین مشتری را دارد بفرست (اگر یک نفر کار طولانی دارد، باجه‌اش را شلوغ نکن).
  • ip_hash: هر مشتری همیشه به همان باجه‌ی همیشگی‌اش (چون پرونده‌اش آنجاست).
  • max_fails: باجه‌ای که دو بار پشت‌سرهم جواب نداد، ده ثانیه کسی را به آن نفرست.
  • backup: باجه‌ی ذخیره فقط وقتی باز می‌شود که همه‌ی باجه‌های اصلی بسته باشند.

upstream: یک اسم برای چند سرور

Section titled “upstream: یک اسم برای چند سرور”

بلوک upstream (در context http، کنار serverها) یک گروه سرور با یک اسم می‌سازد. بعد proxy_pass به‌جای آدرس یک اپ، به اسم گروه اشاره می‌کند:

upstream backend {
server 127.0.0.1:9411;
server 127.0.0.1:9412;
server 127.0.0.1:9413;
}
server {
location / {
proxy_pass http://backend;
}
}
کاربرها فقط Nginx را می‌بینند. Nginx برای هر درخواست، طبق روش پخش، یکی از اعضای upstream را انتخاب می‌کند. عضوی که چند بار پشت‌سرهم جواب نداد (خط‌چین) تا مدتی کنار گذاشته می‌شود.

این درس روی ماشین آزمایشی server (Ubuntu 24.04، Nginx 1.24، Node.js 18 از مخزن Ubuntu) با کاربر root اجرا شده؛ تمرین Compose روی خود داکر. دامنه‌ی آزمایشی shop.test در /etc/hosts به 127.0.0.1 اشاره می‌کند.

اول اپ. یک سرور کوچک Node که فقط می‌گوید «من کدام نسخه‌ام» (اسمش را از پورتش می‌سازد). مسیر /slow سه ثانیه طول می‌کشد (برای مثال least_conn) و مسیر /crash اتصال را بدون جواب می‌بندد (برای پشت پرده):

/srv/lx-app/app.js
// tiny backend: says which instance answered
const http = require('http');
const port = Number(process.env.PORT);
const name = `app-${port}`;
http.createServer((req, res) => {
if (req.url.startsWith('/crash')) return req.socket.destroy(); // die mid-request
const reply = () => res.end(`${name} ${req.method} ${req.url}\n`);
if (req.url.startsWith('/slow')) setTimeout(reply, 3000);
else reply();
}).listen(port, '127.0.0.1');

برای اجرای سه نسخه، سه فایل سرویس جدا لازم نیست. systemd unit قالبی (template unit) دارد: فایلی که اسمش به @ ختم می‌شود، و هر چیزی که بعد از @ در اسم سرویس بیاید، داخل فایل با %i در دسترس است:

/etc/systemd/system/lx-app@.service
[Unit]
Description=LoopX test backend on port %i
[Service]
Environment=PORT=%i
ExecStart=/usr/bin/node /srv/lx-app/app.js
Restart=on-failure
[Install]
WantedBy=multi-user.target
Terminal window
systemctl daemon-reload
systemctl start lx-app@9411 lx-app@9412 lx-app@9413
sleep 1
systemctl is-active lx-app@9411 lx-app@9412 lx-app@9413
for port in 9411 9412 9413; do curl -s "http://127.0.0.1:$port/"; done
خروجی
active
active
active
app-9411 GET /
app-9412 GET /
app-9413 GET /

lx-app@9411 یعنی همان قالب با %i=9411؛ پس PORT=9411. سه پروسه‌ی مستقل داریم که هر کدام خودش را معرفی می‌کند. هنوز هیچ‌کدام پشت Nginx نیست.

مثال ۲: round robin (و چرا zone لازم است)

Section titled “مثال ۲: round robin (و چرا zone لازم است)”

یک upstream با سه عضو، و یک سایت که به آن proxy می‌کند. روش پیش‌فرض round robin است: به نوبت.

/etc/nginx/sites-available/shop.test
upstream backend {
server 127.0.0.1:9411;
server 127.0.0.1:9412;
server 127.0.0.1:9413;
}
server {
listen 80;
server_name shop.test;
location / {
proxy_pass http://backend;
include proxy_params;
}
}
Terminal window
ln -sf /etc/nginx/sites-available/shop.test /etc/nginx/sites-enabled/
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
for i in $(seq 9); do curl -s http://shop.test/; done
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
app-9411 GET /
app-9411 GET /
app-9411 GET /
app-9411 GET /
app-9412 GET /
app-9413 GET /
app-9411 GET /
app-9412 GET /
app-9413 GET /

هر ۹ درخواست جواب گرفتند و هر سه نسخه کار کردند؛ ولی ترتیب عجیب است: چهار درخواست اول همه به ۹۴۱۱ رفتند و بعد نوبتی شد. round robin مگر «به نوبت» نبود؟ دو عدد را ببینیم و یک خط به upstream اضافه کنیم:

Terminal window
grep -E '^worker_processes' /etc/nginx/nginx.conf
nproc
sed -i 's|^upstream backend {|upstream backend {\n zone backend 64k;|' /etc/nginx/sites-available/shop.test
head -n 3 /etc/nginx/sites-available/shop.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
for i in $(seq 9); do curl -s http://shop.test/; done
خروجی
worker_processes auto;
4
upstream backend {
zone backend 64k;
server 127.0.0.1:9411;
nginx: configuration file /etc/nginx/nginx.conf test is successful
app-9411 GET /
app-9412 GET /
app-9413 GET /
app-9411 GET /
app-9412 GET /
app-9413 GET /
app-9411 GET /
app-9412 GET /
app-9413 GET /

Nginx چند worker دارد (worker_processes auto یعنی به تعداد هسته‌ها؛ اینجا ۴). هر worker درخواست‌های اتصال‌های خودش را جواب می‌دهد و بدون zone، شمارنده‌ی نوبت خودش را دارد؛ پس پخش کلی می‌تواند نامتوازن شود. zone backend 64k; یک حافظه‌ی مشترک بین workerها می‌سازد تا همه از یک شمارنده (و یک وضعیت سلامت سرورها) استفاده کنند. حالا دقیقاً به نوبت: ۹۴۱۱، ۹۴۱۲، ۹۴۱۳، و دوباره. ۶۴ کیلوبایت برای ده‌ها سرور کافی است.

مثال ۳: weight، سرور قوی‌تر سهم بیشتر

Section titled “مثال ۳: weight، سرور قوی‌تر سهم بیشتر”

اگر یکی از سرورها دو برابر بقیه CPU دارد، باید سهم بیشتری بگیرد. weight (پیش‌فرض ۱) سهم نسبی هر عضو است:

Terminal window
sed -i 's| server 127.0.0.1:9411;| server 127.0.0.1:9411 weight=3;|' /etc/nginx/sites-available/shop.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
for i in $(seq 10); do curl -s http://shop.test/; done | sort | uniq -c
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
6 app-9411 GET /
2 app-9412 GET /
2 app-9413 GET /

جمع وزن‌ها ۳+۱+۱=۵ است؛ پس از هر ۵ درخواست، ۳ تا به ۹۴۱۱ می‌رود. در ۱۰ درخواست: ۶، ۲ و ۲. Nginx این‌ها را پشت‌سرهم نمی‌فرستد، بلکه در هم می‌چیند (smooth weighted round robin) تا سرور قوی‌تر یک‌باره زیر بار نرود.

مثال ۴: least_conn برای درخواست‌های کند

Section titled “مثال ۴: least_conn برای درخواست‌های کند”

round robin فقط نوبت را می‌شمارد، نه اینکه سرور چقدر سرش شلوغ است. اگر بعضی درخواست‌ها طولانی‌اند (گزارش‌گیری، آپلود)، round robin ممکن است درخواست تازه را به سروری بدهد که هنوز مشغول یک کار سنگین است. دو درخواست /slow (هر کدام ۳ ثانیه) می‌فرستیم و وسط کارشان چهار درخواست معمولی؛ یک بار با round robin، یک بار با least_conn:

Terminal window
cat > /etc/nginx/sites-available/shop.test <<'EOF'
upstream backend {
zone backend 64k;
server 127.0.0.1:9411;
server 127.0.0.1:9412;
server 127.0.0.1:9413;
}
server {
listen 80;
server_name shop.test;
location / {
proxy_pass http://backend;
include proxy_params;
}
}
EOF
test_busy() {
curl -s http://shop.test/slow > /tmp/slow1 &
curl -s http://shop.test/slow > /tmp/slow2 &
sleep 0.5
for i in 1 2 3 4; do curl -s http://shop.test/; done
wait
cat /tmp/slow1 /tmp/slow2
}
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
echo "=== round robin"
test_busy
sed -i 's|^ zone backend 64k;| zone backend 64k;\n least_conn;|' /etc/nginx/sites-available/shop.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
echo "=== least_conn"
test_busy
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
=== round robin
app-9413 GET /
app-9411 GET /
app-9412 GET /
app-9413 GET /
app-9412 GET /slow
app-9411 GET /slow
nginx: configuration file /etc/nginx/nginx.conf test is successful
=== least_conn
app-9413 GET /
app-9413 GET /
app-9413 GET /
app-9413 GET /
app-9412 GET /slow
app-9411 GET /slow

خروجی‌ها را دقیق بخوان (چهار خط اول درخواست‌های معمولی‌اند، دو خط آخر همان دو درخواست کند که بعد از تمام شدن چاپ شدند):

  • round robin: دو درخواست کند به ۹۴۱۱ و ۹۴۱۲ رفتند. بعد درخواست‌های معمولی باز هم به نوبت پخش شدند: ۹۴۱۳، ۹۴۱۱، ۹۴۱۲، ۹۴۱۳؛ یعنی به همان دو سروری که وسط کار سنگین بودند هم درخواست تازه رسید.
  • least_conn: دو درخواست کند باز به ۹۴۱۲ و ۹۴۱۱ رفتند، ولی هر چهار درخواست معمولی به ۹۴۱۳ رفتند؛ تنها سروری که اتصال فعال نداشت.

اپ Node ما چند درخواست را همزمان جواب می‌دهد، پس اینجا کندی‌ای دیده نشد. ولی در اپ‌هایی که هر پروسه در هر لحظه یک درخواست را پردازش می‌کند (مثل خیلی از تنظیم‌های PHP-FPM، یا اپ Python با یک worker)، آن درخواست‌های معمولی پشت کار سه‌ثانیه‌ای در صف می‌ماندند. هر جا زمان درخواست‌ها خیلی متفاوت است (گزارش‌گیری، آپلود، WebSocket که ساعت‌ها باز می‌ماند)، least_conn انتخاب بهتری است.

مثال ۵: ip_hash و hash، هر کاربر به سرور خودش

Section titled “مثال ۵: ip_hash و hash، هر کاربر به سرور خودش”

بعضی اپ‌ها وضعیت کاربر (session، سبد خرید) را در حافظه‌ی همان پروسه نگه می‌دارند. اگر درخواست بعدی کاربر به سرور دیگری برود، سرور جدید او را نمی‌شناسد. ip_hash از روی IP کاربر یک عدد (hash) می‌سازد و همیشه همان سرور را انتخاب می‌کند (به این sticky session یا «چسبندگی» می‌گویند).

برای آزمایش، چند «کاربر» با IP های مختلف لازم داریم. همه‌ی آدرس‌های 127.x.x.x روی loopback لینوکس کار می‌کنند و curl --interface می‌گوید درخواست از کدام IP فرستاده شود:

Terminal window
sed -i 's|^ least_conn;| ip_hash;|' /etc/nginx/sites-available/shop.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
for ip in 127.0.0.1 127.0.0.2 127.0.0.3 127.0.0.4 127.0.1.1 127.0.2.1 127.0.3.1 127.0.4.1; do
printf '%-10s -> %s, again -> %s\n' "$ip" \
"$(curl -s --interface "$ip" http://shop.test/ | cut -d' ' -f1)" \
"$(curl -s --interface "$ip" http://shop.test/ | cut -d' ' -f1)"
done
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
127.0.0.1 -> app-9413, again -> app-9413
127.0.0.2 -> app-9413, again -> app-9413
127.0.0.3 -> app-9413, again -> app-9413
127.0.0.4 -> app-9413, again -> app-9413
127.0.1.1 -> app-9411, again -> app-9411
127.0.2.1 -> app-9412, again -> app-9412
127.0.3.1 -> app-9413, again -> app-9413
127.0.4.1 -> app-9411, again -> app-9411

هر IP دو بار پشت‌سرهم به همان سرور رفت؛ چسبندگی کار می‌کند. ولی دقت کن: چهار IP اول (127.0.0.1 تا 127.0.0.4) همه به app-9413 رفتند. ip_hash برای IPv4 فقط سه بخش اول آدرس را حساب می‌کند (یعنی کل 127.0.0.x یک «کاربر» است). این طراحی عمدی است (کاربرانی که پشت یک شبکه‌ی اداری با چند IP نزدیک هستند، به یک سرور بروند)، ولی وقتی کاربرها از یک محدوده می‌آیند (مثلاً همه از پشت یک اپراتور، یک VPN یا یک CDN)، پخش بار به‌شدت نامتوازن می‌شود.

جایگزین دقیق‌تر hash است که هر متغیری را می‌گیرد؛ با کل IP:

Terminal window
sed -i 's|^ ip_hash;| hash $remote_addr consistent;|' /etc/nginx/sites-available/shop.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
for ip in 127.0.0.1 127.0.0.2 127.0.0.3 127.0.0.4 127.0.0.5 127.0.0.6; do
printf '%-10s -> %s, again -> %s\n' "$ip" \
"$(curl -s --interface "$ip" http://shop.test/ | cut -d' ' -f1)" \
"$(curl -s --interface "$ip" http://shop.test/ | cut -d' ' -f1)"
done
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
127.0.0.1 -> app-9413, again -> app-9413
127.0.0.2 -> app-9411, again -> app-9411
127.0.0.3 -> app-9411, again -> app-9411
127.0.0.4 -> app-9412, again -> app-9412
127.0.0.5 -> app-9413, again -> app-9413
127.0.0.6 -> app-9411, again -> app-9411

حالا IP های هم‌محدوده هم بین سرورها پخش شدند و هر کدام همچنان به سرور خودش می‌چسبد. کلمه‌ی consistent (روش ketama) یعنی اگر یک سرور به گروه اضافه یا از آن کم شود، فقط سهم کوچکی از کاربرها جابه‌جا می‌شوند (بدون آن، تقریباً همه‌ی کاربرها سرورشان عوض می‌شود). hash با هر متغیری کار می‌کند: hash $cookie_sessionid consistent; (بر اساس کوکی) یا hash $request_uri consistent; (هر آدرس همیشه به یک سرور؛ برای سرورهای کش مفید است).

مثال ۶: سرور خراب، max_fails و fail_timeout

Section titled “مثال ۶: سرور خراب، max_fails و fail_timeout”

حالا مهم‌ترین مزیت چند نسخه: تحمل خرابی. به هر عضو می‌گوییم «اگر ۲ بار در ۱۰ ثانیه جواب نداد، ۱۰ ثانیه کنار بگذارش»، و بعد سرور ۹۴۱۲ را خاموش می‌کنیم:

Terminal window
cat > /etc/nginx/sites-available/shop.test <<'EOF'
upstream backend {
zone backend 64k;
server 127.0.0.1:9411 max_fails=2 fail_timeout=10s;
server 127.0.0.1:9412 max_fails=2 fail_timeout=10s;
server 127.0.0.1:9413 max_fails=2 fail_timeout=10s;
}
server {
listen 80;
server_name shop.test;
location / {
proxy_pass http://backend;
include proxy_params;
}
}
EOF
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
: > /var/log/nginx/error.log
systemctl stop lx-app@9412
for i in $(seq 8); do curl -s -w ' (HTTP %{http_code})\n' http://shop.test/ | tr -d '\n'; echo; done
echo "--- error.log:"
grep -oE '(connect\(\) failed|no live upstreams).*upstream: "[^"]*"' /var/log/nginx/error.log | sed 's/ while connecting to upstream, client: .*, upstream:/ ... upstream:/'
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
app-9411 GET / (HTTP 200)
app-9413 GET / (HTTP 200)
app-9413 GET / (HTTP 200)
app-9411 GET / (HTTP 200)
app-9413 GET / (HTTP 200)
app-9411 GET / (HTTP 200)
app-9413 GET / (HTTP 200)
app-9411 GET / (HTTP 200)
--- error.log:
connect() failed (111: Connection refused) ... upstream: "http://127.0.0.1:9412/"
connect() failed (111: Connection refused) ... upstream: "http://127.0.0.1:9412/"

هر ۸ درخواست ۲۰۰ گرفتند؛ کاربر اصلاً خرابی را ندید. ولی error log دو خط دارد. چه شد؟

  • Nginx دو بار درخواستی را به ۹۴۱۲ داد، اتصال رد شد (Connection refused)، و هر بار همان درخواست را فوراً به عضو بعدی داد؛ برای همین کاربر جواب گرفت (فقط چند میلی‌ثانیه دیرتر).
  • بعد از ۲ شکست (max_fails=2)، ۹۴۱۲ برای ۱۰ ثانیه (fail_timeout=10s) کنار رفت و بقیه‌ی درخواست‌ها بدون امتحان کردنش بین ۹۴۱۱ و ۹۴۱۳ پخش شدند.
  • بعد از ۱۰ ثانیه، Nginx دوباره یک درخواست واقعی به ۹۴۱۲ می‌دهد. اگر جواب داد، برمی‌گردد به گروه؛ اگر نه، باز کنار می‌رود.

(در خروجی، ... جای بخشی از خط لاگ است که برای خوانایی با sed کوتاهش کردیم.)

اگر همه خراب باشند چه؟

Terminal window
systemctl stop lx-app@9411 lx-app@9413
: > /var/log/nginx/error.log
curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://shop.test/
grep -oE '(connect\(\) failed|no live upstreams).*upstream: "[^"]*"' /var/log/nginx/error.log | sed 's/ while connecting to upstream, client: .*, upstream:/ ... upstream:/'
systemctl start lx-app@9411 lx-app@9412 lx-app@9413
خروجی
HTTP 502
connect() failed (111: Connection refused) ... upstream: "http://127.0.0.1:9413/"
connect() failed (111: Connection refused) ... upstream: "http://127.0.0.1:9411/"
no live upstreams ... upstream: "http://backend/"

این بار کاربر ۵۰۲ گرفت. Nginx ۹۴۱۳ و ۹۴۱۱ را امتحان کرد و هر دو رد شدند؛ ۹۴۱۲ را اصلاً امتحان نکرد، چون هنوز در ۱۰ ثانیه‌ی کنار ماندنش بود. وقتی هیچ عضو زنده‌ای نماند، خط no live upstreams در error log نوشته شد. هر وقت این پیام را دیدی، یعنی «همه‌ی اعضای upstream خراب یا کنار گذاشته‌اند»؛ اولین قدم: وضعیت خود اپ‌ها (systemctl status 'lx-app@*'). آخر بلوک هر سه را دوباره روشن کردیم.

مثال ۷: backup و down، دیپلوی بدون قطعی

Section titled “مثال ۷: backup و down، دیپلوی بدون قطعی”

دو پارامتر دیگر:

  • backup: این عضو فقط وقتی درخواست می‌گیرد که همه‌ی اعضای اصلی خراب باشند (مثلاً یک سرور ضعیف‌تر، یا نسخه‌ای که صفحه‌ی «در حال تعمیر» نشان می‌دهد).
  • down: این عضو را موقتاً کنار بگذار (بدون پاک کردن خطش). برای دیپلوی: یک سرور را down کن، reload، نسخه‌ی جدید را رویش نصب کن، برگردانش، و سراغ بعدی برو.
Terminal window
sleep 1
sed -i 's| server 127.0.0.1:9413 max_fails=2 fail_timeout=10s;| server 127.0.0.1:9413 backup;|' /etc/nginx/sites-available/shop.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
echo "=== normal:"
for i in 1 2 3 4; do curl -s http://shop.test/; done
echo "=== 9411 marked down:"
sed -i 's| server 127.0.0.1:9411 max_fails=2 fail_timeout=10s;| server 127.0.0.1:9411 max_fails=2 fail_timeout=10s down;|' /etc/nginx/sites-available/shop.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
for i in 1 2 3 4; do curl -s http://shop.test/; done
echo "=== 9412 stopped too:"
systemctl stop lx-app@9412
for i in 1 2 3 4; do curl -s http://shop.test/; done
systemctl start lx-app@9412
sed -i 's| down;|;|' /etc/nginx/sites-available/shop.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
=== normal:
app-9411 GET /
app-9412 GET /
app-9411 GET /
app-9412 GET /
=== 9411 marked down:
nginx: configuration file /etc/nginx/nginx.conf test is successful
app-9412 GET /
app-9412 GET /
app-9412 GET /
app-9412 GET /
=== 9412 stopped too:
app-9413 GET /
app-9413 GET /
app-9413 GET /
app-9413 GET /
nginx: configuration file /etc/nginx/nginx.conf test is successful
  • عادی: فقط ۹۴۱۱ و ۹۴۱۲ (اعضای اصلی) درخواست گرفتند؛ ۹۴۱۳ backup هیچ.
  • ۹۴۱۱ با down: همه به ۹۴۱۲ رفت. هنوز یک عضو اصلی زنده است، پس backup وارد نشد. این همان لحظه‌ای است که می‌توانی روی ۹۴۱۱ نسخه‌ی جدید را نصب کنی، بی‌آنکه حتی یک درخواست به آن برسد (برخلاف خاموش کردن ناگهانی، که چند درخواست را تا رسیدن به max_fails به خطا و تلاش دوباره می‌اندازد).
  • ۹۴۱۲ هم خاموش: حالا هیچ عضو اصلی سالمی نمانده و backup (۹۴۱۳) همه را جواب داد. کاربر باز هم خطایی ندید.

آخر بلوک همه‌چیز برگشت: ۹۴۱۲ روشن شد و down از فایل پاک شد.

پشت پرده: کدام سرور جواب داد، و Nginx کی دوباره امتحان می‌کند؟

Section titled “پشت پرده: کدام سرور جواب داد، و Nginx کی دوباره امتحان می‌کند؟”

Nginx متن‌باز سلامت سرورها را منفعل (passive) تشخیص می‌دهد: خودش به سرورها سر نمی‌زند؛ فقط وقتی یک درخواست واقعی شکست می‌خورد، آن را می‌شمارد. (بررسی فعال با health_check فقط در نسخه‌ی تجاری NGINX Plus است.) وقتی درخواستی شکست خورد، Nginx همان درخواست را به عضو بعدی می‌دهد؛ این رفتار با proxy_next_upstream کنترل می‌شود (پیش‌فرض: error timeout، یعنی خطای اتصال و timeout).

برای دیدن این‌ها، متغیر $upstream_addr (آدرس عضوی که جواب داد؛ اگر چند تا امتحان شد، همه با کاما) و $upstream_status را در یک لاگ جدا می‌نویسیم:

Terminal window
cat > /etc/nginx/conf.d/lx-lblog.conf <<'EOF'
log_format lb '$request_method $request_uri -> $status '
'upstream=[$upstream_addr] upstream_status=[$upstream_status] time=$upstream_response_time';
EOF
sed -i 's|^ server_name shop.test;| server_name shop.test;\n access_log /var/log/nginx/lb.log lb;|' /etc/nginx/sites-available/shop.test
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
: > /var/log/nginx/error.log
systemctl stop lx-app@9412
for i in 1 2 3; do curl -s -o /dev/null http://shop.test/; done
systemctl start lx-app@9412
sleep 1
curl -s -o /dev/null http://shop.test/crash
curl -s -o /dev/null -X POST http://shop.test/crash
cat /var/log/nginx/lb.log
echo "--- error.log:"
grep -oE 'upstream prematurely closed connection|Connection refused' /var/log/nginx/error.log | sort | uniq -c
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
GET / -> 200 upstream=[127.0.0.1:9411] upstream_status=[200] time=0.002
GET / -> 200 upstream=[127.0.0.1:9412, 127.0.0.1:9411] upstream_status=[502, 200] time=0.000, 0.002
GET / -> 200 upstream=[127.0.0.1:9411] upstream_status=[200] time=0.001
GET /crash -> 502 upstream=[127.0.0.1:9412, 127.0.0.1:9411, 127.0.0.1:9413] upstream_status=[502, 502, 502] time=0.007, 0.001, 0.001
POST /crash -> 502 upstream=[127.0.0.1:9411] upstream_status=[502] time=0.001
--- error.log:
1 Connection refused
4 upstream prematurely closed connection

هر خط یک درخواست است:

  1. GET / مستقیم به ۹۴۱۱ رفت: یک آدرس، یک وضعیت.
  2. GET / اول به ۹۴۱۲ (خاموش) رفت و 502 گرفت (Nginx خطای اتصال را در $upstream_status با 502 نشان می‌دهد)، بعد همان درخواست به ۹۴۱۱ رفت و 200. کاربر 200 دید. دو زمان هم داریم، یکی برای هر تلاش.
  3. GET / مستقیم به ۹۴۱۱.
  4. GET /crash: اپ وسط درخواست اتصال را بست (در error log: upstream prematurely closed connection؛ چهار بار، سه تا برای این GET و یکی برای POST بعدی). Nginx چون GET خودتوان (idempotent؛ تکرارش بی‌خطر) است، آن را به ۹۴۱۲، ۹۴۱۱ و حتی ۹۴۱۳ (عضو backup) داد و همه همان کار را کردند؛ نتیجه‌ی نهایی 502. این خطر هم دارد: درخواستی که سرور را از کار می‌اندازد، با تلاش دوباره همه‌ی سرورها را می‌زند. proxy_next_upstream_tries 2; تعداد تلاش‌ها را محدود می‌کند.
  5. POST /crash: فقط یک تلاش، بعد 502.

این تفاوت GET و POST عمدی است: Nginx از نسخه‌ی ۱.۹.۱۳ درخواست‌های غیرخودتوان (non-idempotent؛ POST، LOCK، PATCH) را اگر به سرور رسیده باشند، دوباره به سرور دیگر نمی‌فرستد، چون شاید سرور اول کار را انجام داده و فقط جواب را نفرستاده (مثلاً پرداخت ثبت شده ولی پاسخ گم شده؛ تکرارش یعنی پرداخت دوبار). اگر مطمئنی تکرار بی‌خطر است، proxy_next_upstream error timeout non_idempotent; آن را مجاز می‌کند. خطای «اتصال رد شد» (سرور خاموش) برای POST هم تکرار می‌شود، چون درخواست اصلاً به سرور نرسیده است.

یک نکته‌ی دیگر: وضعیت «این سرور خراب است» در حافظه‌ی workerها نگه داشته می‌شود. بدون zone، هر worker جدا باید خودش ۲ بار شکست بخورد تا سرور را کنار بگذارد (پس کاربرهای بیشتری خطای اول را تجربه می‌کنند). با zone شمارش بین همه مشترک است.

روش‌های پخش (داخل upstream):

روش چطور انتخاب می‌کند مناسب برای
(پیش‌فرض) round robin به نوبت، با رعایت weight درخواست‌های هم‌اندازه و اپ بدون وضعیت
least_conn; عضو با کمترین اتصال فعال (با رعایت weight) درخواست‌های با زمان خیلی متفاوت (گزارش، آپلود، WebSocket)
ip_hash; hash سه بخش اول IPv4 چسبندگی ساده؛ ولی همه‌ی یک /24 یک سرور
hash $remote_addr consistent; hash کل IP، با کمترین جابه‌جایی هنگام تغییر اعضا چسبندگی دقیق‌تر
hash $cookie_NAME consistent; hash یک کوکی چسبندگی بر اساس session
random two least_conn; دو عضو تصادفی، کم‌مشغول‌تر از آن دو چند load balancer با یک گروه سرور

پارامترهای هر server در upstream:

پارامتر پیش‌فرض معنی
weight=N ۱ سهم نسبی
max_fails=N ۱ چند شکست در بازه‌ی fail_timeout تا کنار گذاشته شود (0 یعنی هرگز)
fail_timeout=T 10s هم بازه‌ی شمارش شکست‌ها، هم مدت کنار ماندن
backup فقط وقتی همه‌ی اعضای اصلی خراب‌اند (با hash و ip_hash و random کار نمی‌کند)
down موقتاً خارج از سرویس
max_conns=N ۰ (نامحدود) حداکثر اتصال همزمان به این عضو (با zone بین workerها)

دستورهای مرتبط دیگر: zone NAME SIZE; (حافظه‌ی مشترک)، keepalive N; (اتصال‌های باز به اعضا، درس reverse proxy)، و در location: proxy_next_upstream، proxy_next_upstream_tries و proxy_next_upstream_timeout.

۱) backup همراه با ip_hash یا hash

Section titled “۱) backup همراه با ip_hash یا hash”
Terminal window
cp /etc/nginx/sites-available/shop.test /root/shop.test.bak
sed -i 's|^ zone backend 64k;| zone backend 64k;\n ip_hash;|' /etc/nginx/sites-available/shop.test
nginx -t
cp /root/shop.test.bak /etc/nginx/sites-available/shop.test
خروجی
2026/10/04 14:02:02 [emerg] 36339#36339: balancing method does not support parameter "backup" in /etc/nginx/sites-enabled/shop.test:6
nginx: configuration file /etc/nginx/nginx.conf test failed

روش‌های hash باید برای هر کاربر همیشه یک عضو مشخص را انتخاب کنند و عضوی که «گاهی هست» با این منطق جور نیست. راه‌حل: با hash از backup استفاده نکن؛ عضو خراب خودش با max_fails کنار می‌رود و کاربرانش بین بقیه پخش می‌شوند.

اگر جلوی Nginx یک CDN یا load balancer دیگر باشد، $remote_addr همیشه IP آن است (چند IP محدود)؛ پس ip_hash همه را به یک یا دو سرور می‌فرستد (همان چیزی که در مثال ۵ با 127.0.0.x دیدی). راه‌حل: IP واقعی را با ماژول realip بازیابی کن، یا hash روی یک کوکی.

۳) فراموش کردن پورت یا http://

Section titled “۳) فراموش کردن پورت یا http://”
Terminal window
sed -i 's|proxy_pass http://backend;|proxy_pass backend;|' /etc/nginx/sites-available/shop.test
nginx -t
cp /root/shop.test.bak /etc/nginx/sites-available/shop.test
خروجی
2026/10/04 14:02:02 [emerg] 36342#36342: invalid URL prefix in /etc/nginx/sites-enabled/shop.test:14
nginx: configuration file /etc/nginx/nginx.conf test failed

proxy_pass همیشه پروتکل می‌خواهد (http:// یا https://)، حتی وقتی به اسم یک upstream اشاره می‌کند. اشتباه هم‌خانواده: server 127.0.0.1; (بدون پورت) در upstream خطا نمی‌دهد و یعنی پورت ۸۰؛ پس به‌جای اپ، درخواست‌ها به خود Nginx برمی‌گردند. همیشه پورت را صریح بنویس.

۴) اپ با وضعیت در حافظه، بدون چسبندگی

Section titled “۴) اپ با وضعیت در حافظه، بدون چسبندگی”

اگر اپ سبد خرید را در حافظه‌ی پروسه نگه دارد و روش round robin باشد، سبد کاربر در هر درخواست «گم و پیدا» می‌شود. این خطا در لاگ Nginx دیده نمی‌شود، چون از دید Nginx همه‌چیز 200 است. راه‌حل: session مشترک (Redis، دیتابیس) یا موقتاً hash $cookie_....

✎ تمرینآسان

سه سرور با وزن ۲، ۱ و ۱ تنظیم کن (least_conn یا hash نباشد) و با ۴۰ درخواست نشان بده سهم هر کدام چقدر است.

دیدن جواب
Terminal window
cat > /etc/nginx/sites-available/shop.test <<'EOF'
upstream backend {
zone backend 64k;
server 127.0.0.1:9411 weight=2;
server 127.0.0.1:9412;
server 127.0.0.1:9413;
}
server {
listen 80;
server_name shop.test;
location / {
proxy_pass http://backend;
include proxy_params;
}
}
EOF
nginx -t 2>&1 | tail -n 1 && systemctl reload nginx
sleep 1
for i in $(seq 40); do curl -s http://shop.test/ | cut -d' ' -f1; done | sort | uniq -c
خروجی
nginx: configuration file /etc/nginx/nginx.conf test is successful
20 app-9411
10 app-9412
10 app-9413

جمع وزن‌ها ۴ است: ۹۴۱۱ نصف درخواست‌ها (۲۰ از ۴۰) و هر کدام از دو تای دیگر یک چهارم (۱۰).

✎ تمرینمتوسط

دیپلوی بدون قطعی: در حالی که یک حلقه پشت‌سرهم به shop.test درخواست می‌فرستد، سه نسخه را یکی‌یکی ری‌استارت کن (هر بار: آن عضو را down کن، reload، ری‌استارت اپ، برگرداندن عضو، reload). آخر سر نشان بده چند درخواست فرستاده شد و چند تا غیر از 200 بود.

دیدن جواب
Terminal window
: > /tmp/codes
( for i in $(seq 300); do curl -s -o /dev/null -w '%{http_code}\n' http://shop.test/ >> /tmp/codes; sleep 0.02; done ) &
loop=$!
for port in 9411 9412 9413; do
sed -i "s| server 127.0.0.1:$port\(.*\);| server 127.0.0.1:$port\1 down;|" /etc/nginx/sites-available/shop.test
nginx -t -q && systemctl reload nginx
sleep 1
systemctl restart "lx-app@$port"
sleep 1
sed -i "s| server 127.0.0.1:$port\(.*\) down;| server 127.0.0.1:$port\1;|" /etc/nginx/sites-available/shop.test
nginx -t -q && systemctl reload nginx
echo "restarted $port"
done
wait "$loop"
echo "requests: $(wc -l < /tmp/codes)"
sort /tmp/codes | uniq -c
grep -c down /etc/nginx/sites-available/shop.test
خروجی
restarted 9411
restarted 9412
restarted 9413
requests: 300
300 200
0

هر سه نسخه یکی‌یکی ری‌استارت شدند و در همان مدت ۳۰۰ درخواست فرستاده شد، همه ۲۰۰. آخر سر هیچ down ای در فایل نمانده (0).

چرا هیچ درخواستی خطا نخورد؟ چون هیچ‌وقت درخواستی به سرور در حال ری‌استارت نرسید: down + reload اول آن را از گروه بیرون برد، و reload خودش هم بی‌قطعی است (workerهای قدیمی درخواست‌های در جریان را تمام می‌کنند و workerهای جدید با تنظیم تازه شروع می‌کنند؛ درس ساختار تنظیمات). همین الگو را اسکریپت‌های دیپلوی و ابزارهایی مثل Ansible روی چند سرور واقعی اجرا می‌کنند.

✎ تمرینسخت

تمرین اصلی درس: سه نمونه از یک اپ را با Docker Compose بالا بیاور و پشت یک Nginx (آن هم در Compose) متعادل کن. Nginx باید روی 127.0.0.1:8097 میزبان باشد. بعد تعداد نسخه‌ها را به ۵ برسان و نشان بده Nginx نسخه‌های جدید را هم می‌بیند. (برای اپ از خود ایمیج nginx:alpine استفاده کن که با return 200 اسم کانتینرش را برمی‌گرداند.)

دیدن جواب

اپ: هر نسخه با متغیر $hostname اسم کانتینر خودش را برمی‌گرداند:

app.conf
# every replica answers with its own container hostname
server {
listen 80;
default_type text/plain;
return 200 "app $hostname\n";
}

load balancer: نکته‌ی اصلی resolve است (پایین توضیح داده شده):

lb.conf
# Docker's internal DNS server; re-check names every 5s
resolver 127.0.0.11 valid=5s;
upstream app {
zone app 64k;
server app:80 resolve;
}
server {
listen 80;
location / {
proxy_pass http://app;
}
}
compose.yaml
services:
app:
image: nginx:alpine
volumes:
- ./app.conf:/etc/nginx/conf.d/default.conf:ro
deploy:
replicas: 3
lb:
image: nginx:alpine
volumes:
- ./lb.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "127.0.0.1:8097:80"
depends_on:
- app
Terminal window
docker compose -p lx-lb up -d --quiet-pull 2>&1 | grep -v -e Creating -e Created -e Starting -e Network
sleep 2
docker compose -p lx-lb ps --format '{{.Name}}\t{{.Status}}'
docker compose -p lx-lb exec lb nginx -v
echo "=== 9 requests, 3 replicas:"
for i in $(seq 9); do curl -s http://127.0.0.1:8097/; done | sort | uniq -c
docker compose -p lx-lb up -d --scale app=5 2>&1 | grep -c Started
sleep 6
echo "=== 10 requests, 5 replicas:"
for i in $(seq 10); do curl -s http://127.0.0.1:8097/; done | sort | uniq -c
خروجی
Container lx-lb-app-3 Started
Container lx-lb-app-1 Started
Container lx-lb-app-2 Started
Container lx-lb-lb-1 Started
lx-lb-app-1 Up 2 seconds
lx-lb-app-2 Up 2 seconds
lx-lb-app-3 Up 2 seconds
lx-lb-lb-1 Up 2 seconds
nginx version: nginx/1.31.6
=== 9 requests, 3 replicas:
3 app 245d92fe0f05
3 app df27638eaf67
3 app ff0808eb1b63
2
=== 10 requests, 5 replicas:
2 app 08e29adf3299
2 app 245d92fe0f05
2 app d6efd9145fc0
2 app df27638eaf67
2 app ff0808eb1b63

خط‌به‌خط:

  • deploy.replicas: 3 سه کانتینر از سرویس app ساخت (lx-lb-app-1 تا 3). هیچ‌کدام پورتی روی میزبان ندارند؛ فقط lb روی 127.0.0.1:8097 است.
  • داخل شبکه‌ی Compose، DNS داخلی داکر (روی آدرس 127.0.0.11 داخل هر کانتینر) برای اسم app هر سه IP را برمی‌گرداند. server app:80 یعنی «هر IP که این اسم دارد، یک عضو». پس ۹ درخواست، ۳ به هر کانتینر.
  • --scale app=5 دو کانتینر جدید ساخت (2 = تعداد خط‌های Started). چند ثانیه بعد، ۱۰ درخواست بین پنج کانتینر پخش شد (۲ به هر کدام)، بدون reload Nginx.

راز آخری resolve است (به‌علاوه‌ی resolver و zone که لازمش هستند): Nginx اسم را هر ۵ ثانیه (valid=5s) دوباره از DNS داکر می‌پرسد و اعضا را به‌روز می‌کند. بدون resolve، Nginx اسم را فقط یک بار موقع شروع resolve می‌کند: کانتینرهای اضافه را تا reload بعدی نمی‌بیند، و اگر موقع شروع هیچ کانتینر app ای نباشد، اصلاً بالا نمی‌آید (این خطا را در درس بعد، Nginx در داکر، از نزدیک می‌بینی). resolve در Nginx متن‌باز از نسخه‌ی ۱.۲۷.۳ آمده؛ ایمیج nginx:alpine ما ۱.۳۱ است، ولی Nginx ۱.۲۴ Ubuntu 24.04 آن را ندارد.

⚡ بررسی سریع

ده درخواست با weight=3 روی اولین سرور و weight پیش‌فرض روی دو سرور دیگر. سرور اول چند درخواست می‌گیرد؟

؟ آزمونک
  1. بدون zone در upstream، چرا round robin ممکن است نامتوازن باشد؟

  2. کاربرها همه از پشت یک اپراتور با IP های 5.6.7.x می‌آیند و ip_hash داری. چه اتفاقی می‌افتد؟

  3. max_fails=2 fail_timeout=10s یعنی چه؟

  4. اپ وسط پردازش یک POST اتصال را بست. Nginx چه می‌کند؟

  5. برای دیپلوی یکی‌یکی سرورها، بهترین کار با upstream چیست؟

  6. در Compose با deploy.replicas: 3، چرا server app:80 resolve لازم است؟

  • upstream NAME { server ...; } در context http، و proxy_pass http://NAME;. همیشه zone NAME 64k; بگذار تا workerها حالت مشترک داشته باشند.
  • روش‌ها: round robin (پیش‌فرض، با weight)، least_conn برای درخواست‌های با زمان متفاوت، hash $remote_addr consistent برای چسبندگی (ip_hash فقط سه بخش اول IPv4 را می‌بیند). بهتر از چسبندگی: session مشترک.
  • تحمل خرابی: max_fails و fail_timeout (تشخیص منفعل)؛ درخواست شکست‌خورده به عضو بعدی می‌رود (proxy_next_upstream)، ولی POST رسیده به سرور تکرار نمی‌شود. همه خراب: 502 و no live upstreams.
  • backup (ذخیره، نه با hash) و down (دیپلوی یکی‌یکی بدون قطعی).
  • دیباگ: $upstream_addr، $upstream_status و $upstream_response_time در log_format.
  • داکر: deploy.replicas + resolver 127.0.0.11 + server app:80 resolve; (Nginx ۱.۲۷.۳ به بعد) تا scale بدون reload دیده شود.
برگه‌ی تقلب این درس
دستورکاری که می‌کند
upstream backend { zone backend 64k; server 127.0.0.1:9411; ... }گروه سرور با حالت مشترک
proxy_pass http://backend;فرستادن به گروه
server 127.0.0.1:9411 weight=3;سهم بیشتر
least_conn;کم‌مشغول‌ترین عضو
ip_hash;چسبندگی با سه بخش اول IP
hash $remote_addr consistent;چسبندگی با کل IP
server ... max_fails=2 fail_timeout=10s;کنار گذاشتن عضو خراب
server ... backup;عضو ذخیره
server ... down;خارج از سرویس موقت
log_format lb '... $upstream_addr $upstream_status';کدام عضو جواب داد
systemctl start lx-app@9411اجرای نمونه از unit قالبی systemd
curl --interface 127.0.0.2 URLدرخواست از یک IP دیگر
resolver 127.0.0.11; server app:80 resolve;دیدن scale در داکر
docker compose up -d --scale app=5تغییر تعداد نسخه‌ها