توی این درس یاد میگیری بهجای یک نسخه، چند نسخه از اپ را پشت Nginx بگذاری و درخواستها را بینشان پخش کنی (load balancing، «متعادلسازی بار»). با بلوک upstream کار میکنی، روشهای پخش (round robin، least_conn، ip_hash و hash) را روی سه اپ واقعی آزمایش میکنی، میبینی وقتی یکی از سرورها میمیرد Nginx چطور با max_fails و fail_timeout کنارش میگذارد، با backup و down دیپلوی بدون قطعی انجام میدهی، و آخر سر سه نسخه از یک اپ را با Docker Compose بالا میآوری و پشت Nginx متعادل میکنی.
مسئله: یک سرور کافی نیست
Section titled “مسئله: یک سرور کافی نیست”تا اینجا هر سایت یک اپ داشت: Nginx جلو، اپ پشتش. دو مشکل این مدل:
- ظرفیت: یک پروسهی Node یا Python فقط تا حدی درخواست جواب میدهد. وقتی کاربرها زیاد میشوند، صف درخواستها بلند میشود و سایت کند.
- نقطهی شکست: اگر همان یک اپ 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; }}مثالهای عملی
Section titled “مثالهای عملی”این درس روی ماشین آزمایشی server (Ubuntu 24.04، Nginx 1.24، Node.js 18 از مخزن Ubuntu) با کاربر root اجرا شده؛ تمرین Compose روی خود داکر. دامنهی آزمایشی shop.test در /etc/hosts به 127.0.0.1 اشاره میکند.
مثال ۱: سه نسخه از یک اپ
Section titled “مثال ۱: سه نسخه از یک اپ”اول اپ. یک سرور کوچک Node که فقط میگوید «من کدام نسخهام» (اسمش را از پورتش میسازد). مسیر /slow سه ثانیه طول میکشد (برای مثال least_conn) و مسیر /crash اتصال را بدون جواب میبندد (برای پشت پرده):
// tiny backend: says which instance answeredconst 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 در دسترس است:
[Unit]Description=LoopX test backend on port %i
[Service]Environment=PORT=%iExecStart=/usr/bin/node /srv/lx-app/app.jsRestart=on-failure
[Install]WantedBy=multi-user.targetsystemctl daemon-reloadsystemctl start lx-app@9411 lx-app@9412 lx-app@9413sleep 1systemctl is-active lx-app@9411 lx-app@9412 lx-app@9413for port in 9411 9412 9413; do curl -s "http://127.0.0.1:$port/"; doneactiveactiveactiveapp-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 است: به نوبت.
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; }}ln -sf /etc/nginx/sites-available/shop.test /etc/nginx/sites-enabled/nginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1for i in $(seq 9); do curl -s http://shop.test/; donenginx: configuration file /etc/nginx/nginx.conf test is successfulapp-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 اضافه کنیم:
grep -E '^worker_processes' /etc/nginx/nginx.confnprocsed -i 's|^upstream backend {|upstream backend {\n zone backend 64k;|' /etc/nginx/sites-available/shop.testhead -n 3 /etc/nginx/sites-available/shop.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1for i in $(seq 9); do curl -s http://shop.test/; doneworker_processes auto;4upstream backend { zone backend 64k; server 127.0.0.1:9411;nginx: configuration file /etc/nginx/nginx.conf test is successfulapp-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 (پیشفرض ۱) سهم نسبی هر عضو است:
sed -i 's| server 127.0.0.1:9411;| server 127.0.0.1:9411 weight=3;|' /etc/nginx/sites-available/shop.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1for i in $(seq 10); do curl -s http://shop.test/; done | sort | uniq -cnginx: 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:
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; }}EOFtest_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 nginxsleep 1echo "=== round robin"test_busysed -i 's|^ zone backend 64k;| zone backend 64k;\n least_conn;|' /etc/nginx/sites-available/shop.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1echo "=== least_conn"test_busynginx: configuration file /etc/nginx/nginx.conf test is successful=== round robinapp-9413 GET /app-9411 GET /app-9412 GET /app-9413 GET /app-9412 GET /slowapp-9411 GET /slownginx: configuration file /etc/nginx/nginx.conf test is successful=== least_connapp-9413 GET /app-9413 GET /app-9413 GET /app-9413 GET /app-9412 GET /slowapp-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 فرستاده شود:
sed -i 's|^ least_conn;| ip_hash;|' /etc/nginx/sites-available/shop.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1for 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)"donenginx: configuration file /etc/nginx/nginx.conf test is successful127.0.0.1 -> app-9413, again -> app-9413127.0.0.2 -> app-9413, again -> app-9413127.0.0.3 -> app-9413, again -> app-9413127.0.0.4 -> app-9413, again -> app-9413127.0.1.1 -> app-9411, again -> app-9411127.0.2.1 -> app-9412, again -> app-9412127.0.3.1 -> app-9413, again -> app-9413127.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:
sed -i 's|^ ip_hash;| hash $remote_addr consistent;|' /etc/nginx/sites-available/shop.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1for 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)"donenginx: configuration file /etc/nginx/nginx.conf test is successful127.0.0.1 -> app-9413, again -> app-9413127.0.0.2 -> app-9411, again -> app-9411127.0.0.3 -> app-9411, again -> app-9411127.0.0.4 -> app-9412, again -> app-9412127.0.0.5 -> app-9413, again -> app-9413127.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”حالا مهمترین مزیت چند نسخه: تحمل خرابی. به هر عضو میگوییم «اگر ۲ بار در ۱۰ ثانیه جواب نداد، ۱۰ ثانیه کنار بگذارش»، و بعد سرور ۹۴۱۲ را خاموش میکنیم:
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; }}EOFnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1: > /var/log/nginx/error.logsystemctl stop lx-app@9412for i in $(seq 8); do curl -s -w ' (HTTP %{http_code})\n' http://shop.test/ | tr -d '\n'; echo; doneecho "--- 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 successfulapp-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 کوتاهش کردیم.)
اگر همه خراب باشند چه؟
systemctl stop lx-app@9411 lx-app@9413: > /var/log/nginx/error.logcurl -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@9413HTTP 502connect() 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، نسخهی جدید را رویش نصب کن، برگردانش، و سراغ بعدی برو.
sleep 1sed -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.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1echo "=== normal:"for i in 1 2 3 4; do curl -s http://shop.test/; doneecho "=== 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.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1for i in 1 2 3 4; do curl -s http://shop.test/; doneecho "=== 9412 stopped too:"systemctl stop lx-app@9412for i in 1 2 3 4; do curl -s http://shop.test/; donesystemctl start lx-app@9412sed -i 's| down;|;|' /etc/nginx/sites-available/shop.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxnginx: 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 successfulapp-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 را در یک لاگ جدا مینویسیم:
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';EOFsed -i 's|^ server_name shop.test;| server_name shop.test;\n access_log /var/log/nginx/lb.log lb;|' /etc/nginx/sites-available/shop.testnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1: > /var/log/nginx/error.logsystemctl stop lx-app@9412for i in 1 2 3; do curl -s -o /dev/null http://shop.test/; donesystemctl start lx-app@9412sleep 1curl -s -o /dev/null http://shop.test/crashcurl -s -o /dev/null -X POST http://shop.test/crashcat /var/log/nginx/lb.logecho "--- error.log:"grep -oE 'upstream prematurely closed connection|Connection refused' /var/log/nginx/error.log | sort | uniq -cnginx: configuration file /etc/nginx/nginx.conf test is successfulGET / -> 200 upstream=[127.0.0.1:9411] upstream_status=[200] time=0.002GET / -> 200 upstream=[127.0.0.1:9412, 127.0.0.1:9411] upstream_status=[502, 200] time=0.000, 0.002GET / -> 200 upstream=[127.0.0.1:9411] upstream_status=[200] time=0.001GET /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.001POST /crash -> 502 upstream=[127.0.0.1:9411] upstream_status=[502] time=0.001--- error.log: 1 Connection refused 4 upstream prematurely closed connectionهر خط یک درخواست است:
GET /مستقیم به ۹۴۱۱ رفت: یک آدرس، یک وضعیت.GET /اول به ۹۴۱۲ (خاموش) رفت و502گرفت (Nginx خطای اتصال را در$upstream_statusبا502نشان میدهد)، بعد همان درخواست به ۹۴۱۱ رفت و200. کاربر200دید. دو زمان هم داریم، یکی برای هر تلاش.GET /مستقیم به ۹۴۱۱.GET /crash: اپ وسط درخواست اتصال را بست (در error log:upstream prematurely closed connection؛ چهار بار، سه تا برای اینGETو یکی برایPOSTبعدی). Nginx چونGETخودتوان (idempotent؛ تکرارش بیخطر) است، آن را به ۹۴۱۲، ۹۴۱۱ و حتی ۹۴۱۳ (عضوbackup) داد و همه همان کار را کردند؛ نتیجهی نهایی502. این خطر هم دارد: درخواستی که سرور را از کار میاندازد، با تلاش دوباره همهی سرورها را میزند.proxy_next_upstream_tries 2;تعداد تلاشها را محدود میکند.POST /crash: فقط یک تلاش، بعد502.
این تفاوت GET و POST عمدی است: Nginx از نسخهی ۱.۹.۱۳ درخواستهای غیرخودتوان (non-idempotent؛ POST، LOCK، PATCH) را اگر به سرور رسیده باشند، دوباره به سرور دیگر نمیفرستد، چون شاید سرور اول کار را انجام داده و فقط جواب را نفرستاده (مثلاً پرداخت ثبت شده ولی پاسخ گم شده؛ تکرارش یعنی پرداخت دوبار). اگر مطمئنی تکرار بیخطر است، proxy_next_upstream error timeout non_idempotent; آن را مجاز میکند. خطای «اتصال رد شد» (سرور خاموش) برای POST هم تکرار میشود، چون درخواست اصلاً به سرور نرسیده است.
یک نکتهی دیگر: وضعیت «این سرور خراب است» در حافظهی workerها نگه داشته میشود. بدون zone، هر worker جدا باید خودش ۲ بار شکست بخورد تا سرور را کنار بگذارد (پس کاربرهای بیشتری خطای اول را تجربه میکنند). با zone شمارش بین همه مشترک است.
جدول مرجع
Section titled “جدول مرجع”روشهای پخش (داخل 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.
اشتباهات رایج
Section titled “اشتباهات رایج”۱) backup همراه با ip_hash یا hash
Section titled “۱) backup همراه با ip_hash یا hash”cp /etc/nginx/sites-available/shop.test /root/shop.test.baksed -i 's|^ zone backend 64k;| zone backend 64k;\n ip_hash;|' /etc/nginx/sites-available/shop.testnginx -tcp /root/shop.test.bak /etc/nginx/sites-available/shop.test2026/10/04 14:02:02 [emerg] 36339#36339: balancing method does not support parameter "backup" in /etc/nginx/sites-enabled/shop.test:6nginx: configuration file /etc/nginx/nginx.conf test failedروشهای hash باید برای هر کاربر همیشه یک عضو مشخص را انتخاب کنند و عضوی که «گاهی هست» با این منطق جور نیست. راهحل: با hash از backup استفاده نکن؛ عضو خراب خودش با max_fails کنار میرود و کاربرانش بین بقیه پخش میشوند.
۲) ip_hash پشت CDN یا proxy
Section titled “۲) ip_hash پشت CDN یا proxy”اگر جلوی Nginx یک CDN یا load balancer دیگر باشد، $remote_addr همیشه IP آن است (چند IP محدود)؛ پس ip_hash همه را به یک یا دو سرور میفرستد (همان چیزی که در مثال ۵ با 127.0.0.x دیدی). راهحل: IP واقعی را با ماژول realip بازیابی کن، یا hash روی یک کوکی.
۳) فراموش کردن پورت یا http://
Section titled “۳) فراموش کردن پورت یا http://”sed -i 's|proxy_pass http://backend;|proxy_pass backend;|' /etc/nginx/sites-available/shop.testnginx -tcp /root/shop.test.bak /etc/nginx/sites-available/shop.test2026/10/04 14:02:02 [emerg] 36342#36342: invalid URL prefix in /etc/nginx/sites-enabled/shop.test:14nginx: configuration file /etc/nginx/nginx.conf test failedproxy_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 نباشد) و با ۴۰ درخواست نشان بده سهم هر کدام چقدر است.
دیدن جواب
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; }}EOFnginx -t 2>&1 | tail -n 1 && systemctl reload nginxsleep 1for i in $(seq 40); do curl -s http://shop.test/ | cut -d' ' -f1; done | sort | uniq -cnginx: configuration file /etc/nginx/nginx.conf test is successful 20 app-9411 10 app-9412 10 app-9413جمع وزنها ۴ است: ۹۴۱۱ نصف درخواستها (۲۰ از ۴۰) و هر کدام از دو تای دیگر یک چهارم (۱۰).
دیپلوی بدون قطعی: در حالی که یک حلقه پشتسرهم به shop.test درخواست میفرستد، سه نسخه را یکییکی ریاستارت کن (هر بار: آن عضو را down کن، reload، ریاستارت اپ، برگرداندن عضو، reload). آخر سر نشان بده چند درخواست فرستاده شد و چند تا غیر از 200 بود.
دیدن جواب
: > /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"donewait "$loop"echo "requests: $(wc -l < /tmp/codes)"sort /tmp/codes | uniq -cgrep -c down /etc/nginx/sites-available/shop.testrestarted 9411restarted 9412restarted 9413requests: 300 300 2000هر سه نسخه یکییکی ریاستارت شدند و در همان مدت ۳۰۰ درخواست فرستاده شد، همه ۲۰۰. آخر سر هیچ down ای در فایل نمانده (0).
چرا هیچ درخواستی خطا نخورد؟ چون هیچوقت درخواستی به سرور در حال ریاستارت نرسید: down + reload اول آن را از گروه بیرون برد، و reload خودش هم بیقطعی است (workerهای قدیمی درخواستهای در جریان را تمام میکنند و workerهای جدید با تنظیم تازه شروع میکنند؛ درس ساختار تنظیمات). همین الگو را اسکریپتهای دیپلوی و ابزارهایی مثل Ansible روی چند سرور واقعی اجرا میکنند.
تمرین اصلی درس: سه نمونه از یک اپ را با Docker Compose بالا بیاور و پشت یک Nginx (آن هم در Compose) متعادل کن. Nginx باید روی 127.0.0.1:8097 میزبان باشد. بعد تعداد نسخهها را به ۵ برسان و نشان بده Nginx نسخههای جدید را هم میبیند. (برای اپ از خود ایمیج nginx:alpine استفاده کن که با return 200 اسم کانتینرش را برمیگرداند.)
دیدن جواب
اپ: هر نسخه با متغیر $hostname اسم کانتینر خودش را برمیگرداند:
# every replica answers with its own container hostnameserver { listen 80; default_type text/plain; return 200 "app $hostname\n";}load balancer: نکتهی اصلی resolve است (پایین توضیح داده شده):
# Docker's internal DNS server; re-check names every 5sresolver 127.0.0.11 valid=5s;
upstream app { zone app 64k; server app:80 resolve;}
server { listen 80; location / { proxy_pass http://app; }}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: - appdocker compose -p lx-lb up -d --quiet-pull 2>&1 | grep -v -e Creating -e Created -e Starting -e Networksleep 2docker compose -p lx-lb ps --format '{{.Name}}\t{{.Status}}'docker compose -p lx-lb exec lb nginx -vecho "=== 9 requests, 3 replicas:"for i in $(seq 9); do curl -s http://127.0.0.1:8097/; done | sort | uniq -cdocker compose -p lx-lb up -d --scale app=5 2>&1 | grep -c Startedsleep 6echo "=== 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 Startedlx-lb-app-1 Up 2 secondslx-lb-app-2 Up 2 secondslx-lb-app-3 Up 2 secondslx-lb-lb-1 Up 2 secondsnginx version: nginx/1.31.6=== 9 requests, 3 replicas: 3 app 245d92fe0f05 3 app df27638eaf67 3 app ff0808eb1b632=== 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 آن را ندارد.
آزمونک
Section titled “آزمونک”ده درخواست با weight=3 روی اولین سرور و weight پیشفرض روی دو سرور دیگر. سرور اول چند درخواست میگیرد؟
جمع وزنها ۵ است؛ ۳ از هر ۵ درخواست، یعنی ۶ از ۱۰.
بدون zone در upstream، چرا round robin ممکن است نامتوازن باشد؟
با worker_processes auto چند worker داری که هر کدام جدا میشمارند.
کاربرها همه از پشت یک اپراتور با IP های 5.6.7.x میآیند و ip_hash داری. چه اتفاقی میافتد؟
hash $remote_addr consistent کل IP را حساب میکند.
max_fails=2 fail_timeout=10s یعنی چه؟
Nginx متنباز تشخیص منفعل دارد: فقط درخواستهای واقعی شمرده میشوند.
اپ وسط پردازش یک POST اتصال را بست. Nginx چه میکند؟
برای GET همان درخواست به عضو بعدی میرود؛ برای POST فقط با proxy_next_upstream ... non_idempotent.
برای دیپلوی یکییکی سرورها، بهترین کار با upstream چیست؟
reload بدون قطع اتصالهاست و down عضو را بدون شکست کنار میگذارد.
در Compose با deploy.replicas: 3، چرا server app:80 resolve لازم است؟
resolve از Nginx ۱.۲۷.۳ در نسخهی متنباز هست و zone و resolver میخواهد.
جمعبندی
Section titled “جمعبندی”upstream NAME { server ...; }در contexthttp، و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 | تغییر تعداد نسخهها |