Platform & Tooling · پلتفرم و ابزار متوسطIntermediate ~72 دقیقه مطالعه~64 min read
Nginx، Reverse Proxy و لایهٔ وبNginx, Reverse Proxy & the Web Tier
از نقشهای لایهٔ وب و آناتومی خطبهخط nginx تا proxy_pass و forwarded headerها، load balancing، TLS و HTTP/3، cache، rate limit و ریشهیابی ۵۰۲/۵۰۴ — و اینکه همین مفاهیم چطور به Ingress و service mesh در Kubernetes نگاشت میشوند.From the roles of the web tier and nginx configuration anatomy line by line to proxy_pass and forwarded headers, load balancing, TLS and HTTP/3, caching, rate limiting and diagnosing 502/504 — and how the very same concepts map onto Kubernetes Ingress and a service mesh.
پیشنیاز:Prerequisites: لینوکس برای برنامهنویس بکاندLinux for the Backend Engineer
سرویس Spring Boot تو روی پورت ۸۰۸۰ بالا میآید و کار میکند. ولی هیچ کاربری در دنیای واقعی مستقیم به آن پورت وصل نمیشود. بین مرورگر کاربر و پروسهٔ JVM تو یک لایهٔ کامل مهندسی وجود دارد: چیزی که TLS را باز میکند، هدرها را مرتب میکند، فایلهای استاتیک را بدون بیدارکردن اپلیکیشن سرو میکند، ترافیک را بین چند نمونه پخش میکند، حمله را کند میکند و وقتی اپلیکیشن یک لحظه نفسکم میآورد، بهجای صفحهٔ خطای زشت، چیز معقولی نشان میدهد.
به این لایه میگوییم لایهٔ وب (web tier) و پرکاربردترین ابزار آن nginx است. در مصاحبههای بکاند این موضوع تقریباً همیشه میآید، چون مرز بین «برنامهنویس» و «مهندس» دقیقاً همینجاست: کسی که فقط کد مینویسد، nginx را یک فایل کانفیگ کپیشده از استکاورفلو میداند؛ کسی که سیستم را نگه میدارد، میداند چرا 502 گرفته، چرا آدرس IP کاربر در لاگ اشتباه است، و چرا آپلود فایل ۱۰ مگابایتی با خطای 413 برمیگردد.
این فصل تو را از صفر میبرد تا جایی که بتوانی یک کانفیگ production را خطبهخط توضیح بدهی، عیبیابی کنی و تصمیمهای معمارانهاش را دفاع کنی.
۱. نقشهای لایهٔ وب و توپولوژی آن؛ آناتومی nginx: مدل پروسهای و contextها (main/events/http/server/location).
۲. انتخاب server با listen/server_name و انتخاب location با قاعدهٔ exact ← ^~ ← regex ← prefix.
۳. root در برابر alias، try_files، error_page، تفاوت return با rewrite، متغیرها و map، و لاگی که عیبیابی میکند.
۴. Proxy به اپلیکیشن: proxy_pass و تلهٔ اسلش، X-Forwarded-* و اعتماد Spring Boot، timeout، buffering، WebSocket و keepalive.
۵. فایل استاتیک، gzip/brotli و کش مرورگر؛ سپس load balancing: الگوریتمها، health check، sticky session، blue-green و canary.
۶. TLS: زنجیرهٔ گواهی، HTTP/2 و HTTP/3، HSTS، OCSP stapling و mTLS؛ و کش proxy با zone، key، micro-caching و purge.
۷. محافظت (limit_req، limit_conn، slowloris، هدرهای امنیتی، WAF) و عملیات (reload، تحلیل لاگ، ریشهیابی 502/504).
۸. مقایسه با Apache httpd، HAProxy، Traefik و Envoy — و نگاشت همهچیز به Ingress و service mesh در Kubernetes.
اعداد و پیشفرضهای فصل با مستندات رسمی nginx در میانهٔ ۲۰۲۶ تطبیق داده شدهاند (stable روی 1.30.x، mainline روی 1.31.x)؛ چند پیشفرض مهم در 1.29.6 و 1.29.7 عوض شده که سر جای خودش علامت خورده است. نسخهٔ خودت را با nginx -v چک کن.
۱. جلوی سرویس چه چیزی میایستد و چرا
یک برج اداری را تصور کن. مهندسها در طبقههای بالا کار میکنند، ولی هیچ اربابرجوعی مستقیم بالا نمیرود. اول وارد لابی میشود: نگهبان کارت شناسایی را چک میکند (TLS و احراز اولیه)، پذیرش میگوید کدام طبقه (routing)، اگر پنج نفر پشتسرهم یک سؤال تکراری بپرسند منشی همان جواب آماده را میدهد بدون اینکه مهندس را صدا کند (cache)، اگر کسی بخواهد صد نفر را همزمان بفرستد جلویش گرفته میشود (rate limit)، و اگر یک طبقه در حال تعمیر باشد، اربابرجوع به طبقهٔ دیگری هدایت میشود (load balancing و failover).
اپلیکیشن تو مهندس طبقهٔ بالاست. nginx لابی است. طراحی خوب یعنی مهندس فقط کاری را بکند که واقعاً به تخصص او نیاز دارد.
هفت نقشی که لایهٔ وب بازی میکند
۱. Reverse proxy. «reverse» یعنی برعکسِ proxy معمولی: forward proxy نمایندهٔ کلاینت است (مرورگر درخواست را به آن میدهد تا از طرفش به اینترنت برود)، ولی reverse proxy نمایندهٔ سرور است — کلاینت فکر میکند با سرور اصلی حرف میزند، در حالی که با واسطی حرف میزند که پشتصحنه کار را به سرویسهای داخلی میسپارد. سودش این است که آدرس، تعداد و توپولوژی سرویسهای داخلی از بیرون پنهان میماند.
۲. Load balancer. وقتی سه نمونه بالا داری، یکی باید تصمیم بگیرد هر درخواست کجا برود و اگر نمونهای مُرد ترافیک را از او بردارد. چون این کار در لایهٔ ۷ (HTTP) انجام میشود، تصمیم میتواند به مسیر، هدر یا کوکی وابسته باشد، نه فقط IP و پورت.
۳. TLS terminator. رمزنگاری همینجا باز میشود: گواهی و کلید خصوصی روی nginx است و اپلیکیشن ترافیک ساده میگیرد. پس تمدید گواهی، انتخاب cipher و فعالکردن HTTP/3 یکبار در یک نقطه انجام میشود، نه در ده سرویس.
۴. سرور فایل استاتیک. JS، CSS، تصویر و فونت منطق ندارند؛ nginx آنها را با چند صدم هزینهٔ CPU یک thread جاوا میفرستد.
۵. Cache. پاسخهای تکراری را بدون زدن به اپلیکیشن پس میدهد؛ حتی یک ثانیه کش (micro-caching) بار یک endpoint پرترافیک را چند مرتبه کم میکند.
۶. Rate limiter و سپر اولیه. درخواستهای سیلآسا، بدنههای غولپیکر و اتصالهای کند را قطع میکند، قبل از مصرف thread یا connection پایگاهداده.
۷. نقطهٔ ورود WAF. فایروال اپلیکیشن وب الگوهای حملهٔ شناختهشده (SQL injection، path traversal، اسکنرها) را میبندد؛ چون همهٔ ترافیک از اینجا رد میشود، طبیعیترین نقطهٔ نصب همینجاست.
این هفت نقش اجباراً روی یک باینری نیستند: در استقرار واقعی معمولاً CDN و anti-DDoS در لبهٔ اینترنتاند، nginx در لبهٔ دیتاسنتر، و شاید یک sidecar کنار هر سرویس. اسمها فرق میکند، مفاهیم یکی است.
نمودار زیر مسیر یک درخواست از مرورگر تا JVM را نشان میدهد. | The path of one request from browser to JVM.
flowchart LR
U[Browser] -->|HTTPS| CDN[CDN / Edge]
CDN -->|HTTPS| NG[nginx: TLS, cache, limits]
NG -->|static files| FS[(Disk assets)]
NG -->|HTTP keepalive| A1[Spring Boot #1]
NG --> A2[Spring Boot #2]
NG --> A3[Spring Boot #3]
A1 --> DB[(Database)]
A2 --> DB
A3 --> DB
قاعدهٔ ساده: هر کاری که به دانش دامنه نیاز ندارد و برای همهٔ سرویسها یکسان است باید در لایهٔ وب باشد — TLS، فشردهسازی، rate limit عمومی، کش استاتیک، هدرهای امنیتی. هر کاری که به هویت کاربر، قواعد کسبوکار یا داده وابسته است باید در اپلیکیشن بماند — مجوزدهی ریزدانه، اعتبارسنجی دامنه، تصمیمهای تراکنشی. تیمهایی که منطق کسبوکار را در if و rewrite داخل nginx مینویسند، شش ماه بعد یک زبان برنامهنویسی بدون تست و بدون دیباگر روی دستشان میماند.
۲. آناتومی nginx: پروسهها و contextها
مدل پروسهای: چرا nginx با حافظهٔ کم، دهها هزار اتصال میگیرد
nginx یک پروسهٔ master و چند پروسهٔ worker میسازد. master فقط کارهای ممتاز را انجام میدهد (خواندن کانفیگ، بازکردن پورتهای زیر ۱۰۲۴ و فایلهای لاگ، مدیریت workerها) و کار واقعی در workerها انجام میشود که با کاربر کمدسترسی اجرا میشوند.
نکتهٔ اصلی: هر worker یک thread دارد و روی یک حلقهٔ رویداد کار میکند. یعنی بهجای «یک thread برای هر اتصال»، یک worker با فراخوانیهای غیرمسدودکنندهٔ سیستم (روی لینوکس epoll) هزاران اتصال را نگه میدارد و فقط وقتی سوکتی آماده شد سراغش میرود؛ اتصال بیکار تقریباً هیچ حافظهای مصرف نمیکند. همین ایده در سمت جاوا در فصلهای reactive-reactor و virtual-threads باز شده است.
نتیجهٔ مستقیم این طراحی: چون هر worker تکthreadی است، هر عملیاتی که آن thread را قفل کند تمام اتصالهای آن worker را میخواباند — خواندن فایل از دیسک کند، ماژولهای شخص ثالث سنگین، یا اسکریپتهای Lua با I/O همگام. برای فایلهای بزرگ از sendfile on; و در صورت نیاز aio threads; استفاده کن و هیچوقت منطق سنگین را داخل nginx نبر.
contextها: کانفیگ nginx یک درخت است، نه یک لیست
فایل nginx.conf از بلوکهای تودرتو ساخته میشود که به آنها context میگویند. سلسلهمراتب را حفظ کن، چون قاعدهٔ ارثبری بر همین اساس است:
- main (بیرونیترین سطح، بدون بلوک): تنظیمات کل پروسه —
user،worker_processes،pid،error_log،load_module. - events: تنظیم حلقهٔ رویداد —
worker_connections،use،multi_accept. - http: همهٔ چیزهای مربوط به HTTP —
include mime.types،log_format،gzip،upstream، و بلوکهایserver. - server: یک virtual host —
listen،server_name، گواهی TLS، و بلوکهایlocation. - location: قواعد مربوط به یک مسیر URI.
علاوه بر اینها stream (برای پروکسی TCP/UDP در لایهٔ ۴)، mail و upstream را هم داری.
قاعدهٔ ارثبری: یک directive از سطح بالاتر به پایینتر به ارث میرسد، مگر اینکه در سطح پایینتر دوباره تعریف شود. این ساده بهنظر میرسد ولی یک استثنای بدنام دارد که در بخش هدرهای امنیتی میبینی.
اسکلت یک nginx.conf واقعی:
user nginx;
worker_processes auto; # یکی به ازای هر هستهٔ CPU
worker_rlimit_nofile 65535; # سقف file descriptor برای هر worker
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 8192; # پیشفرض فقط ۵۱۲ است
use epoll; # روی لینوکس
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 75s; # پیشفرض ۷۵ ثانیه
server_tokens off; # نسخهٔ nginx را لو نده
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log main;
include /etc/nginx/conf.d/*.conf; # هر سایت در فایل خودش
}
worker_connections پیشفرض فقط ۵۱۲ است و شمارش آن شامل همهٔ سوکتهاست، نه فقط کلاینتها. در حالت reverse proxy هر درخواست معمولاً دو اتصال میگیرد: یکی از کلاینت و یکی به upstream. پس با ۴ worker و ۸۱۹۲ connection، سقف واقعی حدود ۱۶ هزار درخواست همزمان است — بهشرط اینکه worker_rlimit_nofile و ulimit -n سیستمعامل هم اجازه بدهند. اگر در error.log عبارت worker_connections are not enough را دیدی، مقصر همین است.
فرمانهایی که هر روز لازم داری
nginx -v # نسخه
nginx -V # نسخه + پرچمهای کامپایل و ماژولهای ساختهشده
nginx -t # فقط تست صحت کانفیگ (قبل از هر reload اجباری است)
nginx -T # تست + چاپ کل کانفیگ نهایی با همهٔ includeها
nginx -s reload # بارگذاری مجدد بدون قطع اتصالها
nginx -s quit # خاموشی نرم (اتصالهای باز تمام میشوند)
nginx -s reopen # بستن و بازکردن فایلهای لاگ (برای logrotate)
وقتی کانفیگ در ده فایل و سه لایه include پخش شده و رفتاری میبینی که هیچکجا ننوشتهای، nginx -T کل درخت نهایی را — همان چیزی که nginx واقعاً میبیند — چاپ میکند. با nginx -T | grep -n 'proxy_read_timeout' در سه ثانیه میفهمی کدام فایل مقدار را عوض کرده. این کار را قبل از حدسزدن انجام بده.
چون معماریاش رویدادمحور و غیرمسدودکننده است. هر worker یک thread دارد و با epoll (لینوکس) هزاران سوکت را همزمان پایش میکند؛ فقط سوکتی که داده دارد پردازش میشود. در مدل thread-per-connection هر اتصال بیکار یک stack چند صد کیلوبایتی و یک ورودی در جدول زمانبندی سیستمعامل میگیرد، پس ده هزار اتصال بیکار یعنی گیگابایتها حافظه و context switch سنگین. nginx برای اتصال بیکار عملاً فقط یک ساختار کوچک نگه میدارد. قید مهم این است که این برتری تا وقتی است که هیچ کار مسدودکنندهای داخل worker انجام نشود — یک I/O همگام روی یک worker، همهٔ اتصالهای آن worker را میخواباند.
۳. Virtual host: listen، server_name و انتخاب سرور
یک nginx میتواند صدها دامنه را روی همان IP و همان پورت ۴۴۳ سرو کند. به هر بلوک server یک virtual host میگویند و nginx برای هر درخواست دقیقاً یکی از آنها را انتخاب میکند.
الگوریتم انتخاب دو مرحله دارد:
۱. تطبیق آدرس و پورت با پارامترهای listen.
۲. بین سرورهایی که روی همان آدرس/پورت گوش میدهند، مقایسهٔ هدر Host درخواست با server_name به این ترتیب اولویت: نام دقیق ← طولانیترین wildcard که با * شروع میشود (*.example.com) ← طولانیترین wildcard که به * ختم میشود (mail.*) ← اولین regex که در ترتیب فایل کانفیگ تطبیق بخورد.
۳. اگر هیچکدام نخورد، سروری که با default_server علامت خورده (و اگر علامت نخورده باشد، اولین سرور آن آدرس/پورت) پاسخ میدهد.
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _; # نامی که هرگز با Host واقعی جور نمیشود
ssl_reject_handshake on; # از 1.19.4 — دستدادن TLS را رد کن
return 444; # کد داخلی nginx: بستن اتصال بدون پاسخ
}
server {
listen 443 ssl;
http2 on; # از 1.25.1 به بعد، directive مستقل
server_name shop.example.com www.shop.example.com;
ssl_certificate /etc/ssl/shop/fullchain.pem;
ssl_certificate_key /etc/ssl/shop/privkey.pem;
root /var/www/shop;
}
server_name _; هیچ معنای ویژهای در nginx ندارد؛ فقط یک نام نامعتبر است که هرگز با هدر Host واقعی تطبیق نمیخورد. چیزی که واقعاً «سرور پیشفرض» را میسازد پارامتر default_server روی listen است. اگر یک سرور پیشفرض تعریف نکنی، اولین بلوک server آن پورت نقش پیشفرض را میگیرد و اسکنرهای اینترنت با Host تصادفی به سایت اشتباهی میرسند — که یعنی نشت اطلاعات و آمار آلوده.
یک نکتهٔ نسخهای: تا قبل از nginx 1.25.1 مینوشتی listen 443 ssl http2;، ولی از آن نسخه به بعد پارامتر http2 در listen منسوخ شده و باید directiveی مستقل http2 on; را در سطح server یا http بگذاری — که مزیت عملی هم دارد: میتوانی HTTP/2 را برای یک virtual host روشن و برای دیگری خاموش کنی بدون درگیری با سوکت مشترک.
۴. location: قاعدهای که همه اشتباه میکنند
بعد از انتخاب server، nginx باید تصمیم بگیرد کدام بلوک location بر این URI حاکم است. اینجا جایی است که بیشترین باگهای «چرا این مسیر کانفیگ من را نمیبیند» متولد میشود، چون ترتیب نوشتن در فایل، ترتیب ارزیابی نیست.
پیشوندهای مجاز:
| نشانه | معنی | اولویت |
|---|---|---|
= |
تطبیق دقیق با کل URI | بالاترین؛ جستوجو بلافاصله تمام میشود |
^~ |
تطبیق پیشوندی، ولی «regex را بررسی نکن» | اگر طولانیترین پیشوند باشد، regexها نادیده گرفته میشوند |
~ |
regex حساس به بزرگی و کوچکی حروف | به ترتیب فایل بررسی میشود، اولین تطبیق برنده است |
~* |
regex بدون حساسیت به حروف | همان بالا |
| (بدون نشانه) | تطبیق پیشوندی ساده | فقط اگر هیچ regexی تطبیق نخورد |
@name |
location نامدار | فقط برای redirect داخلی؛ از بیرون قابل دسترسی نیست |
الگوریتم دقیق nginx:
۱. همهٔ locationهای پیشوندی را چک میکند و طولانیترین تطبیق را بهخاطر میسپارد.
۲. اگر آن تطبیق = بود، همانجا تمام؛ اگر ^~ بود، سراغ regex نمیرود.
۳. وگرنه regexها را به ترتیب ظاهرشدن در فایل بررسی میکند و اولین تطبیق برنده است.
۴. اگر هیچ regexی نخورد، به همان طولانیترین پیشوند برمیگردد.
نمودار زیر همین تصمیم را نشان میدهد. | The location selection decision.
flowchart TD
S[Request URI] --> E{Exact '=' match?}
E -- yes --> USE[Use it, stop]
E -- no --> P[Find longest prefix match]
P --> C{Prefix has '^~'?}
C -- yes --> USE
C -- no --> R[Scan regex locations in file order]
R --> M{Any regex matched?}
M -- yes --> UR[Use first matching regex]
M -- no --> UP[Use longest prefix]
مثال حلشده — این کانفیگ را بخوان و بعد جدول را ببین:
server {
location = /health { return 200 "ok\n"; } # A
location / { proxy_pass http://app; } # B
location /static/ { root /var/www; } # C
location ^~ /downloads/ { root /srv; } # D
location ~* \.(png|jpg)$ { expires 30d; root /var/www; } # E
}
| URI درخواستی | بلوک برنده | چرا |
|---|---|---|
/health |
A | تطبیق دقیق؛ جستوجو فوراً تمام میشود |
/api/orders |
B | فقط پیشوند / تطبیق میخورد |
/static/app.css |
C | طولانیترین پیشوند، و هیچ regexی .css را نمیگیرد |
/static/logo.png |
E | پیشوند C طولانیتر است ولی ^~ ندارد، پس regex بررسی و برنده میشود |
/downloads/a.png |
D | ^~ جلوی بررسی regex را میگیرد |
/health/live |
B | = فقط تطبیق دقیق است، نه پیشوندی |
ردیف سوم و چهارم دقیقاً همان چیزی است که در مصاحبه میپرسند: اگر میخواهی یک شاخهٔ کامل زیر دست regexهای عمومی نرود، باید ^~ بگذاری. صدها کانفیگ در طبیعت /static/ را تعریف کردهاند ولی تصویرهایشان از بلوک دیگری سرو میشود و کسی متوجه نمیشود تا روزی که هدرهای کش با هم جور درنیایند.
از نظر کارایی هم regex ارزان نیست: هر درخواست باید فهرست regexها را تا اولین تطبیق طی کند. برای مسیرهای پرتکرار — بهویژه health check — از location = /path استفاده کن تا جستوجو در همان قدم اول تمام شود.
۵. فایل، مسیر و بازنویسی: root، alias، try_files، return، rewrite
root در برابر alias
هر دو مسیر فایل روی دیسک را میسازند، ولی روششان فرق دارد و همین یک تفاوت، منبع بیپایان باگ است:
root: مسیر نهایی = مقدارroot+ کل URI.alias: مسیر نهایی = مقدارalias+ باقیماندهٔ URI بعد از پیشوند location (یعنی پیشوند جایگزین میشود).
location /images/ {
root /data/w3; # /images/cat.png -> /data/w3/images/cat.png
}
location /i/ {
alias /data/w3/images/; # /i/cat.png -> /data/w3/images/cat.png
}
اگر location به / ختم شود، مقدار alias هم باید به / ختم شود. location /i/ { alias /data/images; } مسیر /data/imagescat.png میسازد — یعنی ۴۰۴ مرموز. بدتر از آن، در نسخههای آسیبپذیر ترکیب location /i (بدون اسلش) با alias به path traversal معروف میانجامد. قاعدهٔ امن: تا جای ممکن root را ترجیح بده و alias را فقط وقتی بهکار ببر که واقعاً مسیر روی دیسک با URI همنام نیست — و آنوقت هر دو طرف را با اسلش ببند.
try_files: ستون فقرات سرو کردن SPA
try_files یک فهرست از گزینهها را بهترتیب امتحان میکند و اولین موردی را که وجود دارد سرو میکند؛ آخرین آرگومان یا یک URI برای redirect داخلی است یا یک کد وضعیت.
location / {
root /var/www/spa;
try_files $uri $uri/ /index.html; # الگوی استاندارد برای React/Angular/Vue
}
location /files/ {
root /srv;
try_files $uri =404; # اگر فایل نبود، صریحاً ۴۰۴ بده
}
index و error_page
index میگوید وقتی URI به دایرکتوری اشاره میکند چه فایلی سرو شود (index index.html index.htm;). error_page خطاها را به یک URI یا کد دیگر میبرد:
error_page 500 502 503 504 /50x.html;
location = /50x.html { root /usr/share/nginx/html; internal; }
# خطای backend را به یک JSON تمیز تبدیل کن
error_page 502 504 = @maintenance;
location @maintenance {
default_type application/json;
return 503 '{"error":"service_unavailable","retry_after":30}';
}
internal; یعنی آن location فقط از طریق redirect داخلی قابل دسترسی است، نه مستقیم از بیرون.
return در برابر rewrite — تفاوتی که باید بلد باشی
هر دو در ماژول rewrite هستند ولی کارشان یکی نیست:
returnفوراً پردازش را تمام میکند و یک کد (و اختیاراً یک URL یا متن) برمیگرداند. ارزان، صریح و قابلپیشبینی.rewriteURI را با یک regex بازنویسی میکند. اگر پرچمlastیاbreakبدهی، داخلی است و کاربر چیزی نمیبیند؛ اگرredirect(کد ۳۰۲) یاpermanent(کد ۳۰۱) بدهی، به کلاینت redirect میفرستد.
# درست: هدایت http به https با return — یک خط، بدون regex
server {
listen 80;
server_name shop.example.com;
return 301 https://$host$request_uri;
}
# گران و شکننده: همان کار با rewrite
# rewrite ^(.*)$ https://$host$1 permanent;
# rewrite جایی که واقعاً لازم است: تغییر ساختار URL قدیمی
rewrite ^/blog/([0-9]+)/(.*)$ /articles/$2?id=$1 last;
تفاوت پرچمها: last یعنی «با URI جدید دوباره دنبال location بگرد»، break یعنی «URI را عوض کن ولی در همین location بمان». استفادهٔ اشتباه از last داخل یک location که proxy_pass دارد میتواند حلقهٔ بازنویسی بسازد و به خطای rewrite or internal redirection cycle در لاگ ختم شود.
مستندات و تجربهٔ عملیاتی هر دو یک چیز میگویند: اگر کاری با return یا try_files انجام میشود، سراغ rewrite نرو و مطلقاً از بلوک if داخل location دوری کن. if در nginx معنایی غیرشهودی دارد (سند رسمی جامعه اسمش را «IfIsEvil» گذاشته) و ترکیبش با proxy_pass رفتار تعریفنشده میسازد. تقریباً هر if را میشود با map، try_files یا یک location جداگانه بازنویسی کرد.
۶. متغیرها، map و لاگی که واقعاً عیبیابی میکند
nginx دهها متغیر داخلی دارد: $uri (URI نرمالشدهٔ فعلی)، $request_uri (URI خام همراه query string)، $args، $host، $http_<name> برای هر هدر درخواست، $upstream_addr، $status و بسیاری دیگر.
map یک جدول تبدیل میسازد: از روی مقدار یک متغیر، متغیر جدیدی تولید میکند. map در سطح http تعریف میشود و تنبل است؛ یعنی فقط وقتی درخواست واقعاً به آن متغیر نیاز داشته باشد ارزیابی میشود.
http {
# کلاینتهای WebSocket را تشخیص بده
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# کش را برای کاربران لاگینشده دور بزن
map $http_cookie $skip_cache {
default 0;
"~*SESSION=" 1;
}
}
فرمت لاگی که باید در production داشته باشی
لاگ پیشفرض combined برای عیبیابی reverse proxy کافی نیست، چون هیچچیز دربارهٔ upstream نمیگوید. این نسخه را بگذار:
log_format upstream_json escape=json
'{"time":"$time_iso8601","remote_addr":"$remote_addr",'
'"request":"$request","status":$status,'
'"request_time":$request_time,"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_connect_time":"$upstream_connect_time",'
'"upstream_header_time":"$upstream_header_time",'
'"upstream_response_time":"$upstream_response_time",'
'"cache":"$upstream_cache_status","request_id":"$request_id"}';
access_log /var/log/nginx/access.log upstream_json buffer=64k flush=5s;
چهار متغیر زمانی کل عیبیابی را عوض میکنند: $request_time کل عمر درخواست از دید nginx است (شامل شبکهٔ کاربر)، $upstream_connect_time زمان برقراری TCP/TLS با اپلیکیشن، $upstream_header_time تا رسیدن اولین بایت هدر پاسخ («اپلیکیشن چقدر فکر کرد») و $upstream_response_time تا آخرین بایت بدنه.
اگر $upstream_response_time کوچک ولی $request_time بزرگ است، اپلیکیشن بیگناه است و کاربر روی موبایل با شبکهٔ کند نشسته. این تفکیک، بحثهای بیپایان بین تیم بکاند و تیم اینفرا را در یک دقیقه تمام میکند.
$request_id هم یک شناسهٔ تصادفی است که nginx برای هر درخواست میسازد؛ آن را با proxy_set_header X-Request-Id $request_id; به اپلیکیشن بفرست تا در MDC لاگ جاوا بنشیند و بتوانی از یک خط لاگ nginx مستقیم به همان درخواست در لاگ اپلیکیشن بپری (جزئیات در فصلهای logging و observability).
$upstream_response_time فقط زمان صرفشده در سرور بالادستی (اپلیکیشن) را میشمارد، از لحظهٔ برقراری اتصال تا دریافت آخرین بایت پاسخ. $request_time کل عمر درخواست از دید nginx است: خواندن درخواست از کلاینت، ارسال به upstream، دریافت پاسخ، و نوشتن کامل آن روی سوکت کلاینت. تفاوت این دو تقریباً همان تأخیر شبکهٔ کاربر و زمان انتقال بدنه است. در عمل: اگر هر دو بزرگ باشند، مشکل در اپلیکیشن است؛ اگر فقط $request_time بزرگ باشد، مشکل شبکهٔ کلاینت یا آپلود بزرگ یا کلاینت کند است؛ و اگر $upstream_connect_time بزرگ باشد، مشکل در سطح اتصال و صف backlog سرور بالادستی است، نه در منطق آن.
۷. Proxy کردن به اپلیکیشن: قلب ماجرا
proxy_pass و تلهٔ اسلش
proxy_pass درخواست را به یک آدرس بالادستی میفرستد؛ آدرس میتواند یک میزبان مستقیم باشد یا نام یک بلوک upstream:
upstream app {
server 10.0.1.11:8080;
server 10.0.1.12:8080;
}
server {
location / {
proxy_pass http://app;
}
}
و حالا مهمترین جزئیات نحوی کل فصل: اگر بعد از آدرس یک URI بنویسی (حتی فقط یک /)، nginx پیشوند location را با آن URI جایگزین میکند؛ اگر ننویسی، URI اصلی دستنخورده میرود.
| کانفیگ | درخواست کاربر | چیزی که به اپلیکیشن میرسد |
|---|---|---|
location /api/ { proxy_pass http://app; } |
/api/orders/7 |
/api/orders/7 |
location /api/ { proxy_pass http://app/; } |
/api/orders/7 |
/orders/7 |
location /api/ { proxy_pass http://app/v2/; } |
/api/orders/7 |
/v2/orders/7 |
اول: تیم context-path برنامه را /api میگذارد و در nginx هم proxy_pass http://app/; مینویسد؛ پیشوند دوبار حذف میشود و همهچیز ۴۰۴ میشود. دوم: در location مبتنی بر regex یا در location نامدار (@name) نوشتن بخش URI مجاز نیست و nginx با خطای proxy_pass cannot have URI part in location given by regular expression اصلاً بالا نمیآید. قاعدهٔ ذهنی: اسلش انتهایی یعنی «برش بزن»، نبودنش یعنی «دست نزن».
هدرها: اپلیکیشن پشت proxy کور است
اپلیکیشن دیگر IP کاربر را نمیبیند (IP خودِ nginx را میبیند)، نمیداند کاربر با HTTPS آمده (اتصال داخلی HTTP است) و دامنهٔ عمومی را نمیداند. اینها باید با هدر منتقل شوند:
location / {
proxy_pass http://app;
proxy_set_header Host $host; # دامنهٔ واقعی
proxy_set_header X-Real-IP $remote_addr; # IP کلاینت
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # زنجیرهٔ proxy
proxy_set_header X-Forwarded-Proto $scheme; # http یا https
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
پیشفرض nginx برای Host مقدار $proxy_host است، یعنی نام بلوک upstream نه دامنهٔ کاربر؛ پس بدون این بازنویسی، هر لینکی که اپلیکیشن بسازد غلط است. $proxy_add_x_forwarded_for یعنی «هرچه در هدر ورودی بود + IP کلاینت فعلی» تا زنجیره حفظ شود. $host نرمالشده است (بدون پورت، حروف کوچک، در نبود هدر از server_name پر میشود) و در ۹۹٪ موارد همان چیزی است که میخواهی، برخلاف $http_host خام. استاندارد RFC 7239 هم هدر واحد Forwarded: for=...;proto=...;host=... را تعریف کرده که Spring میفهمدش.
طرف جاوا: چطور Spring Boot به این هدرها اعتماد کند
اگر فقط هدر بفرستی و در اپلیکیشن کاری نکنی هیچ اتفاقی نمیافتد: request.getScheme() همچنان http میگوید، redirectها به http:// میروند و جریان OAuth2 با redirect_uri نامعتبر میشکند. تنظیم رسمی یک پروپرتی است:
# native: پشتیبانی بومی وبسرور جاسازیشده (RemoteIpValve در Tomcat)
# framework: فیلتر خود Spring (ForwardedHeaderFilter / ForwardedHeaderTransformer)
server.forward-headers-strategy=native
# فقط این آدرسها اجازه دارند هدرهای forwarded را تعیین کنند
server.tomcat.remoteip.internal-proxies=10\\.0\\.1\\.\\d{1,3}
NATIVE کار را به وبسرور جاسازیشده میسپارد (در Tomcat یعنی RemoteIpValve که پیشفرض شبکههای خصوصی مثل 10/8، 192.168/16 و 127/8 را قابلاعتماد میداند)، FRAMEWORK از فیلتر خود Spring استفاده میکند که هم X-Forwarded-* و هم Forwarded را میفهمد، و NONE — که پیشفرض است مگر روی پلتفرمهای ابری شناختهشده — هیچ پردازشی نمیکند.
X-Forwarded-For فقط یک هدر متنی است و هر کسی میتواند جعلش کند. دو حالت خطرناک داری. حالت اول: هدر میفرستی ولی استراتژی NONE است ← حلقهٔ redirect بین http و https و لینکهای غلط. حالت دوم: پردازش هدر را روشن میکنی ولی محدودهٔ proxy مورد اعتماد را باز میگذاری ← کاربر با X-Forwarded-For: 127.0.0.1 هر allowlist و rate limit مبتنی بر IP را دور میزند. قاعده: در بیرونیترین proxy قابلاعتماد، هدر ورودی را با proxy_set_header X-Forwarded-For $remote_addr; بازنویسی کن تا زنجیرهٔ جعلی دور ریخته شود، و در اپلیکیشن محدودهٔ proxyها را تنگ نگه دار. اگر خودِ nginx پشت یک CDN است، برای درستشدن لاگهای خودش هم به ماژول realip نیاز داری: set_real_ip_from 10.0.0.0/8; و real_ip_header X-Forwarded-For; (آدرس اصلی در $realip_remote_addr باقی میماند).
Timeout و buffering
proxy_connect_timeout 3s; # پیشفرض 60s — فقط برقراری اتصال TCP
proxy_send_timeout 30s; # پیشفرض 60s — بین دو عمل نوشتن
proxy_read_timeout 30s; # پیشفرض 60s — بین دو عمل خواندن
proxy_read_timeout سقف کل پاسخ نیست، سقف فاصلهٔ بین دو بایت متوالی است؛ پس پاسخ استریمی که هر ۱۰ ثانیه چیزی میفرستد هرگز timeout نمیشود. proxy_connect_timeout را چند ثانیه بگذار (اتصال داخلی یا آنی است یا اصلاً برقرار نمیشود) و proxy_read_timeout را کمی بیشتر از timeout داخلی خود سرویس بگذار تا خطای معنادار اپلیکیشن به کاربر برسد نه یک 504 بیشکل (فصل resilience).
با proxy_buffering on (پیشفرض) nginx پاسخ را تا حد بافرها (proxy_buffer_size برای هدر و proxy_buffers برای بدنه، با سرریز روی دیسک) میخواند، اتصال گران اپلیکیشن را آزاد میکند و بعد با سرعت کاربر میفرستد — همان چیزی که thread اپلیکیشن را از دست کلاینت کند نجات میدهد. ولی برای پاسخهای زنده (SSE، استریم لاگ) بافر یعنی کاربر تا آخر کار هیچ نمیبیند:
location /api/stream {
proxy_pass http://app;
proxy_buffering off; # بایتها را همان لحظه رد کن
gzip off; # فشردهسازی هم بافر میکند
proxy_read_timeout 1h;
}
اگر نمیخواهی برای هر endpoint استریمی یک location بسازی، اپلیکیشن میتواند هدر X-Accel-Buffering: no را در پاسخ بگذارد؛ nginx آن را میفهمد و فقط برای همان پاسخ بافر را خاموش میکند. قرینهاش proxy_request_buffering (پیشفرض on) است که بدنهٔ درخواست را کامل میگیرد؛ برای آپلود استریمی باید خاموشش کنی و بدانی که آنوقت اپلیکیشن مستقیم در معرض کلاینت کند است و retry به سرور بعدی هم ممکن نیست.
WebSocket و keepalive
WebSocket با یک درخواست HTTP دارای Upgrade: websocket شروع میشود و nginx این هدرها را بهصورت پیشفرض رد نمیکند:
map $http_upgrade $connection_upgrade { # در سطح http
default upgrade;
'' close;
}
location /ws/ {
proxy_pass http://app;
proxy_http_version 1.1; # از 1.29.7 پیشفرض شده
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s; # وگرنه هر ۶۰ ثانیه قطع میشود
proxy_send_timeout 3600s;
}
۹۰٪ خرابیهای WebSocket پشت nginx دو علت دارند: فراموشکردن این دو هدر (که به 400 Bad Request یا افت به polling ختم میشود) و proxy_read_timeout پیشفرض ۶۰ ثانیه که اتصال ساکت را دقیقاً یک دقیقه بعد میبندد و کاربر «قطعووصل مرموز» گزارش میدهد.
هر اتصال TCP تازه به اپلیکیشن یعنی هزینهٔ دستدادن و یک سوکت در حالت TIME_WAIT؛ با keepalive اتصالها در استخر میمانند:
upstream app {
server 10.0.1.11:8080;
server 10.0.1.12:8080;
keepalive 64; # اتصال بیکار در هر worker
keepalive_requests 1000; # پیشفرض
keepalive_timeout 60s; # پیشفرض
}
در نسخههای جدید، keepalive به upstream بهصورت پیشفرض روشن است (keepalive 32 local;)، مقدار پیشفرض proxy_http_version از 1.0 به 1.1 تغییر کرده و هدر Connection دیگر بهصورت پیشفرض به بالادست فرستاده نمیشود. در نسخههای قدیمیتر اگر proxy_http_version 1.1; و proxy_set_header Connection ""; را نمینوشتی، directiveی keepalive عملاً بیاثر بود و تیمها فکر میکردند keepalive دارند. اگر روی نسخهٔ قدیمیتری هستی آن دو خط را صریح بنویس.
server backend.internal:8080; هنگام بالا آمدن resolve میشود و تا reload بعدی همان IP میماند. در محیطهایی که IP سرویس عوض میشود (کانتینر، ابر، سرویس مدیریتشده) یعنی ترافیک به آدرس مرده میرود. دو راه درست: پارامتر resolve روی server بههمراه یک resolver تعریفشده (در نسخهٔ متنباز از 1.27.3 در دسترس است)، یا استفاده از متغیر در proxy_pass (set $up backend.internal; proxy_pass http://$up:8080;) که resolve را به زمان اجرا میبرد — با این حواسجمعی که شکل دوم بخش URI را هم تغییر میدهد و مسیر را باید دستی بسازی.
وجود یا نبودِ بخش URI بعد از آدرس. بدون URI، مسیر درخواست بدون تغییر به بالادست میرود. با URI — حتی اگر فقط یک اسلش باشد — nginx آن بخشی از URI را که با پیشوند location تطبیق خورده با آن URI جایگزین میکند؛ یعنی با location /api/ و proxy_pass http://app/; درخواست /api/orders به /orders تبدیل میشود. این تصمیم باید با context-path اپلیکیشن هماهنگ باشد وگرنه مسیر یا دوبار میآید یا حذف میشود. نکتهٔ تکمیلی: در locationهای مبتنی بر regex و locationهای نامدار نوشتن بخش URI مجاز نیست و nginx بالا نمیآید.
اپلیکیشن نمیداند اتصال اصلی کاربر HTTPS بوده، چون اتصال بین nginx و اپلیکیشن plain HTTP است؛ پس redirect مطلق را با http:// میسازد، لبه دوباره به HTTPS برمیگرداند و چرخه تکرار میشود. راهحل دو قسمت دارد: در nginx هدرهای X-Forwarded-Proto، X-Forwarded-Host و Host را درست بفرست، و در Spring Boot با server.forward-headers-strategy (مقدار native یا framework) پردازش این هدرها را روشن کن. حتماً باید محدودهٔ proxy مورد اعتماد را هم تنگ نگه داشت، وگرنه همین قابلیت به یک بردار جعل تبدیل میشود.
۸. فایل استاتیک، فشردهسازی و کش مرورگر
Spring Boot میتواند فایل استاتیک را از classpath:/static سرو کند، ولی هر درخواست یک thread و مسیر کامل فیلترها را مصرف میکند؛ سپردن این کار به nginx هم CPU آزاد میکند و هم کنترل دقیقتری روی هدرهای کش میدهد:
# داراییهای نسخهدار (نامشان hash دارد) — تا ابد کش شوند
location ^~ /assets/ {
root /var/www/app;
expires 1y;
add_header Cache-Control "public, immutable" always;
access_log off;
try_files $uri =404;
}
# HTML هرگز کش دائمی نشود، وگرنه کاربر نسخهٔ قدیمی را میبیند
location = /index.html {
root /var/www/app;
add_header Cache-Control "no-cache" always;
}
immutable به مرورگر میگوید حتی revalidation هم نکند و فقط وقتی امن است که نام فایل با hash محتوا نسخهدار شده باشد. کنار آن sendfile on; (کپی فایل از دیسک به سوکت داخل هسته)، tcp_nopush on; و open_file_cache مصرف CPU را باز هم پایین میآورند.
gzip on; # پیشفرض off است
gzip_comp_level 5; # پیشفرض 1؛ بالاتر از 6 معمولاً صرف نمیکند
gzip_min_length 1024; # فشردهسازی پاسخ کوچک ضرر است
gzip_vary on; # هدر Vary: Accept-Encoding برای کشهای میانی
gzip_proxied any;
gzip_types text/plain text/css application/json application/javascript
application/xml image/svg+xml;
text/html همیشه فشرده میشود و لازم نیست در فهرست بیاید؛ فایلهای از پیش فشرده (JPEG، MP4، ZIP) را هرگز اضافه نکن. brotli حجم متن را نسبت به gzip حدود ۱۵ تا ۲۰ درصد بیشتر کم میکند ولی در توزیع متنباز nginx جزو هسته نیست و باید ماژول ngx_brotli را بسازی یا از بستههای آماده بگیری (همین برای zstd هم صادق است). الگوی عملی: پاسخ داینامیک را با gzip بده و برای داراییهای استاتیک نسخهٔ .br و .gz را هنگام build بساز و از پیش فشرده سرو کن. یک هشدار امنیتی هم هست: حملات خانوادهٔ BREACH با اندازهگیری حجم پاسخ فشرده رازهای داخل بدنه (مثل توکن CSRF) را حدس میزنند، پس در پاسخهایی که هم دادهٔ تحت کنترل مهاجم دارند و هم راز، یا توکن را تصادفیسازی کن یا فشردهسازی را خاموش کن (فصل appsec-owasp).
سه دلیل. کارایی: nginx فایل را با sendfile مستقیم از کش صفحهٔ هسته به سوکت میفرستد، بدون کپی به فضای کاربر و بدون درگیرکردن thread اپلیکیشن. جداسازی منابع: یک موج دانلود دارایی نباید استخر thread یا اتصالهای پایگاهدادهٔ سرویس را مصرف کند. کنترل: هدرهای کش، فشردهسازی، سرو نسخهٔ از پیش فشرده و ETag را یکجا و یکنواخت برای همهٔ سرویسها تنظیم میکنی. در معماری واقعی لایهٔ بعدی CDN است؛ nginx مبدأ (origin) میماند و CDN از آن کش میگیرد.
۹. Load balancing: پخش بار و بقا در برابر خرابی
upstream app {
zone app_zone 64k; # حالت مشترک بین همهٔ workerها
least_conn; # الگوریتم انتخاب
server 10.0.1.11:8080 weight=3 max_fails=3 fail_timeout=10s max_conns=200;
server 10.0.1.12:8080;
server 10.0.1.13:8080 backup; # فقط وقتی همه از کار افتادند
# server 10.0.1.14:8080 down; # موقتاً از چرخش خارج
keepalive 64;
}
| الگوریتم | نحوهٔ کار | کِی مناسب است | خطر |
|---|---|---|---|
| round-robin (پیشفرض) | نوبتی، با درنظرگرفتن weight |
سرویس بدون حالت با درخواستهای هماندازه | با زمان پاسخ نامتوازن بد پخش میکند |
least_conn |
کمترین اتصال فعال | زمان پاسخ متغیر، درخواست طولانی | برای شمارش درست به zone نیاز دارد |
ip_hash |
hash روی IP کلاینت | چسبندگی ساده وقتی session در حافظه است | NAT و موبایل توزیع را کج میکنند |
hash key [consistent] |
hash روی هر کلیدی (کوکی، هدر، مسیر) | کش شاردشده، چسبندگی مبتنی بر کاربر | بدون consistent هر تغییر توپولوژی بازتوزیع بزرگ میسازد |
random two [least_conn] |
دو سرور تصادفی، بهترین را بردار | خوشههای بزرگ با چند load balancer | تقریبی است، نه بهینهٔ مطلق |
nginx چند پروسهٔ worker دارد. اگر zone تعریف نکنی، هر worker شمارش اتصالها و وضعیت سلامت سرورها را جداگانه نگه میدارد؛ یعنی least_conn با دید ناقص تصمیم میگیرد و max_conns عملاً در هر worker جدا اعمال میشود. برای هر upstream جدی یک zone تعریف کن.
Health check. در nginx متنباز فقط حالت غیرفعال (passive) را داری: nginx از روی نتیجهٔ درخواستهای واقعی قضاوت میکند و اگر در بازهٔ fail_timeout تعداد max_fails خطا رخ دهد، سرور برای همان مدت از چرخش خارج میشود و بعد دوباره امتحان میشود. حالت فعال (active) — یعنی صدازدن دورهای یک مسیر سلامت مستقل از ترافیک کاربر با directiveی health_check — بخشی از نسخهٔ تجاری است. در محیطهای کانتینری راهحل درست معمولاً این است که تصمیم سلامت را به ارکستراتور بسپاری: readiness probe نمونهٔ ناسالم را از endpointها حذف میکند و nginx فقط ترافیک را پخش میکند (فصل ms-gateway-discovery).
چرخهٔ عمر یک سرور بالادستی از دید health check غیرفعال. | Upstream server lifecycle under passive health checks.
stateDiagram-v2
[*] --> Healthy
Healthy --> Suspect: request error or timeout
Suspect --> Healthy: a request succeeds
Suspect --> Failed: max_fails reached within fail_timeout
Failed --> Probing: fail_timeout elapsed
Probing --> Healthy: probe request succeeds
Probing --> Failed: probe request fails
proxy_next_upstream error timeout http_502 http_503; # پیشفرض: error timeout
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
nginx بهصورت پیشفرض درخواستهای غیرidempotent مثل POST را وقتی که قبلاً به بالادست فرستاده شدهاند دوباره امتحان نمیکند و این محافظت درست است. اگر با پارامتر non_idempotent روشنش کنی، در حالت timeout ممکن است یک POST دوبار پردازش شود: کاربر یک سفارش میدهد، دو سفارش ثبت میشود. یا این پارامتر را نگذار، یا اپلیکیشن را با idempotency key واقعاً idempotent کن (الگویش در فصلهای api-design و messaging).
Sticky session و چرا بیحالتی بهتر است
اگر اپلیکیشن session را در حافظهٔ خودش نگه دارد، کاربر باید همیشه به همان نمونه برگردد:
upstream app {
server 10.0.1.11:8080 route=a;
server 10.0.1.12:8080 route=b;
sticky cookie srv_id expires=1h httponly secure samesite=lax path=/;
}
directiveی sticky با سه حالت cookie، route و learn از nginx 1.29.6 در نسخهٔ متنباز در دسترس است (پیش از آن فقط تجاری بود و تیمها از ip_hash یا hash $cookie_jsessionid consistent استفاده میکردند). با این حال قضاوت سنیور این است: چسبندگی یک وصله است، نه معماری. با sticky session، استقرار جدید یعنی از دست رفتن سبد خرید کاربران، مقیاسگیری افقی نامتوازن میشود و نمونهٔ داغ نمیتواند بار را واگذار کند. راه درست بیحالتکردن سرویس است: session در Redis یا در یک توکن امضاشده، تا هر نمونه بتواند هر درخواست را جواب بدهد (فصلهای spring-security و caching).
Blue-green و canary در لایهٔ proxy
split_clients "${remote_addr}${http_user_agent}" $pool {
5% app_canary;
* app_stable;
}
map $http_x_canary $pool_final { # ورود دستی تیم QA با یک هدر
default $pool;
"on" app_canary;
}
server {
location / {
proxy_pass http://$pool_final; # نام گروه upstream داخل متغیر
}
}
سه شرط عملی که canary را از «آزمایش روی کاربر» جدا میکند: معیار خودکار (نرخ خطا و صدک ۹۹ تأخیر canary باید جدا اندازهگیری شود، پس نام نسخه را در لاگ و متریک بگذار)، برگشت سریع (تغییر درصد باید یک reload باشد نه یک استقرار)، و پایداری تخصیص (کلید تقسیم باید از شناسهٔ پایدار کاربر ساخته شود وگرنه یک کاربر بین دو نسخه بالا و پایین میپرد). برای blue-green هم همین ساختار کافی است: دو گروه upstream و یک map که سوییچ میکند.
سه مشکل: پشت NAT شرکتی یا اپراتور موبایل هزاران کاربر یک IP دارند و بار نامتوازن میشود؛ IP کاربر موبایل عوض میشود و چسبندگی از بین میرود؛ با اضافه یا کم شدن یک سرور، نگاشت بخش بزرگی از کلاینتها جابهجا میشود. جایگزینها بهترتیب اولویت: بیحالتکردن سرویس و ذخیرهٔ session در یک ذخیرهگاه مشترک (بهترین)، sticky cookie که چسبندگی را به یک کوکی صریح میسپارد، یا hash $cookie_xyz consistent که با hash سازگار جابهجایی هنگام تغییر توپولوژی را کمینه میکند.
غیرفعال یعنی proxy فقط از روی درخواستهای واقعی قضاوت میکند؛ ارزان است ولی تا چند کاربر واقعی خطا نگیرند سرور خراب شناسایی نمیشود و پس از بهبود هم دیرتر برمیگردد. فعال یعنی proxy مستقل از ترافیک، مسیر سلامت را دورهای صدا میزند؛ تشخیص سریعتر است ولی بار اضافه میسازد و در nginx متنباز موجود نیست. در محیط کانتینری تصمیم سلامت را به ارکستراتور میسپارم که readiness probe دارد، و در nginx فقط max_fails/fail_timeout را برای واکنش سریع تنظیم میکنم. نکتهٔ مهم: مسیر سلامت باید سبک و بدون وابستگی سنگین باشد، وگرنه خودش عامل قطعی زنجیرهای میشود.
۱۰. TLS: خاتمهٔ امن در لبه
مبانی رمزنگاری، CA و زنجیرهٔ اعتماد در فصلهای crypto-foundations و pki-tls-gpg ساخته شدهاند؛ اینجا فقط زاویهٔ لایهٔ وب را میبینیم. مهمترین خطای پیکربندی این است: ssl_certificate باید fullchain باشد، یعنی گواهی سرور و بعد گواهیهای میانی در همان فایل. با گواهی برگِ تنها، مرورگرها که میانی را کش دارند مشکلی نشان نمیدهند ولی کلاینتهای جاوا، curl و اپلیکیشن موبایل خطای اعتبارسنجی میگیرند — باگی که بهشکل «فقط برای بعضیها خراب است» ظاهر میشود.
server {
listen 443 ssl;
listen [::]:443 ssl;
listen 443 quic reuseport; # HTTP/3 روی همان شمارهٔ پورت، روی UDP
http2 on;
http3 on; # پیشفرض on است
server_name shop.example.com;
ssl_certificate /etc/letsencrypt/live/shop/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; # با TLS1.3 ترجیح کلاینت بهتر است
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_session_cache shared:SSL:10m; # پیشفرض none یعنی هیچ resumptionی
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
server { # ۸۰ فقط برای هدایت و ACME
listen 80;
server_name shop.example.com;
location /.well-known/acme-challenge/ { root /var/www/acme; }
location / { return 301 https://$host$request_uri; }
}
HTTP/2 چند جریان را روی یک اتصال TCP multiplex میکند و با http2 on; روشن میشود. HTTP/3 همان ایده را روی QUIC/UDP میبرد تا head-of-line blocking در سطح TCP از بین برود؛ برای فعالسازی یک listen ... quic (ترجیحاً با reuseport و روی همان شمارهٔ پورت) و اعلام Alt-Svc لازم است، چون مرورگر اول با HTTPS معمولی میآید و بعد ارتقا میدهد.
Strict-Transport-Security به مرورگر میگوید «تا این مدت هرگز با http وصل نشو». این یک تعهد یکطرفه است: با max-age یکساله و includeSubDomains، مرورگرهایی که هدر را دیدهاند تا انقضا هیچ اتصال http را نمیپذیرند و زیردامنهٔ بدون TLS از دسترس خارج میشود. با مقدار کوتاه شروع کن و بعد بلندش کن؛ افزودن preload تصمیمی است که برگرداندنش ماهها طول میکشد.
OCSP stapling یعنی سرور خودش پاسخ تازهٔ «این گواهی باطل نشده» را از CA میگیرد و ضمیمهٔ دستدادن میکند تا مرورگر مجبور نباشد به CA وصل شود. ولی صادرکنندگان بزرگ رایگان از سال ۲۰۲۵ سرویس OCSP را تعطیل کردهاند و گواهیهای جدیدشان اصلاً آدرس OCSP ندارند؛ ابطال فقط از طریق CRL اعلام میشود. نتیجه: ssl_stapling on; روی چنین گواهیهایی صرفاً هشدار ssl_stapling ignored, no OCSP responder URL in the certificate در لاگ میگذارد. برای گواهیهای تجاری که همچنان OCSP دارند مفید است — و آنوقت resolver هم لازم داری چون nginx باید نام responder را resolve کند.
mTLS در هر دو جهت
# سمت کلاینت: از مراجعهکننده گواهی بخواه
ssl_client_certificate /etc/ssl/internal-ca.pem;
ssl_verify_client on; # optional هم ممکن است
location / {
proxy_pass http://app;
proxy_set_header X-Client-DN $ssl_client_s_dn; # هویت را به اپلیکیشن بده
}
# سمت بالادست: خودت را به سرویس داخلی معرفی کن
location /secure/ {
proxy_pass https://internal-app;
proxy_ssl_certificate /etc/ssl/nginx-client.pem;
proxy_ssl_certificate_key /etc/ssl/nginx-client.key;
proxy_ssl_trusted_certificate /etc/ssl/internal-ca.pem;
proxy_ssl_verify on; # پیشفرض off است
proxy_ssl_session_reuse on;
}
اگر بنویسی proxy_pass https://... و کاری نکنی، nginx گواهی بالادست را اصلاً بررسی نمیکند: هر کسی که بتواند در مسیر شبکهٔ داخلی بنشیند میتواند خودش را جای سرویس جا بزند و تو یک اتصال «امن» با مهاجم داری. هر جا TLS به بالادست داری، proxy_ssl_verify on; و proxy_ssl_trusted_certificate را هم بگذار. مدیریت و چرخش این گواهیها موضوع فصل key-management-secrets است.
چون تمرکز کلید و گواهی در یک نقطه، تمدید و چرخش را ساده میکند، انتخاب cipher و فعالسازی HTTP/2 و HTTP/3 یکجا انجام میشود و اپلیکیشن از هزینهٔ CPU دستدادن آزاد میشود. غلط است وقتی ترافیک بعد از لبه از شبکهٔ نامطمئن عبور میکند یا الزام انطباقی میگوید داده باید تا خود سرویس رمز بماند. در آن حالت یا در لبه باز و دوباره به بالادست رمز میکنی (proxy_pass https:// با proxy_ssl_verify on)، یا در سطح TCP بدون بازکردن عبور میدهی (passthrough) که آنوقت هیچ تصمیم لایهٔ ۷ — مسیریابی، کش، rate limit — ممکن نیست. در Kubernetes همین انتخاب معمولاً با mTLS خودکار service mesh حل میشود.
۱۱. Cache در لایهٔ proxy
proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api:20m
max_size=5g inactive=10m use_temp_path=off;
location /api/catalog/ {
proxy_pass http://app;
proxy_cache api;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 301 30s;
proxy_cache_valid 404 5s;
proxy_cache_lock on; # هجوم همزمان به یک کلید
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on; # کهنه را بده، پسزمینه تازه کن
proxy_cache_revalidate on;
proxy_no_cache $cookie_SESSION; # پاسخ کاربر لاگینشده را ذخیره نکن
proxy_cache_bypass $cookie_SESSION; # و از کش هم نخوان
add_header X-Cache-Status $upstream_cache_status always;
}
keys_zone حافظهٔ اشتراکی متادیتاست و max_size سقف فضای دیسک؛ inactive یعنی کلیدی که در این مدت خوانده نشود حذف میشود حتی اگر معتبر باشد؛ use_temp_path=off باعث میشود فایل موقت روی همان پارتیشن کش نوشته شود و کپی بین دستگاهها حذف شود. مقدار $upstream_cache_status یکی از MISS، HIT، EXPIRED، STALE، UPDATING، REVALIDATED یا BYPASS است؛ بدون گذاشتن آن در لاگ و هدر، اثبات اینکه کش کار میکند حدسوگمان است.
جریان تصمیم کش. | The proxy cache decision flow.
flowchart TD
R[Request] --> B{bypass or no_cache?}
B -- yes --> U[Go to upstream]
B -- no --> K[Build cache key]
K --> F{Fresh entry?}
F -- yes --> H[HIT: serve from cache]
F -- no --> S{Stale allowed?}
S -- yes --> ST[STALE now, refresh in background]
S -- no --> L{Another request filling it?}
L -- yes --> W[Wait on cache lock]
L -- no --> U
U --> SV[Store if cacheable, then serve]
nginx پاسخهای دارای Set-Cookie یا Cache-Control: no-store/private را ذخیره نمیکند. بعضی تیمها برای «بالابردن hit rate» با proxy_ignore_headers Cache-Control Set-Cookie; این محافظت را خاموش میکنند و بدترین باگ ممکن را میسازند: پاسخ حاوی session یا دادههای شخصی یک کاربر برای کاربر بعدی سرو میشود. اگر مجبور به نادیدهگرفتن هدرها شدی، کلید کش را با شناسهٔ کاربر بساز یا کش را فقط به مسیرهای صریحاً عمومی محدود کن.
برای endpointهای پرترافیک و «تقریباً یکسان برای همه» — صفحهٔ اول، لیست محصولات پربازدید، پاسخهای عمومی API — کش یک تا پنج ثانیهای همراه proxy_cache_lock on معجزه میکند: در ثانیهای که هزار درخواست میآید فقط یکی به اپلیکیشن میرسد. تازگی داده عملاً تفاوتی نمیکند ولی بار upstream چند مرتبه پایین میآید. سادهترین و ارزانترین بهینهسازی کل این فصل همین است.
Purge. directiveی proxy_cache_purge بخشی از نسخهٔ تجاری است. در متنباز سه راه داری: ماژول شخص ثالث ngx_cache_purge؛ حذف مستقیم فایل کش (مسیرش از روی MD5 کلید و الگوی levels ساخته میشود)؛ یا تمیزترین راه، نسخهدار کردن کلید کش با افزودن یک متغیر نسخه به proxy_cache_key تا با تغییر داده، کلید قدیمی بهطور طبیعی متروک شود.
هر لایه چیزی را نگه میدارد که در همان لایه ارزانتر و امنتر است. CDN داراییهای استاتیک و پاسخهای کاملاً عمومی را نزدیک کاربر نگه میدارد و تأخیر جغرافیایی و پهنایباند مبدأ را حذف میکند. nginx پاسخهای عمومی و کوتاهعمر مبدأ را نگه میدارد، بهویژه micro-caching که هجوم همزمان را جمع میکند و با پاسخ کهنه سرویس را در برابر خرابی بالادست زنده نگه میدارد. کش داخل اپلیکیشن برای چیزهایی است که کلیدشان به منطق دامنه یا هویت کاربر وابسته است و proxy نمیتواند بفهمدشان. قاعده: هرچه به کاربر نزدیکتر، عمومیتر و سادهتر؛ هرچه به داده نزدیکتر، خصوصیتر و دقیقتر. مسئلهٔ سخت در همهٔ لایهها ابطال است و به همین دلیل کش کوتاهعمر معمولاً از کش طولانی با منطق ابطال پیچیده امنتر است.
۱۲. محافظت: نگهداشتن حمله در لبه
# ۱۰ درخواست بر ثانیه به ازای هر IP (هر مگابایت ≈ ۱۶ هزار وضعیت)
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=conn:10m;
limit_req_status 429; # پیشفرض 503 است؛ 429 معنادارتر است
limit_conn_status 429;
location /api/ {
limit_req zone=perip burst=20 nodelay;
limit_conn conn 20;
proxy_pass http://app;
}
location = /auth/login {
limit_req zone=login burst=3; # بدون nodelay: کند کن، نه رد
proxy_pass http://app;
}
مدل ذهنی limit_req سطل سوراخدار است: درخواستها با نرخ ثابت پردازش میشوند، burst اندازهٔ صفی است که موج کوتاه را جذب میکند و هرچه از صف بزند بیرون با کد خطا رد میشود. بدون nodelay درخواستهای داخل صف تأخیر میخورند تا با نرخ مجاز پیش بروند؛ با nodelay فوراً پردازش میشوند ولی ظرفیت صف مصرف میشود. برای APIهای تعاملی nodelay تجربهٔ بهتری میدهد؛ برای ورود و مسیرهای حساس، تأخیر عمداً مطلوب است چون brute-force را واقعاً کند میکند. کلید را $binary_remote_addr بگذار (حافظهٔ کمتر) و قبل از اعمال واقعی، یک روز با limit_req_dry_run on; بالا بیاور و لاگها را ببین — تقریباً همیشه اولین عدد انتخابی خیلی سختگیرانه است.
client_max_body_size 10m; # پیشفرض فقط 1m — بیشتر یعنی 413
client_body_timeout 10s;
client_header_timeout 10s; # کند فرستادن هدر = slowloris
send_timeout 10s;
large_client_header_buffers 4 8k;
server_tokens off; # نسخه را از هدر Server و صفحات خطا بردار
حملهٔ slowloris یعنی مهاجم صدها اتصال باز میکند و هدرها را بایتبایت و خیلی کند میفرستد تا ظرفیت سرور را قفل کند. سه سلاح در برابرش: timeoutهای کوتاه بالا، limit_conn روی هر IP، و خود معماری رویدادمحور nginx که اتصال بیکار برایش تقریباً رایگان است — برعکس یک وبسرور thread-per-connection.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'" always;
قاعدهٔ ارثبری add_header استثنایی است: هدرهای سطح بالاتر فقط وقتی به ارث میرسند که در سطح پایینتر هیچ add_headerی وجود نداشته باشد. اگر در server چهار هدر امنیتی گذاشته باشی و در یک location یک add_header X-Cache ... اضافه کنی، آن location هر چهار هدر امنیتی را از دست میدهد — بیصدا و بدون خطا. راهحل: هدرهای مشترک را در یک فایل بگذار و در هر location که add_header دارد includeاش کن. پارامتر always هم لازم است تا هدر روی پاسخهای خطا (۴۰۴، ۵۰۰) هم برود.
برای فیلترکردن الگوهای حملهٔ شناختهشده، گزینههای رایج ModSecurity/Coraza با مجموعهقواعد OWASP CRS یا محصولات تجاریاند. دو نکتهٔ سنیور: WAF بدون تنظیم دقیق پر از false positive است و باید اول در حالت تشخیص اجرا شود؛ و WAF جایگزین کد امن نیست، لایهای اضافه است که برای وصلهٔ موقت آسیبپذیریهای افشاشده بسیار ارزشمند است (فصل appsec-owasp).
هر دو، با نقش متفاوت. در nginx یک سقف درشت و ارزان میگذارم که ترافیک سیلآسا را قبل از مصرف thread و اتصال پایگاهداده قطع کند؛ این لایه فقط IP و مسیر را میشناسد. در اپلیکیشن سهمیههای معنادار کسبوکار را اعمال میکنم — بهازای مشتری، پلن یا عملیات — که به هویت و داده وابستهاند. نکتهٔ مقیاسپذیری این است که شمارندهٔ nginx به هر نمونه محدود است: با پنج nginx سقف مؤثر پنج برابر میشود، و اگر سقف دقیق سراسری میخواهی باید شمارنده را مشترک کنی (مثلاً در Redis) که هزینهٔ تأخیر دارد. در هر دو لایه پاسخ باید 429 همراه Retry-After باشد تا کلاینت درست رفتار کند.
۱۳. عملیات: reload، عیبیابی و تنظیم
nginx -t && nginx -s reload # همیشه اول تست، بعد reload
systemctl reload nginx # همین کار را با سیگنال HUP انجام میدهد
هنگام reload، master کانفیگ جدید را میخواند و اگر معتبر بود workerهای تازه میسازد و به قدیمیها میگوید دیگر اتصال جدید نگیرند و بعد از تمامشدن کارشان بمیرند؛ نتیجه اینکه هیچ درخواستی قطع نمیشود. restart یعنی کشتن پروسه و بالا آوردن دوباره، یعنی یک بازهٔ کوتاه بدون سرویس — و فقط وقتی لازم است که چیزی خارج از کانفیگ عوض شده باشد: باینری جدید، load_module جدید، یا تغییر متغیرهای محیطی سرویس.
worker قدیمی تا بستهشدن آخرین اتصالش زنده میماند. با WebSocket یا SSE یعنی بعد از هر reload یک نسل worker با کانفیگ قدیمی باقی میماند و با چند reload در روز، دهها پروسهٔ «shutting down» و مصرف حافظهٔ زیاد خواهی داشت. worker_shutdown_timeout 30s; سقف میگذارد تا اتصالهای طولانی در نهایت قطع شوند — و کلاینتهای استریم باید منطق اتصال مجدد داشته باشند.
| نشانه | معنی | علتهای محتمل و اقدام |
|---|---|---|
502 Bad Gateway |
پاسخ نامعتبر یا اتصال ناموفق به بالادست | سرویس بالا نیست یا پورت غلط است؛ اتصال keepalive که بالادست زودتر بسته (timeout بیکاری اپلیکیشن باید بیشتر از nginx باشد)؛ هدر پاسخ بزرگتر از proxy_buffer_size؛ خطای TLS به بالادست |
504 Gateway Time-out |
بالادست بهموقع جواب نداد | کوئری کند، قفل، اشباع استخر thread یا connection؛ اول علت کندی را بررسی کن، بعد proxy_read_timeout |
499 |
کلاینت قبل از پاسخ قطع کرد | کاربر صفحه را بست یا timeout سمت کلاینت کوتاهتر است؛ اغلب نشانهٔ کندی بالادست |
413 |
بدنه از سقف بزرگتر است | client_max_body_size را با سقف multipart اپلیکیشن هماهنگ کن |
400 Request Header Or Cookie Too Large |
هدر یا کوکی بزرگ | large_client_header_buffers؛ معمولاً نشانهٔ توکن بیش از حد بزرگ |
worker_connections are not enough |
سقف اتصال | worker_connections و worker_rlimit_nofile را بالا ببر |
دو دستور اول در هر حادثه: tail -f /var/log/nginx/error.log و یک شمارش سریع توزیع کدهای وضعیت از لاگ دسترسی. اگر لاگ را JSON نوشته باشی، با jq میتوانی بر اساس upstream_addr گروهبندی کنی و ببینی خطا از یک نمونه میآید یا از همه.
برای رصد، ماژول stub_status شمارندههای پایه (اتصالهای فعال، پذیرفتهشده، رسیدگیشده، کل درخواستها) را میدهد:
location = /nginx_status {
stub_status;
allow 10.0.0.0/8;
deny all;
}
عدد کلیدی این است که اگر accepted و handled با هم فرق کنند، nginx به سقف منابع خورده و اتصال دور ریخته است. exporterهای Prometheus همین خروجی یا لاگ را به متریک تبدیل میکنند (فصل observability). در سطح سیستم هم net.core.somaxconn، ulimit -n و محدودهٔ پورتهای موقت معمولاً قبل از خود nginx گلوگاه میشوند — که دلیل دیگری است برای روشن نگهداشتن keepalive به بالادست (فصل linux-essentials).
اول error.log را میخوانم چون nginx علت دقیق را همانجا مینویسد: connect() failed (111: Connection refused) یعنی سرویس بالا نیست یا پورت اشتباه است؛ upstream prematurely closed connection یعنی اپلیکیشن اتصال را بست — کلاسیکترین حالتش رقابت keepalive وقتی timeout بیکاری اپلیکیشن از nginx کمتر است، یا crash و restart؛ upstream sent too big header یعنی باید proxy_buffer_size را بزرگ کنم. دوم، از خود ماشین nginx مستقیم به بالادست میزنم (curl -v http://10.0.1.11:8080/actuator/health) تا معلوم شود مشکل شبکه/فایروال است یا اپلیکیشن. سوم، لاگ را بر اساس upstream_addr گروه میکنم تا ببینم همهٔ نمونهها خطا میدهند یا یکی. چهارم، اگر همه خطا میدهند سراغ اپلیکیشن میروم: GC طولانی، اشباع استخر اتصال پایگاهداده، یا پرشدن صف accept. اشتباه رایج این است که کسی بدون خواندن error.log فقط timeoutها را زیاد میکند؛ این کار 502 را به 504 تبدیل میکند، نه چیز دیگری.
با nginx -t صحت کانفیگ را تأیید میکنم و بعد nginx -s reload میزنم؛ master کانفیگ جدید را میخواند، workerهای جدید میسازد و به قدیمیها خاموشی نرم اعلام میکند، پس اتصالهای در جریان نمیشکنند. سه نکتهٔ عملی: worker_shutdown_timeout را تنظیم میکنم تا اتصالهای طولانی مثل WebSocket نسل قدیمی را جاودانه نکنند؛ اگر باینری یا ماژول عوض شده باشد reload کافی نیست و باید ارتقای باینری زنده (سیگنالهای USR2، WINCH، QUIT) یا یک استقرار غلتان انجام شود؛ و کانفیگ را در کنترل نسخه نگه میدارم تا برگشت یک git revert و یک reload باشد (فصل cicd-pipelines).
۱۴. انتخاب ابزار، و نگاشت به دنیای Kubernetes
| ابزار | نقطهٔ قوت | ضعف / هزینه | کِی انتخابش کن |
|---|---|---|---|
| nginx | همهکاره: وبسرور + proxy + کش؛ کممصرف، پایدار، مستندات عالی | کانفیگ ایستا (تغییر = reload)؛ health check فعال و چند قابلیت دیگر تجاری | لبهٔ کلاسیک، سرو استاتیک، ترکیب proxy و کش در یک باینری |
| Apache httpd | ماژولهای فراوان، .htaccess، سازگاری با میراث |
مدل پروسه/thread سنگینتر در همزمانی بالا | نگهداری سامانههای قدیمی یا وابستگی به ماژول خاص |
| HAProxy | تخصص در load balancing لایهٔ ۴ و ۷؛ health check و آمار بسیار قوی در نسخهٔ رایگان | وبسرور نیست؛ کش و سرو فایل ندارد | جایی که فقط توزیع بار و پایداری مهم است |
| Traefik | پیکربندی خودکار از روی برچسب کانتینر و منابع Kubernetes؛ ACME داخلی | سربار و انتزاع بیشتر هنگام دیباگ | محیط داینامیک کانتینری با تغییر مداوم سرویسها |
| Envoy | API داینامیک (xDS)، مشاهدهپذیری عمیق، gRPC و circuit breaker درجهیک | پیچیدگی بالا؛ معمولاً به control plane نیاز دارد | data plane سرویسمش و gatewayهای پیشرفته |
| Caddy | HTTPS خودکار با کمترین کانفیگ | اکوسیستم و ابزار عملیاتی کوچکتر | سرویسهای کوچک و داخلی که سادگی اولویت است |
معیار واقعی انتخاب معمولاً کارایی نیست — در بار واقعی اختلاف این ابزارها در برابر زمان پاسخ اپلیکیشن ناچیز است — بلکه مدل پیکربندی است. اگر توپولوژی ایستاست و ماشینها اسم ثابت دارند، فایل ایستا (nginx یا HAProxy) ساده و قابل بازبینی است؛ اگر سرویسها هر چند دقیقه جابهجا میشوند، ابزاری میخواهی که کانفیگ را از منبع حقیقت بسازد. تصمیم را بر اساس نرخ تغییر توپولوژی بگیر، نه بر اساس نمودار بنچمارک.
در Kubernetes دقیقاً همین لایهٔ وب وجود دارد، فقط اسمها عوض میشوند: Ingress/Gateway یک شیء اعلانی است که میگوید این میزبان و این مسیر به کدام سرویس برود و خودش هیچ کاری نمیکند؛ controller پروسهای است که آن شیء را میخواند و یک proxy واقعی (nginx، Envoy، Traefik، HAProxy) را پیکربندی میکند — یعنی همان server و location و upstream این فصل، منتها تولیدشده از روی YAML؛ و service mesh همان قابلیتها (mTLS، تلاش مجدد، circuit breaker، مسیریابی درصدی، متریک) را به ترافیک بین سرویسها میآورد، معمولاً با یک proxy کنار هر pod یا در سطح گره.
نگاشت مفاهیم این فصل به Kubernetes. | Mapping this chapter's concepts onto Kubernetes.
flowchart LR
I[Ingress / Gateway object] --> C[Controller]
C -->|generates config| P[Proxy data plane]
P --> S1[Service A pods]
P --> S2[Service B pods]
S1 -->|mTLS, retries| M[Mesh sidecar]
M --> S2
پروژهٔ Kubernetes اعلام کرده کنترلر پرکاربرد ingress-nginx در پایان مارس ۲۰۲۶ بازنشسته میشود: بدون انتشار جدید، بدون رفع باگ و بدون وصلهٔ امنیتی. جانشین برنامهریزیشده هم به بلوغ نرسید و کنار گذاشته شد، و توصیهٔ رسمی مهاجرت به Gateway API یا یک کنترلر نگهداریشدهٔ دیگر است. Gateway API مدل نقشی روشنی دارد: GatewayClass و Gateway در اختیار تیم پلتفرم و HTTPRoute/GRPCRoute در اختیار تیم اپلیکیشن — چیزی که در Ingress قدیمی با انبوهی annotation اختصاصی هر vendor حل میشد. اگر امروز روی ingress-nginx هستی، مهاجرت یک کار برنامهریزیشده است، نه یک مورد در backlog.
Ingress (و جانشین مدرنش Gateway API) فقط یک شیء اعلانی در API کوبرنتیز است که قواعد مسیریابی ورودی را توصیف میکند و بهتنهایی هیچ بستهای را جابهجا نمیکند. Ingress controller پیادهسازی واقعی است: پروسهای که آن اشیاء را میخواند و یک proxy — که غالباً همان nginx یا Envoy است — را پیکربندی میکند؛ یعنی معادل خودکارِ همان فایل کانفیگی که در این فصل با دست نوشتیم. service mesh مسئلهٔ دیگری را حل میکند: ترافیک سرویسبهسرویس داخل خوشه با mTLS خودکار، تلاش مجدد، circuit breaker، مسیریابی درصدی و متریک یکنواخت. اینها مکملاند نه جایگزین: gateway ترافیک ورودی را میگیرد و mesh ترافیک داخلی را مدیریت میکند، و بیشتر پیادهسازیهای امروزی هر دو نقش را با یک data plane مشترک ارائه میدهند.
از نظر فنی میتواند، ولی چیزهایی را از دست میدهی که هزینهاش زیاد است. با proxy: TLS و تمدید گواهی در یک نقطه متمرکز میشود؛ چند نمونه پشت یک آدرس قرار میگیرند و استقرار بدون قطعی ممکن میشود؛ فایل استاتیک و پاسخهای کششده بدون مصرف thread اپلیکیشن سرو میشوند؛ rate limit، سقف اندازهٔ بدنه و هدرهای امنیتی یکنواخت اعمال میشوند؛ و اپلیکیشن هرگز مستقیم در معرض اینترنت نیست. استثنا سرویسهای کاملاً داخلی و کوچک است، یا محیطهایی که ارکستراتور و mesh همین وظایف را انجام میدهند — در آن حالت proxy همچنان هست، فقط تو مستقیم پیکربندیاش نمیکنی.
چیتشیت
| موضوع | مقدار / دستور | یادداشت |
|---|---|---|
| تست و چاپ کانفیگ | nginx -t / nginx -T |
دومی کل کانفیگ نهایی را چاپ میکند |
| بارگذاری بدون قطعی | nginx -s reload |
همیشه بعد از -t |
| ماژولهای ساختهشده | nginx -V |
وجود realip، stub_status و … را چک کن |
| اولویت location | = ← ^~ ← regex ← پیشوند |
ترتیب فایل فقط برای regexها مهم است |
root در برابر alias |
root: مسیر + کل URI / alias: جایگزینی پیشوند | alias را با اسلش ببند |
| هدرهای proxy | Host، X-Real-IP، X-Forwarded-For/Proto |
Host پیشفرض $proxy_host است |
| سمت Spring | server.forward-headers-strategy=native|framework |
پیشفرض none |
| timeoutهای proxy | connect/send/read، همه پیشفرض 60s |
read یعنی فاصلهٔ بین دو بایت |
| سقف بدنه | client_max_body_size پیشفرض 1m |
وگرنه 413 |
| اتصالهای worker | worker_connections پیشفرض 512 |
هر درخواست پروکسیشده ۲ اتصال |
| keepalive بالادست | keepalive 32 local; (پیشفرض جدید) |
در نسخههای قدیمی صریح تنظیم کن |
| محدودیت نرخ | limit_req_zone + limit_req burst= nodelay |
با limit_req_dry_run شروع کن |
| کش | proxy_cache_path + proxy_cache + proxy_cache_valid |
وضعیت را با $upstream_cache_status ببین |
| TLS | fullchain.pem + ssl_protocols TLSv1.2 TLSv1.3 |
ssl_session_cache پیشفرض none |
| TLS به بالادست | proxy_ssl_verify on; |
پیشفرض off است |
| WebSocket | Upgrade + Connection + proxy_read_timeout بلند |
با map $http_upgrade |
| استریم و SSE | proxy_buffering off; یا هدر X-Accel-Buffering: no |
gzip off هم لازم است |
| زمانسنجی | $request_time در برابر $upstream_*_time |
تفکیک تقصیر شبکه از اپلیکیشن |
لایهٔ وب همان جایی است که مهندسی «سیستم» از برنامهنویسی «سرویس» جدا میشود. nginx یک درخت کانفیگ با contextهای تودرتو است که با مدل رویدادمحور و تکthread در هر worker دهها هزار اتصال را ارزان نگه میدارد. server بر اساس listen و server_name انتخاب میشود و location با قاعدهٔ «دقیق ← ^~ ← regex ← پیشوند»، نه به ترتیب نوشتن. در proxy کردن، بود و نبود اسلش در proxy_pass مسیر را عوض میکند، هدرهای X-Forwarded-* باید هم فرستاده و هم در Spring با server.forward-headers-strategy پذیرفته شوند، و timeout و buffering و keepalive تعیین میکنند اپلیکیشن در برابر کلاینت کند چقدر مصون است. در پخش بار، zone برای درستی شمارندهها لازم است، health check فعال در نسخهٔ متنباز نیست و چسبندگی session یک وصله است در برابر معماری بیحالت. TLS در لبه متمرکز میشود، fullchain میخواهد، به بالادست باید با proxy_ssl_verify on تأیید شود و HSTS تعهدی است که برگشت ندارد. کش — بهویژه micro-caching با proxy_cache_lock — ارزانترین کاهش بار ممکن است، به شرط اینکه محافظت پیشفرض در برابر کشکردن پاسخهای شخصی را خاموش نکنی. limit_req و limit_conn و timeoutهای کوتاه حمله را در لبه نگه میدارند و add_header یک تلهٔ ارثبری خاموش دارد. در عملیات، nginx -t پیش از هر reload واجب است و ریشهیابی 502 همیشه از error.log شروع میشود نه از زیادکردن timeout. و در نهایت همین مفاهیم عیناً در Kubernetes با Gateway API و service mesh تکرار میشوند — کسی که این فصل را فهمیده باشد آنجا فقط دارد همان چیزها را با YAML مینویسد.
Your Spring Boot service comes up on port 8080 and works. But no real user ever connects to that port directly. Between the user's browser and your JVM process sits an entire engineering layer: something that unwraps TLS, fixes up headers, serves static files without waking the application, spreads traffic across instances, slows attackers down, and — when the app hiccups — returns something sane instead of an ugly stack trace.
We call this the web tier, and its most common tool is nginx. This topic shows up in almost every backend interview, because it is exactly where "programmer" ends and "engineer" begins. Someone who only writes code treats nginx as a config file copied from a forum. Someone who keeps systems alive knows why they got a 502, why the client IP in the logs is wrong, and why a 10 MB upload comes back as 413.
This chapter takes you from zero to the point where you can read a production config line by line, debug it, and defend its architectural choices.
- The roles of the web tier and its topology; nginx anatomy: the process model and contexts (main/events/http/server/location).
- Choosing a
servervialisten/server_name, and choosing alocationwith the exact ←^~← regex ← prefix rule. rootvsalias,try_files,error_page,returnvsrewrite, variables andmap, and a log format that actually debugs.- Proxying to the app:
proxy_passand the trailing-slash trap,X-Forwarded-*and making Spring Boot trust them, timeouts, buffering, WebSockets, keepalive. - Static files, gzip/brotli and browser caching; then load balancing: algorithms, health checks, sticky sessions, blue-green and canary.
- TLS: certificate chains, HTTP/2 and HTTP/3, HSTS, OCSP stapling and mTLS; plus proxy caching with zones, keys, micro-caching and purging.
- Protection (
limit_req,limit_conn, slowloris, security headers, WAF) and operations (reload, log analysis, diagnosing502/504). - Nginx vs Apache httpd vs HAProxy vs Traefik vs Envoy — and how all of it maps onto Kubernetes Ingress and a service mesh.
Numbers and defaults here match the official nginx documentation as of mid-2026 (stable on 1.30.x, mainline on 1.31.x); a few important defaults changed in 1.29.6 and 1.29.7 and are flagged where they matter. Check your own build with nginx -v.
1. What sits in front of the service, and why
Picture an office tower. Engineers work upstairs, but no visitor walks straight up. They enter the lobby first: security checks identity (TLS and initial authentication), reception says which floor (routing), if five people ask the same question the receptionist answers from a prepared note instead of calling an engineer (cache), if someone tries to send a hundred people at once they are stopped at the door (rate limiting), and if a floor is under renovation visitors are redirected elsewhere (load balancing and failover).
Your application is the engineer upstairs. Nginx is the lobby. Good design means the engineer only does work that genuinely needs their expertise.
The seven roles the web tier plays
1. Reverse proxy. "Reverse" is the opposite of an ordinary proxy: a forward proxy represents the client (your browser hands it a request to fetch on your behalf), while a reverse proxy represents the server — the client believes it is talking to the real service, but it is talking to an intermediary that hands the work to internal services. The payoff is that the addresses, count and topology of those internal services stay invisible from outside.
2. Load balancer. With three instances running, something must decide where each request goes and remove a dead instance from rotation. Because this happens at layer 7 (HTTP), the decision can depend on path, header or cookie — not just IP and port.
3. TLS terminator. Encryption is unwrapped here: the certificate and private key live on nginx and the app receives plain traffic. Certificate renewal, cipher selection and turning on HTTP/3 happen once, in one place, instead of in ten services.
4. Static file server. JS, CSS, images and fonts carry no logic; nginx ships them at a tiny fraction of the CPU cost of a Java thread.
5. Cache. Repeated, cacheable responses are returned without touching the app. Even a one-second cache (micro-caching) can cut load on a hot endpoint several fold.
6. Rate limiter and first shield. Floods, giant bodies and slow connections are cut here, before they consume a thread or a database connection.
7. WAF entry point. A Web Application Firewall blocks known attack shapes (SQL injection, path traversal, scanners); since all traffic passes through this layer, it is the natural place to install it.
These seven roles do not have to live on one binary: in a real deployment a CDN and anti-DDoS sit at the internet edge, nginx at the datacentre edge, and perhaps a sidecar next to each service. The names change; the concepts do not.
The path of one request from browser to JVM. | مسیر یک درخواست از مرورگر تا JVM.
flowchart LR
U[Browser] -->|HTTPS| CDN[CDN / Edge]
CDN -->|HTTPS| NG[nginx: TLS, cache, limits]
NG -->|static files| FS[(Disk assets)]
NG -->|HTTP keepalive| A1[Spring Boot #1]
NG --> A2[Spring Boot #2]
NG --> A3[Spring Boot #3]
A1 --> DB[(Database)]
A2 --> DB
A3 --> DB
Simple rule: anything that needs no domain knowledge and is identical for every service belongs in the web tier — TLS, compression, generic rate limits, static caching, security headers. Anything that depends on user identity, business rules or data belongs in the application — fine-grained authorization, domain validation, transactional decisions. Teams that write business logic in nginx if and rewrite blocks end up, six months later, maintaining a programming language with no tests and no debugger.
2. Anatomy: processes and contexts
The process model: why nginx holds tens of thousands of connections on little memory
Nginx starts one master process and several worker processes. The master only does privileged work (reading config, binding ports below 1024, opening log files, managing workers); the real work happens in workers running as an unprivileged user.
The key point: each worker is single-threaded and runs an event loop. Instead of "one thread per connection", one worker uses non-blocking system calls (epoll on Linux) to hold thousands of connections and only touches a socket when it is ready; an idle connection costs almost no memory. The same idea on the Java side is covered in the reactive-reactor and virtual-threads chapters.
A direct consequence of that design: because each worker is single-threaded, any operation that blocks that thread stalls every connection on that worker — a slow disk read, a heavy third-party module, or a Lua script doing synchronous I/O. Use sendfile on; (and aio threads; where needed) for large files, and never push heavy logic into nginx.
Contexts: an nginx config is a tree, not a list
nginx.conf is built from nested blocks called contexts. Keep the hierarchy in your head, because inheritance follows it:
- main (outermost, no block): whole-process settings —
user,worker_processes,pid,error_log,load_module. - events: the event loop —
worker_connections,use,multi_accept. - http: everything HTTP —
include mime.types,log_format,gzip,upstream, andserverblocks. - server: one virtual host —
listen,server_name, TLS certificates, andlocationblocks. - location: rules for a URI path.
There are also stream (TCP/UDP proxying at layer 4), mail, and upstream.
Inheritance rule: a directive flows from an outer context into inner ones unless redefined. That sounds simple, and it is — except for one infamous exception you will meet in the security-headers section.
The skeleton of a real nginx.conf:
user nginx;
worker_processes auto; # one per CPU core
worker_rlimit_nofile 65535; # file-descriptor ceiling per worker
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 8192; # the default is only 512
use epoll; # on Linux
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 75s; # default is 75s
server_tokens off; # do not advertise the nginx version
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log main;
include /etc/nginx/conf.d/*.conf; # one file per site
}
worker_connections defaults to just 512, and it counts all sockets, not only clients. In reverse-proxy mode a request usually consumes two connections: one from the client and one to the upstream. So 4 workers × 8192 connections gives a practical ceiling around 16k concurrent requests — provided worker_rlimit_nofile and the OS ulimit -n allow it. If you see worker_connections are not enough in error.log, this is why.
Commands you need every day
nginx -v # version
nginx -V # version + compile flags and built modules
nginx -t # test config only (mandatory before any reload)
nginx -T # test + print the fully assembled config with all includes
nginx -s reload # reload without dropping connections
nginx -s quit # graceful shutdown (open connections finish)
nginx -s reopen # close and reopen log files (for logrotate)
When the config is spread over ten files and three levels of include, and you see behaviour you never wrote, nginx -T prints the final tree — exactly what nginx sees. nginx -T | grep -n 'proxy_read_timeout' tells you in three seconds which file changed the value. Do this before you start guessing.
Because it is event-driven and non-blocking. Each worker is one thread that watches thousands of sockets with epoll (on Linux) and only processes sockets that have data. In a thread-per-connection model every idle connection costs a stack of a few hundred kilobytes plus an entry in the OS scheduler, so ten thousand idle connections mean gigabytes of memory and heavy context switching; nginx keeps only a small structure per idle connection. The important caveat is that this advantage holds only while nothing inside a worker blocks — a single synchronous I/O call stalls every connection on that worker.
3. Virtual hosts: listen, server_name and server selection
One nginx can serve hundreds of domains on the same IP and port 443. Each server block is a virtual host, and nginx picks exactly one per request.
Selection has two stages:
- Match address and port against the
listenparameters. - Among servers listening on that address/port, compare the request's
Hostheader withserver_namein this priority order: exact name ← longest wildcard starting with*(*.example.com) ← longest wildcard ending with*(mail.*) ← the first matching regular expression in file order. - If nothing matches, the server marked
default_serveranswers (or, if none is marked, the first server for that address/port).
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _; # a name that never matches a real Host
ssl_reject_handshake on; # since 1.19.4 — refuse the TLS handshake
return 444; # nginx-internal code: close without a response
}
server {
listen 443 ssl;
http2 on; # since 1.25.1 this is a standalone directive
server_name shop.example.com www.shop.example.com;
ssl_certificate /etc/ssl/shop/fullchain.pem;
ssl_certificate_key /etc/ssl/shop/privkey.pem;
root /var/www/shop;
}
server_name _; has no special meaning in nginx; it is simply an invalid name that never matches a real Host header. What actually makes a server the default is the default_server parameter on listen. If you define no default server, the first server block for that port becomes it, and internet scanners sending random Host values land on the wrong site — which means information leakage and polluted analytics.
One version note: before nginx 1.25.1 you wrote listen 443 ssl http2;, but that listen parameter is now deprecated in favour of the standalone http2 on; directive at server or http level — which is also more practical, since you can enable HTTP/2 for one virtual host and disable it for another without fighting over a shared socket.
4. location: the rule everyone gets wrong
Once the server is chosen, nginx decides which location block governs the URI. This is where most "why is my config ignored?" bugs are born, because the order in the file is not the order of evaluation.
| Modifier | Meaning | Priority |
|---|---|---|
= |
exact match of the whole URI | highest; search stops immediately |
^~ |
prefix match, but "do not check regexes" | if it is the longest prefix, regexes are skipped |
~ |
case-sensitive regex | evaluated in file order, first match wins |
~* |
case-insensitive regex | same as above |
| (none) | plain prefix match | used only if no regex matched |
@name |
named location | internal redirects only; not reachable from outside |
The exact algorithm: nginx checks all prefix locations and remembers the longest match; if that match used =, it stops there; if it used ^~, regexes are skipped; otherwise regexes are tried in the order they appear in the file and the first match wins; if no regex matches, the remembered longest prefix is used.
The location selection decision. | تصمیم انتخاب location.
flowchart TD
S[Request URI] --> E{Exact '=' match?}
E -- yes --> USE[Use it, stop]
E -- no --> P[Find longest prefix match]
P --> C{Prefix has '^~'?}
C -- yes --> USE
C -- no --> R[Scan regex locations in file order]
R --> M{Any regex matched?}
M -- yes --> UR[Use first matching regex]
M -- no --> UP[Use longest prefix]
A worked example — read the config, then the table:
server {
location = /health { return 200 "ok\n"; } # A
location / { proxy_pass http://app; } # B
location /static/ { root /var/www; } # C
location ^~ /downloads/ { root /srv; } # D
location ~* \.(png|jpg)$ { expires 30d; root /var/www; } # E
}
| Request URI | Winner | Why |
|---|---|---|
/health |
A | exact match; search ends immediately |
/api/orders |
B | only the / prefix matches |
/static/app.css |
C | longest prefix, and no regex matches .css |
/static/logo.png |
E | C is a longer prefix but lacks ^~, so the regex is checked and wins |
/downloads/a.png |
D | ^~ prevents regex evaluation |
/health/live |
B | = is exact only, never a prefix |
Rows three and four are exactly what interviewers ask about: if you want a whole branch to stay out of the reach of generic regexes, you must write ^~. There are hundreds of configs in the wild that define /static/ while their images are served from a different block, and nobody notices until the cache headers disagree.
Performance-wise regexes are not free either: every request walks the regex list until the first match. For hot paths — health checks above all — use location = /path so the search ends on the first step.
5. Files, paths and rewriting: root, alias, try_files, return, rewrite
root vs alias
Both build a filesystem path, but differently — and that single difference is an endless source of bugs:
root: final path = therootvalue + the whole URI.alias: final path = thealiasvalue + whatever follows the location prefix (the prefix is replaced).
location /images/ {
root /data/w3; # /images/cat.png -> /data/w3/images/cat.png
}
location /i/ {
alias /data/w3/images/; # /i/cat.png -> /data/w3/images/cat.png
}
If the location ends in /, the alias value must end in / too. location /i/ { alias /data/images; } builds /data/imagescat.png — a mysterious 404. Worse, on vulnerable versions the combination of location /i (no slash) with alias is the classic path-traversal bug. Safe rule: prefer root wherever possible and use alias only when the on-disk path genuinely differs from the URI — and then terminate both sides with a slash.
try_files: the backbone of serving an SPA
try_files tries a list of candidates in order and serves the first one that exists; the last argument is either a URI for an internal redirect or a status code.
location / {
root /var/www/spa;
try_files $uri $uri/ /index.html; # the standard React/Angular/Vue pattern
}
location /files/ {
root /srv;
try_files $uri =404; # if the file is missing, say 404 explicitly
}
index and error_page
index names the file served when a URI points at a directory (index index.html index.htm;). error_page routes errors to another URI or code:
error_page 500 502 503 504 /50x.html;
location = /50x.html { root /usr/share/nginx/html; internal; }
# turn a backend failure into clean JSON
error_page 502 504 = @maintenance;
location @maintenance {
default_type application/json;
return 503 '{"error":"service_unavailable","retry_after":30}';
}
internal; means that location is reachable only through an internal redirect, never directly from outside.
return vs rewrite — a difference you must know
Both live in the rewrite module, but they do different jobs:
returnends processing immediately and returns a code (optionally with a URL or a body). Cheap, explicit, predictable.rewriterewrites the URI with a regex. With thelastorbreakflag it is internal and the user sees nothing; withredirect(302) orpermanent(301) it sends a redirect to the client.
# right: http to https with return — one line, no regex
server {
listen 80;
server_name shop.example.com;
return 301 https://$host$request_uri;
}
# expensive and fragile: the same thing with rewrite
# rewrite ^(.*)$ https://$host$1 permanent;
# rewrite where it is genuinely needed: reshaping legacy URLs
rewrite ^/blog/([0-9]+)/(.*)$ /articles/$2?id=$1 last;
The flags differ: last means "restart the location search with the new URI", break means "change the URI but stay in this location". Misusing last inside a location that has a proxy_pass can create a rewrite loop and the rewrite or internal redirection cycle error in the log.
Documentation and operational experience agree: if something can be done with return or try_files, do not reach for rewrite, and avoid if inside location entirely. if in nginx has counter-intuitive semantics (the community document is literally titled "IfIsEvil") and combining it with proxy_pass produces undefined behaviour. Almost every if can be rewritten with map, try_files or a separate location.
6. Variables, map, and logs that actually debug
Nginx exposes dozens of built-in variables: $uri (the current normalized URI), $request_uri (the raw URI including the query string), $args, $host, $http_<name> for any request header, $upstream_addr, $status and many more.
map builds a translation table: it derives a new variable from the value of another. It is declared at http level and is lazy — evaluated only when a request actually needs it.
http {
# detect WebSocket clients
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# bypass the cache for logged-in users
map $http_cookie $skip_cache {
default 0;
"~*SESSION=" 1;
}
}
The log format you want in production
The default combined format is useless for debugging a reverse proxy because it says nothing about the upstream. Use this instead:
log_format upstream_json escape=json
'{"time":"$time_iso8601","remote_addr":"$remote_addr",'
'"request":"$request","status":$status,'
'"request_time":$request_time,"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_connect_time":"$upstream_connect_time",'
'"upstream_header_time":"$upstream_header_time",'
'"upstream_response_time":"$upstream_response_time",'
'"cache":"$upstream_cache_status","request_id":"$request_id"}';
access_log /var/log/nginx/access.log upstream_json buffer=64k flush=5s;
Four timing variables change everything: $request_time is the full lifetime of the request as nginx sees it (including the user's network), $upstream_connect_time is the time to establish TCP/TLS with the app, $upstream_header_time is the time until the first response-header byte ("how long the app thought"), and $upstream_response_time is the time until the last body byte.
If $upstream_response_time is small while $request_time is large, the application is innocent and the user is on a slow mobile network. That distinction ends endless backend-versus-infra arguments in one minute.
$request_id is a random identifier nginx generates per request; forward it with proxy_set_header X-Request-Id $request_id; so it lands in your Java logging MDC and you can jump from one nginx log line straight to the same request in the application log (details in the logging and observability chapters).
$upstream_response_time counts only time spent in the upstream server, from establishing the connection to receiving the last byte of the response. $request_time is the whole life of the request from nginx's point of view: reading the request from the client, sending it upstream, receiving the response, and writing all of it to the client socket. The gap between them is essentially client network latency and body transfer time. In practice: both large means the application is the problem; only $request_time large means a slow client, a big upload or a bad network; a large $upstream_connect_time points at the connection layer and the upstream's accept queue rather than at its logic.
7. Proxying to the application: the heart of it
proxy_pass and the trailing-slash trap
proxy_pass forwards the request to an upstream address, which may be a host directly or the name of an upstream block:
upstream app {
server 10.0.1.11:8080;
server 10.0.1.12:8080;
}
server {
location / {
proxy_pass http://app;
}
}
And now the single most important piece of syntax in this chapter: if you write a URI after the address (even just a /), nginx replaces the matched location prefix with that URI; if you do not, the original URI is passed untouched.
| Config | Request | What the app receives |
|---|---|---|
location /api/ { proxy_pass http://app; } |
/api/orders/7 |
/api/orders/7 |
location /api/ { proxy_pass http://app/; } |
/api/orders/7 |
/orders/7 |
location /api/ { proxy_pass http://app/v2/; } |
/api/orders/7 |
/v2/orders/7 |
First: the team sets the application context-path to /api and also writes proxy_pass http://app/; in nginx — the prefix is stripped twice and everything 404s. Second: inside a regex location or a named location (@name) a URI part is not allowed, and nginx refuses to start with proxy_pass cannot have URI part in location given by regular expression. Mental rule: a trailing slash means "cut it off", no slash means "leave it alone".
Headers: behind a proxy, the app is blind
The application no longer sees the user's IP (it sees nginx), does not know the user arrived over HTTPS (the internal hop is plain HTTP), and does not know the public hostname. All of that has to travel in headers:
location / {
proxy_pass http://app;
proxy_set_header Host $host; # the real hostname
proxy_set_header X-Real-IP $remote_addr; # client IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # the proxy chain
proxy_set_header X-Forwarded-Proto $scheme; # http or https
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
}
Nginx's default for Host is $proxy_host, i.e. the name of the upstream block, not the user's domain — so without this override every link the app builds is wrong. $proxy_add_x_forwarded_for means "whatever arrived in the header, plus the current client IP", preserving the chain. $host is normalized (no port, lower-case, filled from server_name when the header is absent) and is what you want in 99% of cases, unlike the raw $http_host. RFC 7239 also defines a single Forwarded: for=...;proto=...;host=... header, which Spring understands too.
The Java side: making Spring Boot trust those headers
If you only send the headers and change nothing in the app, nothing happens: request.getScheme() still says http, redirects go to http://, and OAuth2 breaks with an invalid redirect_uri. The official switch is one property:
# native: the embedded web server's own support (RemoteIpValve in Tomcat)
# framework: Spring's own filter (ForwardedHeaderFilter / ForwardedHeaderTransformer)
server.forward-headers-strategy=native
# only these addresses are allowed to set forwarded headers
server.tomcat.remoteip.internal-proxies=10\\.0\\.1\\.\\d{1,3}
NATIVE delegates to the embedded server (in Tomcat that is RemoteIpValve, which by default trusts private ranges such as 10/8, 192.168/16 and 127/8), FRAMEWORK uses Spring's own filter which understands both X-Forwarded-* and Forwarded, and NONE — the default, except on recognised cloud platforms — does no processing at all.
X-Forwarded-For is just text and anyone can forge it. Two dangerous states exist. First: you send the headers but the strategy is NONE → redirect loops between http and https and wrong absolute links. Second: you enable header processing but leave the trusted-proxy range wide open → a user sends X-Forwarded-For: 127.0.0.1 and bypasses every IP-based allowlist and rate limit. The rule: at the outermost trusted proxy, overwrite the incoming header with proxy_set_header X-Forwarded-For $remote_addr; so a forged chain is discarded, and keep the trusted range tight in the app. If nginx itself sits behind a CDN, you also need the realip module for its own logs to be correct: set_real_ip_from 10.0.0.0/8; and real_ip_header X-Forwarded-For; (the original address stays available in $realip_remote_addr).
Timeouts and buffering
proxy_connect_timeout 3s; # default 60s — TCP connection setup only
proxy_send_timeout 30s; # default 60s — between two write operations
proxy_read_timeout 30s; # default 60s — between two read operations
proxy_read_timeout is not a cap on the whole response; it caps the gap between two consecutive bytes, so a stream that emits something every 10 seconds never times out. Keep proxy_connect_timeout to a few seconds (an internal connection either happens instantly or not at all), and set proxy_read_timeout slightly higher than the service's own internal timeout so the user gets the application's meaningful error rather than a shapeless 504 (see the resilience chapter).
With proxy_buffering on (the default) nginx reads the response into buffers (proxy_buffer_size for the header, proxy_buffers for the body, spilling to disk beyond that), releases the expensive application connection, and then feeds the client at the client's own speed — which is what protects your application threads from slow clients. But for live responses (SSE, log streaming) buffering means the user sees nothing until the end:
location /api/stream {
proxy_pass http://app;
proxy_buffering off; # pass bytes through as they arrive
gzip off; # compression buffers too
proxy_read_timeout 1h;
}
If you would rather not create a location per streaming endpoint, the application can return the X-Accel-Buffering: no header; nginx honours it and disables buffering for that response only. The mirror-image setting is proxy_request_buffering (default on), which reads the entire request body before contacting the app; for streaming uploads you must turn it off, knowing that the application is then directly exposed to slow clients and retrying to another server is no longer possible.
WebSockets and keepalive
A WebSocket starts as an HTTP request carrying Upgrade: websocket, and nginx does not forward those headers by default:
map $http_upgrade $connection_upgrade { # at http level
default upgrade;
'' close;
}
location /ws/ {
proxy_pass http://app;
proxy_http_version 1.1; # the default since 1.29.7
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s; # otherwise it dies every 60s
proxy_send_timeout 3600s;
}
Ninety percent of WebSocket failures behind nginx have two causes: forgetting those two headers (which ends in 400 Bad Request or a fallback to polling), and the default 60-second proxy_read_timeout that closes an idle connection exactly one minute in, producing reports of "mysterious disconnects".
Every fresh TCP connection to the app costs a handshake and a socket in TIME_WAIT; with keepalive, connections stay pooled:
upstream app {
server 10.0.1.11:8080;
server 10.0.1.12:8080;
keepalive 64; # idle connections per worker
keepalive_requests 1000; # default
keepalive_timeout 60s; # default
}
In current versions, keepalive to upstreams is enabled by default (keepalive 32 local;), the default proxy_http_version changed from 1.0 to 1.1, and the Connection header is no longer sent upstream by default. On older versions, if you did not write proxy_http_version 1.1; and proxy_set_header Connection ""; the keepalive directive was effectively a no-op — and teams believed they had keepalive when they did not. On an older build, write those two lines explicitly.
server backend.internal:8080; is resolved at startup and that IP is kept until the next reload. In environments where service IPs change (containers, cloud, managed services) that means traffic to a dead address. Two correct fixes: the resolve parameter on server together with a configured resolver (available in open source since 1.27.3), or using a variable in proxy_pass (set $up backend.internal; proxy_pass http://$up:8080;) which moves resolution to request time — noting that the variable form also changes URI handling, so you must construct the path yourself.
The presence or absence of a URI part after the address. Without a URI, the request path is passed upstream unchanged. With a URI — even a bare slash — nginx replaces the part of the URI that matched the location prefix with it, so with location /api/ and proxy_pass http://app/; a request for /api/orders becomes /orders. That choice must line up with the application's context-path, otherwise the prefix is either duplicated or stripped. One more detail: in regex-based and named locations a URI part is not allowed at all and nginx refuses to start.
The application does not know the user's original connection was HTTPS, because the hop between nginx and the app is plain HTTP. So it builds an absolute redirect with http://, the edge bounces it back to HTTPS, and the loop repeats. The fix has two halves: in nginx send X-Forwarded-Proto, X-Forwarded-Host and Host correctly, and in Spring Boot enable processing of those headers with server.forward-headers-strategy set to native or framework. The trusted-proxy range must be kept tight as well, otherwise the same feature becomes a spoofing vector.
8. Static files, compression and browser caching
Spring Boot can serve static files from classpath:/static, but every such request consumes a thread and the whole filter chain; handing the job to nginx frees CPU and gives finer control over cache headers:
# versioned assets (hashed file names) — cache forever
location ^~ /assets/ {
root /var/www/app;
expires 1y;
add_header Cache-Control "public, immutable" always;
access_log off;
try_files $uri =404;
}
# HTML must never be cached permanently, or users see a stale app
location = /index.html {
root /var/www/app;
add_header Cache-Control "no-cache" always;
}
immutable tells the browser not even to revalidate, which is only safe when file names are content-hashed. Alongside it, sendfile on; (kernel-space copy from disk to socket), tcp_nopush on; and open_file_cache push CPU use down further.
gzip on; # default is off
gzip_comp_level 5; # default 1; above 6 rarely pays off
gzip_min_length 1024; # compressing tiny responses is a loss
gzip_vary on; # Vary: Accept-Encoding for intermediate caches
gzip_proxied any;
gzip_types text/plain text/css application/json application/javascript
application/xml image/svg+xml;
text/html is always compressed and need not be listed; never add already-compressed formats (JPEG, MP4, ZIP). Brotli typically shrinks text another 15–20% compared to gzip, but it is not part of the open-source nginx core: you must build or install the ngx_brotli module (the same is true for zstd). The practical pattern is gzip for dynamic responses, plus .br and .gz variants generated at build time and served pre-compressed for static assets. One security caveat: the BREACH family of attacks guesses secrets inside a compressed body (such as a CSRF token) by measuring response size, so for responses that mix attacker-controlled data with a secret, either randomise the token per response or disable compression there (see appsec-owasp).
Three reasons. Performance: nginx pushes the file straight from the kernel page cache to the socket with sendfile, with no copy into user space and no application thread involved. Resource isolation: a wave of asset downloads should not consume the service's thread pool or database connections. Control: cache headers, compression, pre-compressed variants and ETags are configured once, uniformly, for every service. In a real architecture the next layer is a CDN; nginx remains the origin that the CDN caches from.
9. Load balancing: spreading traffic and surviving failure
upstream app {
zone app_zone 64k; # shared state across all workers
least_conn; # selection algorithm
server 10.0.1.11:8080 weight=3 max_fails=3 fail_timeout=10s max_conns=200;
server 10.0.1.12:8080;
server 10.0.1.13:8080 backup; # only when everything else is down
# server 10.0.1.14:8080 down; # temporarily out of rotation
keepalive 64;
}
| Algorithm | How it works | When it fits | Risk |
|---|---|---|---|
| round-robin (default) | in turn, honouring weight |
stateless services, similar-sized requests | distributes badly when response times vary a lot |
least_conn |
fewest active connections | variable response times, long requests | needs zone to count correctly |
ip_hash |
hash of the client IP | crude stickiness when sessions live in memory | NAT and mobile clients skew distribution |
hash key [consistent] |
hash of any key (cookie, header, path) | sharded caches, per-user stickiness | without consistent, any topology change reshuffles everything |
random two [least_conn] |
pick two at random, take the better one | large clusters behind several balancers | approximate, not globally optimal |
Nginx runs several worker processes. If you declare no zone, each worker tracks connection counts and server health separately, so least_conn decides with partial information and max_conns is effectively enforced per worker. Declare a zone for any serious upstream.
Health checks. Open-source nginx gives you the passive kind only: nginx judges servers by the outcome of real requests, and if max_fails errors occur within fail_timeout, the server leaves rotation for that period and is then retried. The active kind — periodically calling a health path independently of user traffic, via the health_check directive in ngx_http_upstream_hc_module — is part of the commercial subscription. In container environments the right answer is usually to delegate health decisions to the orchestrator: a readiness probe removes an unhealthy instance from the service endpoints while nginx simply distributes traffic (see ms-gateway-discovery).
Upstream server lifecycle under passive health checks. | چرخهٔ عمر سرور بالادستی با health check غیرفعال.
stateDiagram-v2
[*] --> Healthy
Healthy --> Suspect: request error or timeout
Suspect --> Healthy: a request succeeds
Suspect --> Failed: max_fails reached within fail_timeout
Failed --> Probing: fail_timeout elapsed
Probing --> Healthy: probe request succeeds
Probing --> Failed: probe request fails
proxy_next_upstream error timeout http_502 http_503; # default: error timeout
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
By default nginx does not retry non-idempotent requests such as POST once they have already been sent upstream, and that protection is correct. If you enable it with the non_idempotent parameter, a timeout can cause the same POST to be processed twice: the user places one order and two are created. Either leave that parameter off, or make the application genuinely idempotent with an idempotency key (the pattern lives in the api-design and messaging chapters).
Sticky sessions, and why statelessness wins
If the application keeps sessions in its own memory, a user must always return to the same instance:
upstream app {
server 10.0.1.11:8080 route=a;
server 10.0.1.12:8080 route=b;
sticky cookie srv_id expires=1h httponly secure samesite=lax path=/;
}
The sticky directive with its cookie, route and learn methods has been available in open-source nginx since 1.29.6 (before that it was commercial only, and teams used ip_hash or hash $cookie_jsessionid consistent). Still, the senior judgment is that stickiness is a patch, not an architecture: with sticky sessions a deployment throws away users' carts, horizontal scaling becomes uneven, and a hot instance cannot shed load. The right move is to make the service stateless — keep the session in Redis or in a signed token so any instance can answer any request (see spring-security and caching).
Blue-green and canary at the proxy layer
split_clients "${remote_addr}${http_user_agent}" $pool {
5% app_canary;
* app_stable;
}
map $http_x_canary $pool_final { # let QA opt in with a header
default $pool;
"on" app_canary;
}
server {
location / {
proxy_pass http://$pool_final; # upstream group name inside a variable
}
}
Three practical conditions separate a canary from "experimenting on users": automated criteria (the canary's error rate and p99 latency must be measured separately, so tag the version in logs and metrics), fast rollback (changing the percentage must be a reload, not a deployment), and stable assignment (the split key must derive from a stable user identifier, otherwise one user bounces between versions and sees bizarre bugs). Blue-green needs the same machinery: two upstream groups and a map that switches between them.
Three problems: behind corporate NAT or a mobile carrier thousands of users share one IP, so load is badly skewed; a mobile user's IP changes and stickiness evaporates; and adding or removing one server remaps a large fraction of clients. Alternatives in order of preference: make the service stateless and store sessions in a shared store (best), use sticky cookie so stickiness rides on an explicit cookie, or use hash $cookie_xyz consistent, where consistent hashing minimises reshuffling when the topology changes.
Passive means the proxy judges only by real requests: it is free and adds no load, but a broken server is not detected until real users hit errors, and recovery is slower. Active means the proxy calls a health endpoint on a schedule independently of traffic: detection is faster and recovery more controlled, but it adds load and is not available in open-source nginx. In practice, in a container environment I delegate the health decision to the orchestrator's readiness probe and configure only max_fails/fail_timeout in nginx for fast reaction. The important detail is that the health endpoint must be lightweight and free of heavy dependencies, or it becomes the cause of a cascading outage itself.
10. TLS: terminating securely at the edge
Cryptography basics, CAs and the chain of trust are built in the crypto-foundations and pki-tls-gpg chapters; here we only take the web-tier view. The most common misconfiguration is this: ssl_certificate must be the fullchain — the server certificate followed by the intermediates in the same file. With a leaf-only certificate, browsers (which usually cache intermediates) show no problem, while Java clients, curl and mobile apps fail validation — a bug that presents as "it is only broken for some people".
server {
listen 443 ssl;
listen [::]:443 ssl;
listen 443 quic reuseport; # HTTP/3 on the same port number, over UDP
http2 on;
http3 on; # on by default
server_name shop.example.com;
ssl_certificate /etc/letsencrypt/live/shop/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/shop/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; # with TLS 1.3, client preference is better
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_session_cache shared:SSL:10m; # the default, none, means no resumption
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
server { # port 80 only for redirects and ACME
listen 80;
server_name shop.example.com;
location /.well-known/acme-challenge/ { root /var/www/acme; }
location / { return 301 https://$host$request_uri; }
}
HTTP/2 multiplexes many streams over one TCP connection and is enabled with http2 on;. HTTP/3 moves the same idea onto QUIC/UDP so that TCP-level head-of-line blocking disappears; enabling it needs a listen ... quic (ideally with reuseport, on the same port number) plus the Alt-Svc advertisement, because the browser arrives over ordinary HTTPS first and upgrades afterwards.
Strict-Transport-Security tells the browser "never speak http to me for this long". It is a one-way commitment: with a one-year max-age plus includeSubDomains, browsers that saw the header will refuse http until it expires, and any subdomain without TLS becomes unreachable. Start with a short value and grow it; adding preload is a decision that takes months to undo.
OCSP stapling means the server fetches a fresh "this certificate is not revoked" response from the CA and attaches it to the handshake, so the browser need not contact the CA. But the big free issuers shut down their OCSP service during 2025 and their new certificates carry no OCSP responder URL at all; revocation is published via CRLs instead. The result is that ssl_stapling on; merely logs ssl_stapling ignored, no OCSP responder URL in the certificate. It remains useful for commercial certificates that still support OCSP — and then you also need a resolver, because nginx must resolve the responder's hostname.
mTLS in both directions
# client side: demand a certificate from the caller
ssl_client_certificate /etc/ssl/internal-ca.pem;
ssl_verify_client on; # optional is also possible
location / {
proxy_pass http://app;
proxy_set_header X-Client-DN $ssl_client_s_dn; # pass the identity to the app
}
# upstream side: present yourself to an internal service
location /secure/ {
proxy_pass https://internal-app;
proxy_ssl_certificate /etc/ssl/nginx-client.pem;
proxy_ssl_certificate_key /etc/ssl/nginx-client.key;
proxy_ssl_trusted_certificate /etc/ssl/internal-ca.pem;
proxy_ssl_verify on; # the default is off
proxy_ssl_session_reuse on;
}
Write proxy_pass https://... and do nothing else, and nginx does not validate the upstream certificate at all: anyone able to sit in the internal network path can impersonate the service, and you have an "encrypted" connection to an attacker. Wherever you use TLS to an upstream, also set proxy_ssl_verify on; and proxy_ssl_trusted_certificate. Managing and rotating those certificates is the subject of the key-management-secrets chapter.
Because concentrating keys and certificates in one place simplifies renewal and rotation, cipher choice and enabling HTTP/2 and HTTP/3 happen once, and the application is freed from handshake CPU cost. It is wrong when traffic beyond the edge crosses an untrusted network, or when a compliance requirement says data must stay encrypted all the way to the service. Then you either terminate and re-encrypt to the upstream (proxy_pass https:// with proxy_ssl_verify on), or pass through at TCP level without decrypting — in which case no layer-7 decision (routing, caching, rate limiting) is possible any more. In Kubernetes the same choice is usually solved by a service mesh's automatic mTLS.
11. Caching in the proxy layer
proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api:20m
max_size=5g inactive=10m use_temp_path=off;
location /api/catalog/ {
proxy_pass http://app;
proxy_cache api;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 301 30s;
proxy_cache_valid 404 5s;
proxy_cache_lock on; # collapse a stampede on one key
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on; # serve stale, refresh in the background
proxy_cache_revalidate on;
proxy_no_cache $cookie_SESSION; # never store a logged-in user's response
proxy_cache_bypass $cookie_SESSION; # and never read from cache for them
add_header X-Cache-Status $upstream_cache_status always;
}
keys_zone is shared memory for metadata and max_size caps disk usage; inactive evicts a key that is not read within that window even if it is still fresh; use_temp_path=off keeps temporary files on the cache partition and avoids a cross-device copy. $upstream_cache_status is one of MISS, HIT, EXPIRED, STALE, UPDATING, REVALIDATED or BYPASS — without exposing it in logs and headers, "the cache is working" is guesswork.
The proxy cache decision flow. | جریان تصمیم کش proxy.
flowchart TD
R[Request] --> B{bypass or no_cache?}
B -- yes --> U[Go to upstream]
B -- no --> K[Build cache key]
K --> F{Fresh entry?}
F -- yes --> H[HIT: serve from cache]
F -- no --> S{Stale allowed?}
S -- yes --> ST[STALE now, refresh in background]
S -- no --> L{Another request filling it?}
L -- yes --> W[Wait on cache lock]
L -- no --> U
U --> SV[Store if cacheable, then serve]
Nginx refuses to store responses carrying Set-Cookie or Cache-Control: no-store/private. Some teams disable that protection with proxy_ignore_headers Cache-Control Set-Cookie; to "improve the hit rate" and create the worst possible bug: one user's session or personal data served to the next visitor. If you must ignore headers, build the user identity into the cache key or restrict caching to explicitly public paths.
For hot endpoints that are "nearly identical for everyone" — the landing page, popular product lists, public API responses — a one-to-five-second cache with proxy_cache_lock on works wonders: out of a thousand requests in that second, exactly one reaches the application. Data freshness barely changes while upstream load drops by an order of magnitude. It is the cheapest optimisation in this whole chapter.
Purging. The proxy_cache_purge directive is part of the commercial subscription. In open source you have three options: the third-party ngx_cache_purge module; deleting the cache file directly (its path is derived from the MD5 of the key and the levels pattern); or, cleanest, versioning the cache key by adding a version variable to proxy_cache_key so that changed data naturally orphans the old key.
Each layer holds what is cheapest and safest to hold there. The CDN keeps static assets and fully public responses close to users, removing geographic latency and origin bandwidth. Nginx keeps short-lived public responses at the origin — especially micro-caching, which collapses stampedes and, with stale responses, keeps the service alive while the upstream is failing. The in-application cache is for things whose key depends on domain logic or user identity, which a proxy cannot understand. Rule of thumb: closer to the user means more public and simpler; closer to the data means more private and more precise. Invalidation is the hard problem at every layer, which is why short TTLs are usually safer than long TTLs with clever invalidation logic.
12. Protection: keeping attacks at the edge
# 10 requests per second per IP (one megabyte holds roughly 16k states)
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=conn:10m;
limit_req_status 429; # the default is 503; 429 is more meaningful
limit_conn_status 429;
location /api/ {
limit_req zone=perip burst=20 nodelay;
limit_conn conn 20;
proxy_pass http://app;
}
location = /auth/login {
limit_req zone=login burst=3; # no nodelay: slow them down, do not reject
proxy_pass http://app;
}
The mental model for limit_req is a leaky bucket: requests are processed at a fixed rate, burst is the queue that absorbs a short spike, and anything beyond the queue is rejected with an error code. Without nodelay, queued requests are delayed so they proceed at the configured rate; with nodelay they are processed immediately but still consume queue capacity. For interactive APIs nodelay gives a better experience; for login and sensitive paths the delay is deliberately desirable, because it genuinely slows brute force. Use $binary_remote_addr as the key (it uses less memory), and before enforcing anything run a day with limit_req_dry_run on; and read the logs — the first number you pick is almost always too strict.
client_max_body_size 10m; # the default is only 1m — beyond it, 413
client_body_timeout 10s;
client_header_timeout 10s; # sending headers slowly = slowloris
send_timeout 10s;
large_client_header_buffers 4 8k;
server_tokens off; # strip the version from Server and error pages
A slowloris attack opens hundreds of connections and dribbles headers out byte by byte to exhaust server capacity. Three weapons answer it: the short timeouts above, limit_conn per IP, and nginx's own event-driven architecture, for which an idle connection is nearly free — the exact opposite of a thread-per-connection server.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'" always;
add_header inheritance is the exception to the rule: headers from an outer context are inherited only if the inner context defines no add_header at all. Put four security headers in server, then add a single add_header X-Cache ... in one location, and that location silently loses all four — no error, no warning. The fix is to keep shared headers in one file and include it in every location that adds headers of its own. The always parameter is also required so headers are sent on error responses (404, 500) too.
For blocking known attack shapes, the common options are ModSecurity/Coraza with the OWASP Core Rule Set, or commercial products. Two senior notes: an untuned WAF is full of false positives and must first run in detection-only mode; and a WAF does not replace secure code — it is an extra layer whose real value is buying time to patch a disclosed vulnerability (see appsec-owasp).
Both, with different roles. In nginx I set a coarse, cheap ceiling that cuts floods before they consume a thread or a database connection; that layer knows only IP and path. In the application I enforce meaningful business quotas — per customer, per plan, per operation — which depend on identity and data. The scalability caveat is that an nginx counter is per instance: with five nginx nodes the effective ceiling is five times higher, and if you need an exact global limit the counter must be shared (for example in Redis), which costs latency. In both layers the response should be 429 with a Retry-After header so clients behave correctly.
13. Operations: reload, diagnosis and tuning
nginx -t && nginx -s reload # always test first, then reload
systemctl reload nginx # the same thing via the HUP signal
On reload the master reads the new config and, if it is valid, spawns new workers and tells the old ones to stop accepting connections and exit once their work is done — so no request is dropped. A restart kills the process and starts it again, meaning a brief outage; it is only needed when something outside the config changed: a new binary, a new load_module, or changed service environment variables.
An old worker stays alive until its last connection closes. With WebSockets or SSE that means every reload leaves a generation of workers running the old config, and with several reloads a day you end up with dozens of "shutting down" processes eating memory. worker_shutdown_timeout 30s; puts a ceiling on it so long-lived connections are eventually cut — and streaming clients must implement reconnection logic.
| Symptom | Meaning | Likely causes and what to do |
|---|---|---|
502 Bad Gateway |
invalid response from, or failed connection to, the upstream | service down or wrong port; a keepalive connection the upstream closed first (its idle timeout must exceed nginx's); response header larger than proxy_buffer_size; TLS failure to the upstream |
504 Gateway Time-out |
the upstream did not answer in time | slow query, lock contention, exhausted thread or connection pool; investigate the slowness before raising proxy_read_timeout |
499 |
the client disconnected before the response | user closed the page, or a client-side timeout shorter than the server's; usually a symptom of upstream slowness |
413 |
body larger than the limit | raise client_max_body_size and align it with the app's multipart limits |
400 Request Header Or Cookie Too Large |
oversized header or cookie | raise large_client_header_buffers; usually a sign of an oversized token |
worker_connections are not enough |
connection ceiling reached | raise worker_connections and worker_rlimit_nofile |
The first two commands in any incident: tail -f /var/log/nginx/error.log, and a quick tally of status codes from the access log. If you write JSON logs you can group by upstream_addr with jq and immediately see whether errors come from one instance or from all of them.
For monitoring, the stub_status module exposes the basic counters (active, accepted, handled connections and total requests):
location = /nginx_status {
stub_status;
allow 10.0.0.0/8;
deny all;
}
The key number is that when accepted and handled differ, nginx hit a resource ceiling and dropped connections. Prometheus exporters turn this output or the logs into metrics (see observability). At the system level, net.core.somaxconn, ulimit -n and the ephemeral port range usually become bottlenecks before nginx itself does — another reason to keep upstream keepalive enabled (see linux-essentials).
First I read error.log, because nginx writes the exact reason there: connect() failed (111: Connection refused) means the service is down or the port is wrong; upstream prematurely closed connection means the app closed the connection — classically a keepalive race when the app's idle timeout is shorter than nginx's, or a crash/restart; upstream sent too big header means I need a larger proxy_buffer_size. Second, I call the upstream directly from the nginx host (curl -v http://10.0.1.11:8080/actuator/health) to separate a network/firewall problem from an application problem. Third, I group the logs by upstream_addr to see whether all instances fail or just one. Fourth, if all of them fail I move to the application: long GC pauses, an exhausted database connection pool, or a full accept queue. The common mistake is raising timeouts without reading error.log — that converts a 502 into a 504 and nothing else.
I validate it with nginx -t and then run nginx -s reload. The master reads the new config, starts new workers and asks the old ones to shut down gracefully, so in-flight requests are never broken. Three practical points: I set worker_shutdown_timeout so long-lived connections such as WebSockets do not keep the old generation alive forever; if the binary or a module changed, a reload is not enough and I need a live binary upgrade (the USR2, WINCH, QUIT signal dance) or, in modern environments, a rolling deployment; and I keep the config in version control so a rollback is one git revert plus one reload (see cicd-pipelines).
14. Choosing a tool, and mapping it onto Kubernetes
| Tool | Strength | Cost / weakness | Choose it when |
|---|---|---|---|
| nginx | all-rounder: web server + proxy + cache; light, stable, excellent docs | static config (change = reload); active health checks and some features are commercial | classic edge, static serving, proxy and cache in one binary |
| Apache httpd | huge module set, .htaccess, legacy compatibility |
heavier process/thread model at high concurrency | maintaining legacy systems or depending on a specific module |
| HAProxy | specialist in L4/L7 load balancing; very strong health checks and stats in the free edition | not a web server; no caching or file serving | when only traffic distribution and resilience matter |
| Traefik | auto-configuration from container labels and Kubernetes resources; built-in ACME | more overhead and more abstraction to debug through | dynamic container environments where services move constantly |
| Envoy | dynamic APIs (xDS), deep observability, first-class gRPC and circuit breaking | high complexity; usually needs a control plane | service-mesh data planes and advanced gateways |
| Caddy | automatic HTTPS with minimal config | smaller ecosystem and operational tooling | small internal services where simplicity wins |
The real selection criterion is usually not performance — under real load the differences are negligible next to application response time — but the configuration model. If the topology is static and machines have stable names, a static file (nginx or HAProxy) is simple and reviewable; if services move every few minutes, you want a tool that generates config from the source of truth. Decide by the rate of topology change, not by benchmark charts.
Kubernetes has exactly this same web tier, only the names change: an Ingress/Gateway is a declarative object saying which host and path map to which service and does nothing by itself; a controller is the process that reads that object and configures a real proxy (nginx, Envoy, Traefik, HAProxy) — that is, the same server, location and upstream from this chapter, generated from YAML; and a service mesh brings the same capabilities (mTLS, retries, circuit breaking, percentage routing, uniform metrics) to traffic between services, usually via a proxy beside each pod or at node level.
Mapping this chapter's concepts onto Kubernetes. | نگاشت مفاهیم این فصل به Kubernetes.
flowchart LR
I[Ingress / Gateway object] --> C[Controller]
C -->|generates config| P[Proxy data plane]
P --> S1[Service A pods]
P --> S2[Service B pods]
S1 -->|mTLS, retries| M[Mesh sidecar]
M --> S2
The Kubernetes project announced that the widely used ingress-nginx controller retires at the end of March 2026: no new releases, no bug fixes, no security patches. The planned successor never matured and was itself abandoned, and the official recommendation is to migrate to Gateway API or another maintained controller. Gateway API has a clear role model: GatewayClass and Gateway belong to the platform team while HTTPRoute/GRPCRoute belong to application teams — the thing old Ingress solved with piles of vendor-specific annotations. If you are on ingress-nginx today, migration is a planned project, not a backlog item.
Ingress (and its modern successor, Gateway API) is only a declarative object in the Kubernetes API describing inbound routing rules; on its own it moves no packets. The Ingress controller is the actual implementation: a process that reads those objects and configures a proxy — very often nginx or Envoy — which is the automated equivalent of the config file we wrote by hand in this chapter. A service mesh solves a different problem: service-to-service traffic inside the cluster, with automatic mTLS, retries, circuit breaking, percentage routing and uniform metrics. They are complementary rather than alternatives: the gateway handles north-south traffic while the mesh governs east-west traffic, and most current implementations offer both roles on a shared data plane.
Technically it can, but you give up things that are expensive to lose. With a proxy: TLS and certificate renewal are centralised in one place; multiple instances sit behind one address, which makes zero-downtime deployment possible; static files and cached responses are served without consuming application threads; rate limits, body-size caps and security headers are applied uniformly; and the application is never directly exposed to the internet. The exception is small, purely internal services, or environments where an orchestrator and a mesh already perform these duties — and there the proxy still exists, you simply do not configure it directly.
Cheat sheet
| Topic | Value / command | Note |
|---|---|---|
| test and dump config | nginx -t / nginx -T |
the second prints the fully assembled config |
| zero-downtime reload | nginx -s reload |
always after -t |
| built modules | nginx -V |
check for realip, stub_status, … |
| location priority | = ← ^~ ← regex ← prefix |
file order matters only among regexes |
root vs alias |
root: path + whole URI / alias: prefix replaced | always end alias with a slash |
| proxy headers | Host, X-Real-IP, X-Forwarded-For/Proto |
Host defaults to $proxy_host |
| Spring side | server.forward-headers-strategy=native|framework |
defaults to none |
| proxy timeouts | connect/send/read, all default 60s |
read = gap between two bytes |
| body size cap | client_max_body_size default 1m |
otherwise 413 |
| worker connections | worker_connections default 512 |
each proxied request uses 2 |
| upstream keepalive | keepalive 32 local; (new default) |
set it explicitly on older versions |
| rate limiting | limit_req_zone + limit_req burst= nodelay |
start with limit_req_dry_run |
| caching | proxy_cache_path + proxy_cache + proxy_cache_valid |
observe via $upstream_cache_status |
| TLS | fullchain.pem + ssl_protocols TLSv1.2 TLSv1.3 |
ssl_session_cache defaults to none |
| TLS to upstream | proxy_ssl_verify on; |
the default is off |
| WebSocket | Upgrade + Connection + long proxy_read_timeout |
via map $http_upgrade |
| streaming / SSE | proxy_buffering off; or X-Accel-Buffering: no |
gzip off too |
| timing | $request_time vs $upstream_*_time |
separates network blame from app blame |
The web tier is where systems engineering separates from service programming. Nginx is a tree of nested contexts that, with an event-driven single-threaded worker model, holds tens of thousands of connections cheaply. A server is selected by listen and server_name, and a location by the rule exact ← ^~ ← regex ← prefix, never by file order. When proxying, the presence or absence of a trailing slash in proxy_pass rewrites the path, X-Forwarded-* headers must be both sent and accepted (via server.forward-headers-strategy in Spring), and timeouts, buffering and keepalive decide how immune your application is to slow clients and bad networks. In load balancing, zone is required for correct counters, active health checks are not in the open-source build, and session stickiness is a patch compared with a stateless architecture. TLS is centralised at the edge, needs the fullchain, must be verified upstream with proxy_ssl_verify on, and HSTS is a commitment you cannot take back. Caching — especially micro-caching with proxy_cache_lock — is the cheapest load reduction available, as long as you do not disable the default protection against caching personalised responses. limit_req, limit_conn and short timeouts keep attacks at the edge, and add_header hides a silent inheritance trap. Operationally, nginx -t before every reload is mandatory, and diagnosing 502 always starts in error.log, never with raising timeouts. Finally, all of these concepts repeat verbatim in Kubernetes through Gateway API and service meshes — someone who understood this chapter is simply writing the same things in YAML there.