多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Nginx as a Reverse Proxy

Nginx as a Reverse Proxy “Nginx or a reverse proxy?” is a category error worth unpacking.Reverse proxyis arole; Nginx is oneimplementationof it. The useful questions are what the role actually requires, how Nginx implements it, and when a different implementation fits better.Abbreviation glossaryAbbreviationFull English name中文rProxyreverse proxy反向代理TLSTransport Layer Security传输层安全协议SSLSecure Sockets Layer安全套接层TLS 的前身SNIServer Name Indication服务器名称指示L4 / L7OSI Layer 4 (transport) / Layer 7 (application)四层传输层/ 七层应用层ALBApplication Load Balancer (AWS)应用负载均衡器NLBNetwork Load Balancer (AWS)网络负载均衡器SSEServer-Sent Events服务器推送事件WAFWeb Application FirewallWeb 应用防火墙xDSExtensible Discovery Service (Envoy’s config API)可扩展发现服务CRDCustom Resource Definition (Kubernetes)自定义资源定义RPSRequests Per Second每秒请求数TTFBTime To First Byte首字节时间1. The role, stated preciselyA reverse proxy is a server that terminates a client connection, then originates aseparateconnection to one or more upstream servers on the client’s behalf. Two connections, two independent lifecycles. Everything interesting follows from that split:Because the connections are independent, the proxy canfan out(load balancing),retry(a failed upstream attempt need not fail the client),cache,rewrite, andterminate TLSwithout the client knowing.Because the proxy terminates TLS, it becomes the place where certificates, cipher policy, and HTTP protocol versions are decided — the client may speak HTTP/2 while the upstream speaks HTTP/1.1.Because the client only ever sees the proxy, upstream topology is free to change. This is the property that makes rolling deploys, blue/green, and canaries possible at all.For how this differs from a forward proxy, see forward and reverse proxy. The rest of this article is about the reverse direction only.连接 1TLS 1.3 HTTP/2连接 2HTTP/1.1 keepalive连接池复用被动健康检查max_fails / fail_timeoutproxy_cacheClient浏览器 / 移动端Nginx反向代理upstream app-1upstream app-2upstream app-3标记为 down磁盘缓存区2. Why Nginx scales: the architecture in one sectionNginx’s design choice, made in 2004 against Apache’s process-per-connection model, isevent-driven, non-blocking, fixed worker count.Onemasterprocess reads config, binds listening sockets, and manages workers. It runs as root only to bind privileged ports and to open files.Nworkerprocesses (worker_processes auto;→ one per CPU core) each run a single-threaded event loop overepoll(Linux) /kqueue(BSD). A worker holds tens of thousands of connections simultaneously because a connection that is waiting costs only a file descriptor and a small state struct — not a thread stack.Workers share nothing except shared-memory zones you declare explicitly (proxy_cache_pathkeys,limit_req_zonecounters,upstreamstate). This is why rate limits and connection limits in open-source Nginx areper-worker-shared-zone, not per-cluster — a distinction that bites when you size limits.The practical consequences for you as an operator:Blocking a worker blocks every connection it holds.Disk I/O is the usual culprit;aioandsendfileexist for this. Any third-party module doing synchronous work — a naive Lua script making a blocking call — will destroy tail latency.Memory is bounded and predictable.A worker’s footprint is dominated by buffers you configured, not by concurrency. This is why Nginx behaves gracefully at the point where thread-per-request servers fall over.Config reload is graceful by construction.nginx -s reloadspawns new workers with the new config; old workers stop accepting and drain in-flight requests. Zero dropped connections, no connection reuse across the boundary.3.proxy_pass: the mechanics, including the trailing-slash trapThis is the single most misunderstood directive in Nginx.location /api/ { proxy_pass http://backend; # NO trailing slash } # GET /api/users → upstream receives /api/userslocation /api/ { proxy_pass http://backend/; # trailing slash } # GET /api/users → upstream receives /usersThe rule:if theproxy_passvalue contains a URI component (anything after the host, including a bare/), the part of the request URI matched by thelocationprefix is replaced by that URI.If there is no URI component, the original request URI is passed through unchanged.Two corollaries that cost people afternoons:With aregexlocation or alocationusing named captures, the URI-replacement form is not allowed — Nginx requires you to construct the target explicitly, usually with variables andrewrite.Using avariableinproxy_pass(proxy_pass http://$upstream_host;) changes the resolution semantics entirely: Nginx then resolves the name at request time using theresolverdirective, rather than once at startup. This is the standard trick for upstreams whose DNS changes — and the standard cause ofno resolver defined to resolve ...errors.# Dynamic upstream resolution, re-resolved per TTL resolver 10.0.0.2 valid30s ipv6off; location /svc/ { set $target service.internal.example:8080; proxy_pass http://$target/; }Without this,Nginx resolves upstream hostnames once at startup and caches the result forever.On a platform where backends get new IPs — Kubernetes, ECS, any autoscaling group — a staticproxy_pass http://service.internal:8080;will keep hammering a dead IP until you reload. This is one of the most common production surprises when moving Nginx into a container platform.4. Headers: the inheritance rule nobody remembersproxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host;The rule:proxy_set_headerdirectives are inherited from an outer block only if the inner block defines none of its own.Add a singleproxy_set_headerinside alocationand every inherited header fromserverorhttpsilently disappears. This is the mechanism behind “it worked until I added one header and the app started 404-ing on Host”.TheHostheader matters more than it looks:Default isproxy_set_header Host $proxy_host;— theupstream’sname. Virtual-hosted backends, absolute URL generation, and cookie domains all break.$hostis the request’s Host header with the port stripped, falling back toserver_name. Usually what you want.$http_hostis the raw client-supplied value including port. Use when the upstream needs to reconstruct exact URLs; be aware it is fully attacker-controlled.$proxy_add_x_forwarded_forappends$remote_addrto any existingX-Forwarded-For.That existing value came from the client and is a lie until proven otherwise.If your Nginx is the internet-facing edge, overwrite rather than append:proxy_set_header X-Forwarded-For $remote_addr; # edge: do not trust what arrivedIf Nginx isbehinda trusted load balancer, usereal_ipto establish the true client address before anything else reads it:set_real_ip_from 10.0.0.0/8; # the trusted LB range, and only that real_ip_header X-Forwarded-For; real_ip_recursive on; # walk right-to-left past trusted hopsGet this wrong in either direction and you have either broken geolocation and rate limiting, or built a trivially spoofable IP allowlist.5. Buffering: the reason your streaming endpoint is brokenBy default Nginxbuffers the upstream response: it reads the response as fast as the upstream can produce it, into memory (proxy_buffers) and spilling to disk (proxy_max_temp_file_size), then feeds it to the client at the client’s pace.This is the right default for most traffic. It frees the upstream worker — typically an expensive thread in a Java or Python app — the instant the response is produced, rather than holding it for the seconds a mobile client on a poor connection needs to receive it. Slow-client absorption is arguably Nginx’s single largest contribution to backend capacity.It is exactly wrong for:Server-Sent Events and long-poll— events accumulate in the buffer and arrive in a burst, or never.Streaming LLM token responses— the user sees nothing, then the whole answer at once.Large uploadswithproxy_request_buffering on(the default for requests) — the whole body lands on the proxy’s disk before the upstream sees a byte.location /stream/ { proxy_pass http://backend; proxy_buffering off; # forward each chunk immediately proxy_cache off; proxy_read_timeout 3600s; # a long-lived stream is not a stuck request chunked_transfer_encoding on; add_header X-Accel-Buffering no; # also tells any upstream Nginx to stop buffering } location /upload/ { proxy_pass http://backend; proxy_request_buffering off; # stream the body through client_max_body_size 0; # 0 no limit; set a real number in production }X-Accel-Buffering: nois worth remembering in the other direction too — anupstream applicationcan emit that header to ask the fronting Nginx to disable buffering for that response, without any proxy config change.6. Upstreams, keepalive, and timeoutsupstream backend { # Algorithms: round-robin (default), least_conn, ip_hash, hash key [consistent] least_conn; server 10.0.1.10:8080 max_fails3 fail_timeout10s weight2; server 10.0.1.11:8080 max_fails3 fail_timeout10s; server 10.0.1.12:8080 backup; # only used when all primaries are down keepalive 32; # persistent connections retained PER WORKER keepalive_timeout 60s; keepalive_requests 1000; } server { location / { proxy_pass http://backend; proxy_http_version 1.1; # REQUIRED for upstream keepalive proxy_set_header Connection ; # REQUIRED: clear the inherited close proxy_connect_timeout 2s; # TCP connect — should be small proxy_send_timeout 30s; # between successive writes to upstream proxy_read_timeout 30s; # between successive reads — NOT total duration proxy_next_upstream error timeout http_502 http_503; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 5s; } }Points that matter in production:Upstream keepalive needs all three lines.keepalive 32;alone does nothing: withoutproxy_http_version 1.1andproxy_set_header Connection ;Nginx still sendsConnection: closeand opens a fresh TCP connection per request. On a TLS-to-upstream path that is a full handshake per request, and it shows up as a flat tens-of-milliseconds tax on TTFB.keepalive Nis per worker, not global.With 8 workers andkeepalive 32, the upstream may see up to 256 idle connections from this one proxy. Size upstream connection limits accordingly.proxy_read_timeoutis an inactivity timer, not a total-request budget.An upstream that dribbles a byte every 29 seconds will never time out. If you need a hard ceiling, enforce it upstream or in front.proxy_next_upstreamretries are dangerous on non-idempotent requests.errorandtimeoutare in the default set, and atimeouton a POST means the upstream may well have processed it. Nginx hasnon_idempotentas an opt-in for this reason — theabsenceof that keyword means POST/PATCH/LOCK are not retried, which is correct. Do not add it casually.Health checking in open-source Nginx is passive only.max_fails/fail_timeoutmark a server down after real requests fail — meaning real users absorb the failures, and a server that recovers is only rediscovered when thefail_timeoutwindow lapses and a user’s request is used as the probe. Active health checks (health_checkdirective) are an Nginx Plus feature. This is one of the clearest reasons teams move to Envoy or a cloud load balancer.7. TLS termination and re-encryptionserver { listen 443 ssl; http2 on; server_name api.example.com; ssl_certificate /etc/nginx/tls/fullchain.pem; # leaf intermediates, in order ssl_certificate_key /etc/nginx/tls/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; # TLS 1.3: let the client choose ssl_session_cache shared:SSL:10m; # shared across workers — important ssl_session_tickets off; # unless you rotate ticket keys properly ssl_stapling on; # OCSP stapling for the server cert ssl_stapling_verify on; resolver 1.1.1.1 valid300s; location / { # Re-encrypt to the upstream proxy_pass https://backend; proxy_ssl_verify on; proxy_ssl_trusted_certificate /etc/nginx/tls/internal-ca.pem; proxy_ssl_name backend.internal.example; # SNI verification name proxy_ssl_server_name on; # actually send SNI — off by default proxy_ssl_session_reuse on; } }Two defaults that surprise people:proxy_ssl_verifyisoffby default.Nginx will happily proxy to an upstream presenting any certificate, including an expired self-signed one from an attacker who has won a DNS race. If you re-encrypt, verify.proxy_ssl_server_nameisoffby default, so Nginx does not send SNI to the upstream. Against any modern multi-tenant TLS endpoint this fails, often with a confusing certificate error rather than an obvious one.ssl_session_cache shared:...must be shared, notbuiltin. The builtin cache is per worker, so a client whose resumption attempt lands on a different worker does a full handshake. On a busy edge this is a measurable CPU difference.8. Complete, annotated configA realistic edge config combining the above:user nginx; worker_processes auto; worker_rlimit_nofile 65535; events { worker_connections 16384; multi_accept on; } http { # --- logging with the fields you will actually need at 03:00 --- log_format main $remote_addr $host $request $status $body_bytes_sent rt$request_time uct$upstream_connect_time uht$upstream_header_time urt$upstream_response_time ua$upstream_addr us$upstream_status cache$upstream_cache_status rid$request_id; access_log /var/log/nginx/access.log main buffer32k flush5s; sendfile on; tcp_nopush on; keepalive_timeout 65s; server_tokens off; # do not advertise the version # --- rate limiting: per worker-shared zone, 10 MB ≈ 160k IPs --- limit_req_zone $binary_remote_addr zoneperip:10m rate20r/s; limit_conn_zone $binary_remote_addr zoneconn_perip:10m; proxy_cache_path /var/cache/nginx levels1:2 keys_zonestatic:100m max_size10g inactive60m use_temp_pathoff; upstream backend { least_conn; server 10.0.1.10:8080 max_fails3 fail_timeout10s; server 10.0.1.11:8080 max_fails3 fail_timeout10s; keepalive 32; } server { listen 443 ssl; http2 on; server_name api.example.com; ssl_certificate /etc/nginx/tls/fullchain.pem; ssl_certificate_key /etc/nginx/tls/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_session_cache shared:SSL:10m; # Behind a trusted cloud LB — establish the real client IP first set_real_ip_from 10.0.0.0/8; real_ip_header X-Forwarded-For; real_ip_recursive on; limit_req zoneperip burst40 nodelay; limit_conn conn_perip 20; # Shared proxy settings — remember: any proxy_set_header in an inner # block discards ALL of these. Re-declare or use an include file. proxy_http_version 1.1; proxy_set_header Connection ; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Request-ID $request_id; proxy_connect_timeout 2s; proxy_read_timeout 30s; location /healthz { access_log off; return 200 ok\n; } location /static/ { proxy_pass http://backend; proxy_cache static; proxy_cache_valid 200 301 302 10m; proxy_cache_use_stale error timeout updating http_502 http_503; proxy_cache_lock on; # collapse concurrent misses proxy_cache_background_update on; add_header X-Cache-Status $upstream_cache_status always; } location /events { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; } location /ws { proxy_pass http://backend; proxy_set_header Upgrade $http_upgrade; # WebSocket upgrade proxy_set_header Connection upgrade; # NOTE: this blocks proxy_set_header list replaces the server-level # one entirely — Host and X-Forwarded-* must be repeated here. proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 3600s; } location / { proxy_pass http://backend; } } }proxy_cache_lock ondeserves a callout: without it, N concurrent requests for the same cold cache key all go to the upstream. That is the cache stampede that turns a cache expiry into an outage.Verify before reloading, always:nginx-t# parse and validatenginx-T|less# dump the FULLY resolved config, includes expandednginx-sreload# graceful: new workers take over, old ones drainnginx -Tis the one to reach for when a directive “isn’t taking effect” — it shows exactly what Nginx assembled from your includes.9. Nginx versus the other implementations of the roleNginx (OSS)HAProxyEnvoyTraefikCaddyCloud ALB/NLBPrimary identityWeb server that proxies wellDedicated load balancerProgrammable L7 proxyContainer-native edge routerBatteries-included web serverManaged serviceConfig modelStatic file reloadStatic file reloadDynamic via xDS APIDynamic from labels/CRDsStatic file, very terseConsole / API / IaCConfig changeGraceful reloadGraceful reload (seamless since 1.8)Hot, no reloadAutomatic on service changeReload or admin APIAPI callActive health checksPlus onlyYes, richYes, incl. outlier detectionYesBasicYesObservabilityBasicstub_status; logsVery detailed stats socketBest in class— per-upstream histogramsGoodBasicCloudWatchCircuit breaking / outlier ejectionNoPartialYesPartialNoPartialAutomatic TLS certificatesNo (use certbot)NoNoYesYesYes (ACM)gRPC / HTTP/2 upstreamYes (grpc_pass)YesNative, first-classYesYesALB: yesStatic file servingExcellentNoNoNoExcellentNoCachingYes, solidNoLimitedNoVia pluginCloudFront separatelyMemory footprintLowLowestHighModerateModeraten/aExtensibilityModules (compile-time), Lua via OpenRestyLua, SPOEWASM, Lua, external filtersMiddleware pluginsModules in GoLambdaEdge etc.Typical sweet spotEdge: TLS static proxy in one processPure high-RPS TCP/HTTP balancingService mesh data plane; dynamic fleetsKubernetes / Docker ingressSmall services, zero-config HTTPSYou do not want to run itChoose Nginx whenyou want one low-footprint process doing TLS termination, static assets, caching, and proxying, over a topology that changes on the order of deploys rather than seconds. It is the highest value-per-megabyte option in this table and the most widely understood.Choose something else when:Upstreams change continuously and you cannot reload for every change →Envoy(xDS) orTraefik(service discovery).You need active health checks, outlier ejection, and circuit breaking without paying for Nginx Plus →EnvoyorHAProxy.You need per-upstream latency histograms and retry/timeout budgets as first-class telemetry →Envoy.You are doing pure L4 balancing at very high connection rates →HAProxyor anNLB.You want TLS certificates to just work with no certbot cron →CaddyorTraefik.The operational burden is not worth it → a managedALB, accepting the loss of caching and config expressiveness.A very common and entirely reasonable production shape isboth: a cloud load balancer for the public IP, TLS certificates, and cross-zone distribution, with Nginx behind it doing the routing, caching, header work, and static serving that the cloud LB cannot express.10. Operational checklistnginx -tin CI;nginx -Tdiffed on config changesproxy_http_version 1.1proxy_set_header Connection whereverkeepaliveis setresolverconfigured, and variable-basedproxy_passused for any upstream whose IP changesX-Forwarded-Foroverwritten at the true edge,set_real_ip_fromscoped to the trusted range onlyproxy_buffering offon every streaming and SSE route, and nowhere elseproxy_ssl_verify onandproxy_ssl_server_name onon every re-encrypted upstreamssl_session_cache shared:(neverbuiltin)proxy_cache_lock onon cacheable routes$upstream_connect_time/$upstream_header_time/$upstream_response_timein the access log — without them you cannot tell a slow upstream from a slow proxyserver_tokens off, andclient_max_body_sizeset to a real valueKnow that health checks are passive, and that the first failures are paid for by usersSee alsoForward proxy and reverse proxy
返回列表