半夜两点被告警叫起来,线上 502 刷屏,第一反应是应用崩了,登上机器一看应用进程活得好好的、端口也通,问题卡在最前面那层 Nginx 上——这种场面我经历过不止一次。Nginx 网站服务在很多团队里是个"配好就不管"的角色,直到流量涨上来、业务开始灰度、上游开始滚动发布,前面这层薄薄的代理才暴露出它真正的复杂度。这篇就把这些年做反向代理配置、负载均衡编排和生产环境调优的实操整理一遍:proxy_pass怎么写才不丢请求头,upstream的几种调度策略各自适合什么业务,worker_connections到底该按什么公式倒推,以及 502/504/413 这几类高频故障的定位路径。手上有一台到几十台 Nginx、正准备从"能跑"往"跑得稳"过渡的后端和运维同学最适合看,新人可以照着抄配置,老手可以对照着查自己漏掉的参数。
1. 为什么把 Nginx 放在架构入口这一层
1.1 反向代理替我们挡下的那些事
先说清楚反向代理在做什么。正向代理是替客户端发请求,反向代理是替服务端收请求——客户端以为自己在跟app.internal说话,实际上中间隔了一层 Nginx,它负责把请求转发到后端的某一台应用服务器上,再把响应原样吐回去。这个"中间人"身份一旦确立,很多事情就能在这一层顺手做掉:静态文件直接由 Nginx 从磁盘读,不用占用应用进程;TLS 握手在这一层终结,后端只跑明文 HTTP,证书更新只改一处;请求头在这里补齐,后端拿到的X-Forwarded-For、X-Real-IP才是真实客户端地址,否则应用日志里全是 Nginx 的内网 IP,排查问题时等于瞎了一半。
更实际的价值在于解耦。应用要滚动发布,upstream里摘掉一台机器就行;要按路径把/api和/static拆到不同后端,location写两条规则就行;要做限流,limit_req加两行就够,不用回头改业务代码。我见过不少团队一开始图省事让应用直接暴露端口,等要做灰度、要限流、要统一日志的时候才发现得把整条链路重排一遍,代价比一开始就加一层代理大得多。
当然这层代理不是免费的。每多一跳就多一次连接建立、多一次内存拷贝,配置写错还会引入新的故障点——502 到底是应用挂了还是 Nginx 连不上,没有清晰的日志根本分不清。所以后面几节的重点,本质上都是把这层"中间人"的行为变得可预测、可观测。
1.2 Nginx、API 网关、应用服务器三者的职责边界
这一块特别容易糊。很多团队上了 Nginx 之后又把鉴权、限流、路由全塞进去,配置文件几千行,改一次心惊胆战。我的一般划分是这样的:Nginx 负责连接层面的治理——TLS 终结、静态资源、请求转发、连接复用、基础限流、日志采集,这些都是跟业务语义无关的事;API 网关负责业务语义——鉴权、签名校验、参数改写、灰度打标、配额;应用服务器只关心自己的业务逻辑。
按这个边界切,好处是 Nginx 配置几乎不随业务迭代变动。业务新增一个接口,不用动nginx.conf;鉴权规则改了,也不用动。反过来,如果按路径分流这种纯路由的事,放 Nginx 是最省成本的,一个location精确匹配就搞定了,没必要专门写进网关代码里。
有个例外值得提一句:完全静态的 SPA 前端,我一般直接让 Nginx 用try_files兜底到index.html,不经过任何后端。这类请求占总量可能六成以上,全放过去打应用纯属浪费。
1.3 选型阶段踩过的几个认知误区
第一个误区是觉得"配置越复杂越专业"。早期我喜欢把所有参数都显式写死,proxy_buffer_size、proxy_buffers、各种 timeout 一个不落,结果出问题时根本不知道哪个参数生效了。后来改成只写必须的,其余交给默认值,反而好维护——Nginx 的默认值大多经过充分验证,除非你能量化说明为什么需要改,否则别动。
第二个误区是把开源版和商业版的能力混为一谈。这是新手最容易踩的坑:网上搜"nginx 健康检查",一半文章在讲health_check指令,那是商业版才有的主动健康检查,开源版压根不认这个指令,写进去直接启动失败。开源版只有基于max_fails和fail_timeout的被动摘除,请求打失败了才标记,这两者的行为差异非常大,后面 3.2 节会展开。
第三个误区是认为 reload 绝对无损。nginx -s reload确实优雅,老 worker 会处理完存量连接再退出,但如果老 worker 上有长连接(WebSocket 或者 keepalive 撑很久的),它可能几十秒甚至几分钟都不退,期间的连接数会短暂翻倍。滚动发布密集的时候,这个细节能把文件句柄打满。
2. 反向代理配置:从最小可用到能上生产
2.1 一份能跑起来的最小配置长什么样
先给一份我实际在用的骨架,能直接跑,后面所有优化都在这上面加:
worker_processes auto; worker_rlimit_nofile 65535; events { worker_connections 16384; multi_accept on; use epoll; } http { include mime.types; default_type application/octet-stream; sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; server_tokens off; upstream app_backend { server 10.0.0.11:8080 weight=1 max_fails=3 fail_timeout=10s; server 10.0.0.12:8080 weight=1 max_fails=3 fail_timeout=10s; keepalive 64; keepalive_requests 1000; keepalive_timeout 60s; } server { listen 80; server_name app.internal; client_max_body_size 20m; location / { proxy_pass http://app_backend; 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_connect_timeout 5s; proxy_send_timeout 30s; proxy_read_timeout 60s; } } }这段配置里最容易被忽略的是proxy_http_version 1.1加proxy_set_header Connection ""这两行。它们必须成对出现,作用是让 Nginx 到后端的连接走 HTTP/1.1 并清掉Connection: close头,否则upstream里那个keepalive 64完全不生效——连接池形同虚设,每个请求都重新三次握手。我见过线上 QPS 卡在几千上不去,最后就是这两行没写。
另一个坑是proxy_pass结尾的斜杠。proxy_pass http://app_backend;不带斜杠,请求/api/user会原样转成/api/user;写成proxy_pass http://app_backend/;带斜杠,Nginx 会把location匹配到的那段前缀替换掉。比如location /api/ { proxy_pass http://app_backend/; },请求/api/user到后端就变成/user了。这两种行为都是设计如此,但混用必然出 404,团队内部最好定一条规矩:要么统一带斜杠,要么统一不带。
2.2 proxy_set_header 这一组参数为什么一个都不能省
Host不传的话,后端收到的是Host: app_backend(upstream 的名字),很多框架靠 Host 做虚拟主机路由、生成回调地址,直接崩。用$host而不是$http_host是有讲究的:$host是经过处理的、优先级为请求行里的 host、然后是 Host 头、最后是server_name,且会去掉端口;$http_host是客户端原始传来的 Host 头,包含端口。对外暴露的域名场景用$host更稳,但如果你需要保留端口,那就得用$http_host。
X-Real-IP传$remote_addr,这是直连 Nginx 的那个客户端 IP。注意如果 Nginx 前面还有一层 SLB 或者 CDN,$remote_addr拿到的是 SLB 的 IP,这时候需要set_real_ip_from加real_ip_header X-Forwarded-For配合,把$remote_addr还原成真实客户端。这块配错,风控和日志全废。
X-Forwarded-For用$proxy_add_x_forwarded_for,它的行为是在已有 XFF 后面追加$remote_addr,形成一条链。应用取真实 IP 的时候要注意取第一个还是最后一个——取决于你的信任边界。如果只信任第一层代理,应该取链上第一个非内网地址;如果简单粗暴取最后一个,那是直连 Nginx 的那一方的 IP,很可能就是 SLB。这个细节我在做风控对接时踩过,排查了两天才发现拿到的是代理 IP。
X-Forwarded-Proto传$scheme,用于后端生成重定向地址时判断该用 http 还是 https。不加的话,HTTPS 站点在应用层做 301 跳转时会跳到 http,触发浏览器混合内容拦截,登录态直接丢。
2.3 超时参数怎么算才不拍脑袋
三个超时参数的作用范围经常被误解,这里逐个拆:
proxy_connect_timeout是 Nginx 与上游建立 TCP 连接的超时,默认 60s,我一般压到 3~5s。理由很直白:内网环境下建连超过 5s,基本可以判定对端异常,继续等只是拖长故障窗口。这个值不要超过upstream里fail_timeout的一半,否则一台机器卡死会把整个请求周期拖过fail_timeout,被动摘除机制等于失效。
proxy_read_timeout是最容易理解错的一个。它不是整个请求的总处理时长,而是两次读操作之间的最大间隔。也就是说后端只要在 60s 内有任何数据吐出来,计时器就重置一次。所以一个跑 5 分钟但每 10s 输出一次进度的流式接口,设 60s 完全没问题;反过来,一个后端处理要 70s 且中途完全不输出的接口,就得把这个值调上去,否则必 504。默认 60s 对绝大多数 CRUD 接口够用,长任务接口按实际单次静默窗口来定。
proxy_send_timeout同理,是向上游两次写操作之间的间隔,不是整个请求体的传输时长。大文件上传场景这个值要适当放大,但更关键的是配合proxy_request_buffering off,让 Nginx 不把整个请求体先缓存到磁盘再转发,而是边收边发——否则一个 2GB 的上传会在/var/cache/nginx里留一堆临时文件,磁盘被撑爆。
给一个我这边长任务接口的实数:后端幂等导出接口,实际耗时 40 到 180s 不等,每处理一批数据会 flush 一次进度,间隔约 5s。proxy_read_timeout我设 120s 而不是 180s,因为只要 5s 有数据就不触发超时,120s 的静默意味着后端真的卡死了,早断早释放连接。这个思路比"按最长耗时设"更合理——超时值的本质是"容忍多久没动静",不是"允许跑多久"。
2.4 WebSocket 与流式响应:两处必须改的配置
WebSocket 升级请求在 Nginx 这层的处理跟普通 HTTP 不一样。缺了Upgrade和Connection头,后端根本不知道这是个升级请求,握手直接失败。标准写法:
location /ws { proxy_pass http://app_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }$connection_upgrade不是内置变量,需要在http块里用map定义:
map $http_upgrade $connection_upgrade { default upgrade; '' close; }这样写的好处是一份配置同时兼容普通 HTTP 和 WS,不用为了 WS 单独复制一个 location。超时我拉到 3600s,因为长连接可能长时间没有业务消息但有心跳,如果客户端有心跳保活,其实 60s 也够;如果没有心跳,拉长超时只是让死连接占着资源,最好还是在应用层加心跳。
流式响应(SSE、大文件下载、视频流)的关键是proxy_buffering off。默认 Nginx 会把后端返回的内容全部收进缓冲区再一次性发给客户端,对普通接口这能减少客户端慢速带来的后端占用,对 SSE 就是灾难——事件全被攒着不下发,前端看起来像卡死了。加上proxy_buffering off之后数据边收边转。同时要把proxy_cache相关的东西关掉,不然流式内容被缓存更是雪上加霜。
顺带一个经验:X-Accel-Buffering: no这个响应头让后端可以自己控制是否缓冲,比全局关掉更精细。我在图片服务和 SSE 接口上都用这个,后端在需要流式的接口上自己加这个头,Nginx 侧的缓冲配置不用动。
3. 负载均衡:五种调度策略和它们的真实适用边界
3.1 策略对比与选型判断
upstream的调度算法不是随便挑一个就行,选错了在低流量下看不出来,流量一上来就暴露问题。下面这张表是我实际对比过的:
| 策略 | 配置写法 | 适用场景 | 主要问题 |
|---|---|---|---|
| 轮询 | 默认,无需配置 | 后端配置一致、请求耗时均匀 | 长短请求混合时负载严重倾斜 |
| 加权轮询 | weight=3 | 机器配置不同、需要灰度放量 | 权重是静态的,没法跟随实际负载 |
| 最少连接 | least_conn | 请求耗时差异大、有慢接口 | 连接数不等于负载,长连接场景失真 |
| IP 哈希 | ip_hash | 无共享 session 存储的老系统 | 节点变动导致缓存命中暴跌 |
| 一致性哈希 | hash $key consistent | 有本地缓存、需要提高命中率 | 需要挑一个分布均匀的 key |
轮询的问题最典型:一个接口平均 20ms,另一个批量导出接口 30s,十台机器上如果同一时刻有几台在跑导出,这几台的并发连接会迅速堆起来,而轮询还在均匀地往里塞。改成least_conn后这种情况明显缓解,因为新请求会优先给当前连接数最少的机器。
但least_conn也不是万能的。它看的是连接数,不是 CPU 或内存。如果一个服务大量使用长连接(WebSocket、gRPC 流),连接数跟实际负载完全脱钩,这时候least_conn的效果还不如加权轮询。我一般会先看服务的连接形态:短连接占比高的 CRUD 服务用least_conn收益明显;长连接服务就用轮询加权重。
加权轮询里有个细节值得说:默认是平滑加权轮询,比如两台机器权重 3:1,实际调度序列是A A B A而不是A A A B,避免连续打同一台造成尖峰。这个行为在文档里提得不多,但确实是这样实现的。
3.2 开源版没有主动健康检查,这件事必须说清楚
这是被误解最多的一点。max_fails=3 fail_timeout=10s的含义是:在 10 秒内累积失败 3 次,就把这台机器标记为不可用,持续 10 秒,之后放一个请求过去试探(所谓半开),成功就恢复,失败就再标记 10 秒。整个过程是被动的——必须有请求失败才会触发,而不是后端一挂就立刻摘掉。
这意味着什么?如果后端某一台在下线前没做优雅停机,进程被 kill 掉,Nginx 是完全不知道的,下一个请求打过去 → 连接拒绝 → 失败计数 +1 → 再打一个 → +1 → 第三个才摘掉。中间那两三个失败请求就直接返给用户了,500 已经产生了。
能做的补救有几个。一是让后端优雅停机,收到 SIGTERM 后先停止接受新连接、处理完存量再退出,配合 K8s 的preStop加几秒 sleep,给 Nginx 一个缓冲窗口。二是把max_fails降到 1 或 2,代价是网络抖动时容易误摘。三是靠proxy_next_upstream把失败请求转发到下一台:
proxy_next_upstream error timeout http_502 http_503 http_504; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 5s;注意默认的proxy_next_upstream只有error和timeout,不含 502/503/504。也就是说后端返回 502 的时候,Nginx 默认是直接把 502 吐给用户的,不会重试下一台。这一条我在排查"为什么后端明明有多台还是报错"时卡过很久。
但加http_502有风险:如果请求是非幂等的(POST 下单、支付),重试可能导致重复提交。稳妥的做法是只在 GET 类接口的location里开,POST 接口保守一点只保留error timeout。这个区分要在配置里写明白,别一刀切。
3.3 会话保持:ip_hash 的代价和一致性哈希的取舍
老系统没有集中式 session 存储,靠ip_hash让同一个客户端固定打到同一台。它按客户端 IP 的前三段做哈希,同一个小区的用户可能全落到同一台,出口 IP 集中的办公网更是重灾区。更麻烦的是节点扩容:加一台机器,哈希取模的结果全变,几乎所有用户的 session 都失效,登录态大面积掉线。所以ip_hash我只在无法改代码的存量系统上用,新系统一律推集中式 session。
hash $key consistent用的是环状一致性哈希,加减节点只影响相邻的一小段,绝大多数 key 的映射关系不变。key 的选法很关键:用$request_uri适合有 URL 级缓存的场景,用$cookie_user_id或$arg_uid适合用户维度的缓存亲和。前提是这个 key 的分布要足够散,否则环上热点集中,照样倾斜。
我给一个真实取舍:某个图片处理服务本地有缩略图缓存,命中率能到 80%。用轮询时每台机器各自缓存一份,整体命中率只有 80%(因为每个请求随机落,只有落到"自己缓存过这张图"的机器才命中,实际命中率 = 命中数/总机器数分布不均)。改一致性哈希按 URL 哈希之后,同一张图永远去同一台,命中率稳定在 80% 以上的高位,后端响应时间直接降了一半。这个改动的收益比我做过的绝大多数代码优化都大。
3.4 灰度发布与权重调整的实操
灰度靠weight做是最简单的方式。新版本先上 1 台,权重给 1,老版本 9 台各权重 10,流量占比约 1%。观察十几分钟,错误率和延迟正常就把新版本权重提到 10,逐步放大。整个过程只需要改配置 reload,不重启任何进程。
但权重灰度有个硬伤:它只能按请求比例分,没法按用户维度分。要做"指定用户走新版本",得靠 cookie 或者请求头判断:
upstream app_v1 { server 10.0.0.11:8080; } upstream app_v2 { server 10.0.0.21:8080; } map $cookie_gray $backend_pool { default app_v1; 1 app_v2; } server { location / { proxy_pass http://$backend_pool; # 其余 proxy_set_header 照旧 } }这里有个必须注意的点:proxy_pass里带变量时,Nginx 不会在启动时解析 upstream 名字,而是运行时去解析,如果名字解析不了会直接 502 并且日志里提示no resolver defined。用 upstream 名字(而不是域名)的情况下通常没问题,但如果换成域名就必须配resolver。这个坑我在一次改造里撞过,配置看着没错,就是全站 502。
reload 时的平滑性也要考虑:新配置生效的瞬间,新连接走新 upstream,老连接还在老 worker 上。灰度期间如果新旧配置的 upstream 完全不同,会有极短时间两套并存,这在大多数场景下无害,但涉及数据库连接池切换时要留意。
4. 生产调优:从 worker 数到内核参数
4.1 worker_processes 与 worker_connections 的算式
这两个参数是配套的,不能分开拍。worker_processes auto会按 CPU 核数设置,这是合理默认值——Nginx 是单线程事件驱动模型,每个 worker 独占一个核效率最高,进程数超过核数反而增加上下文切换。8 核机器就 8 个 worker,别写 16。
worker_connections是单个 worker能同时处理的最大连接数。整机的理论最大并发是:
最大并发连接 = worker_processes × worker_connections但这是客户端侧的连接数。作为反向代理,每个客户端请求还需要一条到上游的连接,所以实际能承载的客户端并发约为:
实际客户端并发 ≈ worker_processes × worker_connections ÷ 2按 8 核、worker_connections 16384算,理论 131072,实际约 65536 个并发请求。这个量级对绝大多数业务足够了。如果确实需要更高,往上调之前先确认两件事:文件句柄够不够,内存扛不扛得住。每个连接在内核里占一个 fd,用户态还有对应的连接结构体开销,不是纯数字游戏。
worker_rlimit_nofile必须大于等于worker_connections,我一般设成worker_connections × 2留余量。注意这个指令只是让 Nginx 自己setrlimit,系统层面的nofile还得配。systemd 管理的服务在 unit 文件里写LimitNOFILE=65535,别指望改/etc/security/limits.conf就一定生效——那个对 systemd 服务经常不买账,这是我在好几台机器上验证过的坑。
还有一个隐性的连接消耗:如果开了keepalive到上游,每个 worker 会额外维护一个空闲连接池(keepalive 64指的是每个 worker 最多 64 条空闲连接)。这 64 条连接也占 fd,也占worker_connections的配额。量大时要把这部分算进预算。
4.2 上游连接复用与缓冲区的取舍
upstream里的keepalive是性价比最高的一项优化。不开的时候,每个请求都要跟后端重新建 TCP 连接,TIME_WAIT 堆积、握手延迟、端口耗尽全来了。开了之后连接复用,延迟能降 30% 以上(跨机房场景更明显),后端看到的连接数也稳定下来。
三个参数要一起看:
upstream app_backend { server 10.0.0.11:8080; server 10.0.0.12:8080; keepalive 64; # 每个 worker 的空闲连接池上限 keepalive_requests 1000; # 单条连接最多服务多少个请求后关闭 keepalive_timeout 60s; # 空闲连接多久没被复用就关掉 }keepalive 64是每个 worker的上限,不是全局。8 个 worker 就是 8×64=512 条潜在的空闲连接。这个数按实际 QPS 估:如果单 worker 峰值 500 QPS、后端单请求 50ms,那并发约 25,keepalive 64绰绰有余。设太大只是白占 fd。
keepalive_requests默认是 1000,我一般不调。它的作用是防止单条连接被用太久、后端侧的负载统计失真。有些后端框架有单连接最大请求数限制,这时候 Nginx 侧要配得比它小,否则后端主动关连接、Nginx 还以为是复用失败,日志里会看到大量upstream prematurely closed connection。
缓冲区这块,默认值对大多数接口够用。真正需要调的是大响应体的场景:proxy_buffers 8 16k配proxy_buffer_size 16k,一次能缓存 128k 的响应头加内容。如果后端返回的响应头特别大(比如一堆 Set-Cookie,或者超长的 JWT),超过了proxy_buffer_size,会报upstream sent too big header然后 502。这个报错很好认,直接在日志里搜关键词就行,解决办法就是把proxy_buffer_size提到 32k 或 64k。
反过来,响应体特别大(比如几百 MB 的文件下载)时,别一味加大缓冲区,那是在拿内存换磁盘 IO,应该直接proxy_buffering off让它流式转发,后端和客户端之间直通。
4.3 压缩、静态缓存与 TLS 会话复用
gzip的开关和参数直接决定带宽账单。默认只对text/html生效,其余类型要显式放开:
gzip on; gzip_vary on; gzip_min_length 1024; gzip_comp_level 5; gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml; gzip_disable "msie6";gzip_comp_level我固定用 5。1 到 9 的压缩率提升是边际递减的,5 到 9 可能只多压 3%,但 CPU 消耗翻倍。生产环境用 4 到 6 是最稳的区间。gzip_min_length设 1k 是因为小于 1k 的响应压缩后可能反而更大,白白消耗 CPU。另外要注意,图片、视频、woff2 字体这些本身已经压缩过的格式绝对不要加进gzip_types,压不动还浪费算力。
顺带说一句brotli。它的压缩率比 gzip 好,但开源版 Nginx 默认不包含,需要自己编译模块或者装第三方包。如果团队没有维护自定义编译产物的意愿,别为了 10% 的带宽去引入一个升级时容易出问题的依赖。
静态资源缓存是我认为最容易被低估的一项。文件名带哈希的构建产物(app.a1b2c3.js这类)完全可以设一年强缓存:
location ~* \.(js|css|png|jpg|svg|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; access_log off; }immutable的作用是告诉浏览器"这个 URL 的内容永远不会变,不用发条件请求"。不加的话,浏览器按 F5 时会带If-None-Match去问一遍,即便命中缓存也白跑一个请求。加了之后刷新完全不发请求,体验和带宽双赢。前提是文件名必须带内容哈希,用固定名字的千万别这么配,否则发版后用户看到的是旧文件。
TLS 方面,ssl_session_cache shared:SSL:10m值得配。10MB 的共享缓存大约能存 4 万个会话,命中后省掉完整握手的一次往返和若干次非对称运算。ssl_session_timeout给 10m 到 1h 都行,看用户回访频率。协议版本只留 TLSv1.2 和 TLSv1.3,更老的版本既慢又不安全。
4.4 日志里必须留的字段
默认的combined格式排障时严重不够用,它没有上游耗时,没有上游地址,出问题时根本分不清是 Nginx 慢还是后端慢。我这边固定用这套:
log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" rt=$request_time ' 'urt=$upstream_response_time ua=$upstream_addr ' 'us=$upstream_status rid=$request_id'; access_log /var/log/nginx/access.log main buffer=64k flush=5s;几个字段的含义值得记住。$request_time是从收到客户端第一个字节到发出最后一个字节的总时长,包含客户端慢速接收的时间,所以它可能比后端耗时大很多。$upstream_response_time是 Nginx 与后端之间的耗时,多次重试时会有多个值用逗号分隔。两个一比就能定位:rt大urt小 = 客户端或网络慢;两个都大 = 后端慢;urt是多个值 = 发生过重试。
$upstream_addr在重试时同样会有多个值,配合$upstream_status能看出是哪台机器返回了什么状态码,这是定位"某台机器间歇性 502"的关键证据。$request_id是 Nginx 自己生成的唯一 ID,让后端在响应头里回传,就能把 Nginx 日志和应用日志串起来,跨层排查时省下大量时间。
日志写磁盘本身有开销,高流量下用buffer=64k flush=5s攒批写,别每条都直接落盘。磁盘空间按日均量做轮转,logrotate配好,postrotate里发USR1信号让 Nginx 重开文件句柄,别用copytruncate——那个方式在写日志的瞬间会丢内容。
5. 常见故障排查实录
5.1 502、504、499 分别指向哪里
502 是"上游返回了无效响应"或者"连不上上游"。定位路径很固定:先看错误日志里的具体原因,connect() failed (111: Connection refused)说明后端端口没监听,进程挂了或者还没起来;upstream prematurely closed connection说明后端接了连接就关,常见于后端超时主动断开、或者keepalive配得比后端的上限大;upstream sent too big header就是 4.2 节说的缓冲区不够;no live upstreams说明所有后端都被max_fails摘掉了,这时候要去看后端是不是整体不可用,或者fail_timeout设得太短导致抖动。
504 是超时,来源只有两个:proxy_connect_timeout触发(连不上但没被拒,比如网络丢包、后端 accept 队列满了),或者proxy_read_timeout触发(连上了但后端不吐数据)。区分方法看日志,connect 超时会有upstream timed out (110: Connection timed out) while connecting to upstream,read 超时是while reading response header from upstream。前者查网络和后端 backlog,后者查后端处理逻辑。
499 是 Nginx 特有的状态码,表示客户端在服务端返回响应之前主动断开了连接。它不对应任何错误日志(默认不记),但大量出现说明客户端等不及了:要么后端太慢,用户关页面或者前端设了很短的请求超时;要么proxy_read_timeout比客户端超时还长,Nginx 还在傻等。这个码在监控里必须单独看,它往往是"用户感知到的故障"的第一手信号。
5.2 上传报 413 与请求体限制
413 是client_max_body_size拦下来的,默认值只有 1MB。这个默认值坑过太多人——本地测小文件没事,上线传张高清图就 413。解决办法是在对应的server或location里放开,但别图省事在http块里全局设成0(不限制),那等于给攻击者开了个免费的内存和磁盘炸弹。
我的做法是按路径精细控制:普通接口 2m,头像上传 10m,文档上传 50m,并且这一层限制要和后端框架自己的限制对齐。见过太多次 Nginx 放开了 50m,应用层还卡在 10m,用户传到 30m 被应用拒了,报错信息还特别含糊。
还有一个容易混淆的:client_body_buffer_size决定请求体在内存里缓存多少,超过的部分写临时文件。默认 8k 或 16k,跟client_max_body_size不是一个东西。大上传场景下临时文件会落到client_body_temp_path,要确保那个分区有足够空间,不然会看到write() failed (28: No space left on device)。
5.3 负载不均:为什么某台机器总是最忙
现象是几台后端 CPU 差异明显。排查顺序我一般这样走:
先看$upstream_addr的分布。如果某台机器压根没收到请求,那可能是被max_fails摘了但没恢复,或者权重写错了。如果收到了但明显偏多,那要看策略——轮询下请求数应该是均等的,偏多说明该机器处理快、连接释放快、能接更多新请求,这在least_conn下是正常现象,因为快机器本来就该多干。
再看请求耗时是否均匀。长短请求混合是最常见的根因:一堆 20ms 的查询和几个 30s 的导出混在一起,轮询会把长请求均匀撒到各台,但每台处理长请求期间接不了新活,导致排队。这种要么用least_conn,要么把长任务接口单独切一个upstream池,物理隔离,别互相拖累。
最后看连接形态。如果启用了 keepalive 但proxy_http_version没配 1.1,连接池不生效,每台机器的表现会受 TCP 握手和端口分配的随机性影响,看起来就像"不均匀"。这个问题隐蔽性很强,因为功能完全正常,只是性能上不去。
我确实遇到过一次特别隐蔽的:某台机器因为时钟偏移,日志时间戳错乱,导致监控里看起来它某个时段完全没流量。实际流量是均匀的,只是数据被归到了错误的桶里。查了半天负载均衡配置,最后发现是 NTP 没同步。
5.4 高频问题速查表
| 现象 | 优先查 | 常见根因 | 处理 |
|---|---|---|---|
| 502 间歇出现 | 错误日志关键词 | 后端优雅停机缺失、proxy_next_upstream未含 5xx | 加 preStop 缓冲、按需开 5xx 重试 |
| 504 集中在长接口 | $upstream_response_time | proxy_read_timeout小于后端静默窗口 | 按静默间隔调,不是按总耗时 |
| 全站 502 且配置刚改过 | nginx -t结果 | proxy_pass带变量但无resolver | 补resolver或改用 upstream 名 |
| 连接池不生效 | 配置全文搜 http_version | 漏了proxy_http_version 1.1或Connection "" | 两行必须成对加 |
| 上传 413 | client_max_body_size | 沿用默认 1MB | 按路径分级设置 |
| 日志大量 499 | 前端超时设置 | 后端慢于前端超时 | 优化后端或调前端超时 |
| fd 报警 | worker_rlimit_nofile、systemd unit | limits.conf 对 systemd 无效 | unit 文件里写 LimitNOFILE |
| 负载倾斜 | $upstream_addr分布 | 长短请求混合 | 拆独立 upstream 或换 least_conn |
这张表我放在团队的排障手册第一页,新人半夜被叫起来时照着走,能省掉一大半摸索时间。
6. 几个我认为最值的习惯
配置改完先nginx -t,这谁都知道,但我建议再加一步:在测试环境用真实流量回放跑一遍再上。语法检查只能发现写错指令,发现不了location优先级写错导致的规则不生效——后者要等线上流量验证。
所有改动走版本控制,配置文件入库,reload 之前先 commit。Nginx 配置的回滚成本极低,前提是你知道上一版长什么样。我见过直接vim改线上配置、改错了还没备份的情况,最后是凭记忆往回改的,那种体验不想再来第二次。
监控上,除了常规的错误率,我会单独盯 499 和$upstream_response_time的 P99。499 涨了说明用户在跑,往往比错误率更早暴露问题;upstream P99 涨了说明后端在恶化,这时候 Nginx 还一切正常,但容量已经在悬崖边上。这两个指标比单纯的 5xx 率灵敏得多。