那是一次大促。
倒计时结束,流量开始涌入。监控大屏的曲线往上走,看起来一切正常——直到第十分钟。
后端机器的CPU曲线集体开始往上飙。不是一台,是所有机器同时飙。请求超时告警像刷屏一样涌过来,客服那边的电话已经响了。
所有人盯着屏幕,没人知道发生了什么,因为代码没动、配置没变、容量也是提前评估过的。
最后定位到的根因,只有六个字:keepalive没有配。
Nginx跟后端之间用的是短连接。大促一开闸,每一个请求都要新建TCP连接,后端的accept队列被打满,CPU全扑在握手上,业务处理能力直线下降,然后请求堆积,然后雪崩。
加上这几行配置只需要五分钟:
upstream backend { server192.168.1.10:8080; keepalive64; keepalive_requests1000; keepalive_timeout60s; }
location / { proxy_pass http://backend; proxy_http_version1.1; proxy_set_header Connection ""; }
但我们是在损失了百万流水之后才加上去的。
事故从来不是代码写坏了,往往只是一个参数没配,或者配了个错的。
今天这篇,把进阶配置、Docker/K8s特有问题、热升级姿势,以及五个真实事故从现象到根因到改法的完整复盘。每个坑都附上「正确做法」,对照着把自己的配置检查一遍。
Nginx默认的日志格式,你翻一天也找不到响应时间。请求进来了,状态码是什么,body多大——但完全不知道花了多久,慢在哪个环节。
出了性能问题,这种日志等于没有。
正确做法:自定义日志格式,把这四个字段加进去。
log_format detail '$remote_addr [$time_local] "$request" $status ' 'rt=$request_time ' 'uct=$upstream_connect_time ' 'uht=$upstream_header_time ' 'urt=$upstream_response_time ' '"$http_x_forwarded_for"';
access_log /var/log/nginx/access.log detail;
搞清楚这四个字段的关系,排查问题能少走很多弯路:
(request_time):用户感知到的总延迟
(upstream_connect_time):建立到后端连接花了多久
(upstream_header_time):后端收到请求到开始回响应头
(upstream_response_time):整个后端处理时间
排障公式:rt远大于urt→问题在发送响应的网络;urt高但uct低→后端业务慢;uct本身就高→后端连接队列有压力,或者网络有问题。
有了这四个字段,很多"不知道慢在哪"的问题,一条日志就能定位到方向。
另外,日志切割也是生产必选。高流量系统access_log一天几十GB很正常,不切割磁盘迟早撑爆。用logrotate做每日切割,切完发USR1信号重新打开文件——注意是USR1,不是reload,发reload会重新加载配置,长连接会被影响。
二、WebSocket连接隔几分钟就断?八成是这两个地方没配
做过实时功能的同学大概都遇到过:WebSocket连接建立没问题,但过一会就断了,然后客户端重连,再断,反复循环。
最常见的原因一:超时时间没改。Nginx的proxy_read_timeout默认60秒。WebSocket是长连接,可能几分钟甚至十几分钟没有数据(靠心跳维持),60秒一到,Nginx直接断掉。
最常见的原因二:协议升级头没配齐。很多人只加了Upgrade头,没加Connection头,或者Connection头写死了固定值,导致普通HTTP请求也受影响。
正确的完整配置:
map$http_upgrade$connection_upgrade { default upgrade; '' close; }
location /ws/ { proxy_pass http://websocket_backend; proxy_http_version1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout3600s; proxy_send_timeout3600s;}
还有一个经常被忽略的细节:WebSocket加了负载均衡,连接会乱跳。 WebSocket是有状态的,同一用户的连接被轮询到另一台后端,上下文就没了。
短期解法是upstream用ip_hash,但在用户走NAT或CDN的场景下会退化成单点。真正推荐的方案是让后端无状态化:把连接上下文存Redis,后端不保存任何连接状态,这样轮询分到哪台都没关系,还能横向扩展。
三、Docker里Nginx莫名其妙吃内存?查一下这个参数
容器里用Nginx有一个很隐蔽的坑。
你给容器分配了2个CPU,但Nginx配的是worker_processes auto。结果Nginx读了宿主机的/proc/cpuinfo,看到64核,于是启动了64个worker进程。内存直接上去了,而且这64个worker互相竞争资源,效果反而比2个worker更差。
正确做法:在启动脚本里动态注入核心数。
#!/bin/shCORES=$(nproc)sed -i "s/worker_processes auto/worker_processes ${CORES}/" /etc/nginx/nginx.confexec nginx -g "daemon off;"
注意最后要用exec启动,让Nginx直接成为PID 1。如果用普通方式启动,PID 1是shell,容器收到SIGTERM时shell不会把信号转发给Nginx,在途请求全部强制中断,你以为是优雅退出,其实不是。
K8s里还有另一个坑:DNS缓存导致upstream失效。 Nginx启动时解析了Service域名,缓存起来,之后Pod滚动更新,IP变了,但Nginx还在往老IP打流量,502一直报。
解法是用resolver指令配合变量形式的proxy_pass,强制每次请求都重新走DNS:
resolver kube-dns.kube-system.svc.cluster.local valid=10s;
location / { set $backend "my-service.default.svc.cluster.local:8080"; proxy_pass http://$backend; }
四、 `nginx -s reload`你用了多少年?它其实不是完全无损的
很多人以为reload是无损操作。对于普通HTTP短连接来说,确实几乎无感知。但如果你有WebSocket、大文件下载、长轮询这类长连接——
reload之后,老的worker进程会等待已有连接处理完,但等待有上限。 超过worker_shutdown_timeout,老worker强制退出,正在处理的长连接被中断。
正确做法:根据业务连接时长配这个参数,并在低峰期做变更。
worker_shutdown_timeout 3600s;
热升级(不停服替换Nginx二进制)的步骤也有讲究,很多人在这里犯错:
OLD_PID=$(cat /var/run/nginx.pid)
kill -USR2 $OLD_PID
kill -WINCH $OLD_PID
kill -QUIT $(cat /var/run/nginx.pid.oldbin)
热升级中间绝对不能kill -9老进程 ——这会瞬间断掉所有老连接,完全失去热升级的意义。
事故一:upstream写了个下线的IP,全站502持续五分钟
有人手动改了生产配置,写了一个已经下线的机器IP,reload后Nginx开始往那台机器打流量,三次失败后被动健康检查才把它标记掉,期间所有请求都在超时。
最佳实践:配置变更走Git,不允许直接改生产文件;upstream写服务发现的域名,不写死IP;把健康检查参数收紧(max_fails=2 fail_timeout=10s),让发现时间从分钟级降到十几秒。
手动申请的年度证书,没设任何续期提醒。某天早上九点用户开始投诉,紧急申请、替换、reload,40分钟期间网站完全打不开。
最佳实践:能用Let's Encrypt自动续期就用,加上cron每天运行certbot renew;同时配独立的到期监控,提前30天告警,不能把"续期成功"作为唯一的保障。有条件就直接上云托管证书,自动续期、自动部署,什么都不用操心。
0 8 * * * DAYS=$(openssl x509 -in /etc/nginx/ssl/cert.pem -noout -enddate \ | cut -d= -f2 | xargs -I{} date -d {} +%s \ | xargs -I{} expr {} - $(date +%s) | xargs -I{} expr {} / 86400); \ [ "$DAYS" -lt 30 ] && echo "证书还有${DAYS}天过期" | mail -s "SSL证书告警" ops@yourco.com
事故三:一个if指令,POST请求全部404,排查了两个多小时
有同学想按请求方法把流量分到不同后端,在location里用if写了分流逻辑,结果POST请求走到了一个没有对应接口的后端,全部404。查了后端代码、路由、数据库,全部没问题,最后才想到去查Nginx配置。
最佳实践:Nginx的if在location块里行为极其反直觉,能不用就不用。按请求属性做流量分发,用map实现:
location /api/ { if ($request_method = GET) { proxy_pass http://backend_a; } proxy_pass http://backend_b;}map$request_method$api_backend { GET http://backend_a; default http://backend_b;}location /api/ { proxy_pass$api_backend;}
事故四:gzip压了JSON,前端fetch拿到乱码
Nginx对application/json开了gzip,gzip_proxied配成了any,会无条件压缩所有响应。前端的自定义fetch封装没带Accept-Encoding: gzip请求头,但收到了压缩响应,直接拿二进制解析JSON,全是乱码。
最佳实践:gzip是协商机制,服务端应该尊重客户端的声明。gzip_proxied不要设any,应该检查请求头:
gzip_proxied any;
gzip_proxied expired no-cache no-store private no_last_modified no_etag auth;
事故五:就是开头说的那次大促——keepalive没配
最佳实践:keepalive不是可选项,是高并发场景的基础配置。而且光配还不够,要在压测里验证。压测脚本要模拟真实的连接复用行为,不要只测接口吞吐量,要看TIME_WAIT的积累速度。大促前必须做压测,不能靠猜。
这些坑,事后看每一个都显而易见。但出事的时候,告警在刷屏、用户在投诉、领导在问,你根本没有时间静下来思考。
所以这些事要提前做。在配置review的时候过一遍,在压测的时候验证一遍,在大促前的checklist里对照一遍。出了事才补,代价太高了。