ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

生产环境Nginx代理WebSocket全指南:从断连排查到集群架构

生产环境Nginx代理WebSocket全指南:从断连排查到集群架构 做WebSocket服务的人大概率都经历过这个场景本地联调一切正常代码也没问题一旦把服务挂到Nginx后面客户端要么连不上要么连上十几秒就掉浏览器控制台翻来覆去就那么几行——WebSocket connection to ws://xxx failed[websocket] onclose code: 1006重连还在原地打转。这篇文章就把Nginx在生产环境代理WebSocket实时长连接这件事完整过一遍先讲清楚它为什么会断再给可以直接抄的配置然后结合线上真实批量断连的排查过程聊会话保持、集群路由和日常巡检。写的时候尽量还原我实际踩坑的思考过程而不是给一份干巴巴的官方文档翻译。1. 先搞明白一件事WebSocket过Nginx为什么会断1.1 协议跃迁从HTTP升级到WebSocket的101很多人配Nginx反代WebSocket第一步就照抄了一段配置抄完发现能通但不知道为什么一旦出问题就完全没有排查方向。我建议还是先把WebSocket握手机制吃透。WebSocket的握手本质上走的还是HTTP流程。客户端发一个HTTP GET请求请求头里带Upgrade: websocket和Connection: Upgrade服务端如果同意切换协议就返回一个101 Switching Protocols。从这之后这个TCP连接就不再走HTTP的请求/响应语义了而是直接变成WebSocket数据帧的双向通道。如果你把普通HTTP请求理解成一次性快递签收即结束WebSocket就是水管接好之后一直通水。Nginx要做的不是帮你把水龙头拧一下就走而是要保证这根水管在双方不主动关掉之前一直保持畅通、双向通水。这里就引出了Nginx代理WebSocket的第一个核心矛盾Nginx本身是个HTTP反向代理它默认处理的是一次性的HTTP请求。当一个请求升级成WebSocket之后Nginx必须从代理HTTP请求切换到透传TCP隧道的模式。这个切换靠的就是握手阶段那几个请求头。1.2 真正坑人的是Connection和Upgrade新手配WebSocket反代最常见的错误就是只写了一个proxy_pass然后发现浏览器一直握手失败。原因在于Nginx默认不会把客户端的Connection和Upgrade头转发给后端。HTTP协议里Connection头是逐跳hop-by-hop头部按规范它只对当前一跳有意义不应该被代理继续传递。Nginx严格遵守了这个语义默认会把Connection头吞掉。于是后端收到的HTTP请求里没有Upgrade: websocket自然不会返回101前端WebSocket连接就卡死在握手阶段。解决思路就是手动把这两个头补回去同时兼顾普通HTTP请求不受污染。这里有一个Nginx官方推荐的经典写法map $http_upgrade $connection_upgrade { default upgrade; close; }这段配置的作用是当客户端请求带Upgrade头时$connection_upgrade变量变成upgrade当客户端没带比如同域名下的普通接口请求时就变成close。这样你后面用proxy_set_header Connection $connection_upgrade;既能满足WebSocket升级要求又不会给普通HTTP请求乱加头。很多教程图省事直接写proxy_set_header Connection upgrade;在只跑WebSocket的location里问题不大但如果这个location偶尔还要处理普通请求就可能出现奇怪的转发问题。用map做映射是更稳的做法。1.3 proxy_http_version 1.1是硬前提另一个容易忽略的点proxy_http_version。Nginx向上游服务器发起请求时默认使用HTTP/1.0。而HTTP/1.0里压根没有Upgrade机制的定义Connection头的语义也很有限只有keep-alive和close。所以哪怕你转发了一堆Header只要proxy_http_version还是默认的1.0有些后端根本不会识别升级请求。显式声明成1.1proxy_http_version 1.1;这行配置几乎成了WebSocket反代的三件套之一。你可以把它理解成跟后端约定好我用一套支持协议升级的对话方式跟你说话。下面是常见配置和现象对照排查时可以拿着对号入座配置/场景现象只写proxy_pass没有其它处理握手失败后端收到普通GET不返回101转发了Upgrade但没设proxy_http_version 1.1部分后端/HTTP/1.0连接不支持Upgrade语义表现随机连接建立成功但隔一段时间就断大概率是proxy_read_timeout默认60秒触发连接批量断开客户端集体重连优先查后端单节点故障、连接数、健康检查2. 生产级核心配置逐行拆解与完整落地2.1 从最小可用配置到生产增强先给一份完整的Nginx配置生产环境可以直接改改IP就用# 放在 nginx.conf 的 http 块内 map $http_upgrade $connection_upgrade { default upgrade; close; } upstream ws_cluster { server 10.0.1.10:8080 max_fails2 fail_timeout30s; server 10.0.1.11:8080 max_fails2 fail_timeout30s; } server { listen 80; server_name ws.example.com; location /ws { proxy_pass http://ws_cluster; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; 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 10s; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; } }逐行说下几个容易被忽略的点。upstream里我加了max_fails2 fail_timeout30s。这是Nginx的被动健康检查如果向后端建连或转发握手请求时失败就累计失败次数30秒内失败达到2次就暂时把节点摘除。注意这只是连接建立失败时的保护一旦WebSocket连接已经建立中途后端挂了Nginx是感知不到的因为它不会主动去探测一条长连接的健康状态。这个问题第3章会展开。Host、X-Real-IP、X-Forwarded-For这几个头作用是让后端拿到真实的客户端地址和域名。WebSocket业务里经常要按用户IP做限流、按域名做路由这几个头不传后端看到的全是Nginx内网IP很多功能就废了。2.2 超时时间、缓冲开关与头部透传WebSocket连接连上后过一会儿就自动断开90%的根因都在超时时间上。Nginx里默认的proxy_read_timeout是60秒它的含义是Nginx从上游服务器读取数据时两次读操作之间的最大间隔。注意它不是连接总时长而是如果这么长时间没有从后端读到任何数据就判定超时。对普通HTTP接口来说60秒完全够用。但WebSocket是一条长连接客户端和服务端可能好几分钟都不发一条消息只靠心跳维持。如果心跳间隔超过60秒proxy_read_timeout就会触发Nginx主动把连接掐了客户端那边表现为1006异常关闭。生产环境我建议把proxy_read_timeout和proxy_send_timeout调到3600秒起。如果业务有心跳且心跳间隔固定比如30秒一次Ping/Pong600秒也够如果完全没有心跳那就按业务能接受的最大静默时间乘以2来设。这里有一个地方要提醒WebSocket协议自带Ping/Pong控制帧只要业务层在用心跳Nginx读超时会被帧刷新所以别把连接断全部甩锅给Nginx。很多时候是服务端或客户端压根没发心跳Nginx只是那个执行断开的人。再提proxy_buffering off。WebSocket数据是要实时到达的缓冲会带来额外的延迟和粘包感。虽然握手完成后Nginx基本退化成数据帧透传但把这个开关显式关掉能减少握手阶段和切换阶段的诡异延迟建议加上。2.3 配置校验与平滑加载改完配置之后老生常谈的两步nginx -t nginx -s reloadnginx -t检查语法nginx -s reload平滑重载。很多人担心reload会把在线WebSocket用户全部踢掉这里明确说一下不会。Nginx reload时master进程会重新读取配置并启动新的worker进程老worker进程会继续处理尚未完成的请求包括已经建立的WebSocket长连接直到这些连接关闭后才退出。所以日常改配置、加节点对在线长连接基本无感。但有几个坑要提醒nginx -t只校验语法不校验upstream里的IP是否可达也不校验证书文件是否存在reload时如果某个被引用的文件路径写错Nginx会回滚到旧配置并在error.log里记一条emerg级别的日志这一步很多人不看log以为reload成功就完事了不要把reload和restart搞混restart是冷启动所有长连接瞬间全断生产环境千万别这么干。2.4 为什么选http模块而不是stream模块聊到Nginx做WebSocket负载均衡一定会有人问为什么不用stream模块做四层TCP转发确实四层转发也能把TCP长连接透传到后端配置还更简单stream { upstream ws_backend { server 10.0.1.10:8080; server 10.0.1.11:8080; } server { listen 8080; proxy_pass ws_backend; proxy_timeout 1h; } }从TCP层面看这是可用的。但问题在于stream模块工作在四层它看不到HTTP路径、Host头、Header做不了/ws/chat和/ws/live这种按路径分流做不了Header注入也做不了基于HTTP的鉴权子请求。如果一台Nginx上不止一个WebSocket业务用stream基本只能靠端口区分端口管理会变得很难受。我个人的选型原则是能走七层绝不走四层除非你的需求就是我不管里面是什么协议你给我透传。绝大多数生产WebSocket场景用http模块就够了。3. 实例复现线上批量1006断连的完整排查链路3.1 第一眼浏览器控制台全是1006前阵子帮一个朋友排查线上问题他们的架构是前端Vue后端Golang的Gin框架加gorilla/websocketNginx反代两个后端节点。上线第一天一切正常第二天早上开始在线用户陆续掉线。掉线的特征非常统一浏览器控制台反复出现[websocket] onclose code: 1006然后客户端自动重连重连成功之后过一会儿又掉。先解释一下1006WebSocket关闭码里的1006表示异常关闭连接层没有收到Close帧。它不是业务错误码而是连接被某个环节硬生生掐断了。TCP层直接断掉、Nginx超时回收、后端进程重启都会表现为1006。3.2 看日志Nginx记录里的prematurely closed connection排查的第一步是打开Nginx的error.log我看到了大量这样的记录upstream prematurely closed connection while reading response header from upstream这句话的意思是Nginx正在等待后端返回响应头后端却把连接提前关闭了。也就是说问题大概率不在Nginx而在上游节点。再对一下access.log正常WebSocket握手成功在日志里应该是status101但发现不少连接在101之后紧接着出现了status499。499是Nginx在客户端主动断开连接时记录的状态码说明用户在连接建立后又被断开。到这里可以确定Nginx本身没有乱断连接是后端先出了问题导致客户端连接被断开断开后的重连又触发了新的握手请求。3.3 定位根因轮询不均衡与单节点故障继续往后端查。用ss -s看系统连接数发现两台WebSocket节点的活跃连接数差距很大其中一台的连接数是另一台的两倍还多。原因其实不复杂Nginx默认的轮询策略只在新建连接时起作用而WebSocket是长连接一旦建立就长期固定在某台节点上。不同用户在线时长不一样时间一长节点之间的连接数自然慢慢倾斜。再看后端日志连接数多的那台节点goroutine数量异常GC频繁心跳读超时被触发服务端主动Close了一部分连接。客户端那边没有退避机制断线后马上重连重连风暴直接把Nginx和另外一台节点也拖入高负载状态最终表现为大批量1006。另外一个有意思的线索来自压测工具。团队用JMeter的WebSocket Sampler做压测时脚本里反复报stream disconnected before completion: failed to send websocket request: io。这个报错的本意是脚本还在往一条已经被关闭的WebSocket连接上发送请求。很多人的第一反应是改脚本但顺着报错往回查你会发现背后真实原因是服务端把连接断了。排查思路应该是先找出连接被谁断开、为什么断开而不是在脚本层逃避。3.4 这一路踩出来的改进项这个Case最终落地了这么几件事upstream块里加了max_fails和fail_timeout被动健康检查先把异常节点摘除客户端重连改成指数退避加随机抖动从1秒、2秒、4秒递增最大30秒避免重连风暴后端放宽读写Deadline心跳超时不再一次超时就断连接改成连续多次超时才判定异常补全后端日志记录连接ID、建立时间、断开原因方便按连接维度回溯增加独立健康检查链路定期向后端发起WebSocket探测请求发现异常及时摘除节点。这套组合拳打完之后线上1006断连基本消失了。它给我最大的教训是Nginx在WebSocket场景下只是透明通道它不会主动维护连接健康也不会帮你处理客户端重连风暴。真正的根因治理往往在Nginx之外。4. 会话保持与路由策略长连接场景下的负载均衡算法选型4.1 为什么默认轮询在长连接场景下会让你栽跟头默认的round-robin轮询是按新连接平均分配的。这在短请求场景下没问题但WebSocket这种长连接一旦建立就会一直留在某个节点上。后果有两个不同节点的连接数慢慢失衡有的节点快满了有的还很闲用户断线重连时新连接可能被路由到另一台节点。如果后端在进程内存里维护着用户ID到连接对象的映射就会发生新连接在B节点推送却打到A节点的错乱。你想想用户在A节点连着突然网络抖动重连后连接被Nginx分配到B节点。如果代码里没做连接状态的全局同步A节点还认为这个用户在线B节点也认为这个用户在线但推送只发到A节点A节点对应的连接其实已经废了消息就彻底丢了。所以对带内存会话的长连接业务来说默认轮询不是一个好选择。4.2 用ip_hash还是用一致性哈希如果后端一时半会改不动最直接的缓解方案是用哈希类负载均衡算法让同一类来源的连接尽量固定到同一节点upstream ws_backend { ip_hash; server 10.0.1.10:8080; server 10.0.1.11:8080; }ip_hash按客户端IP哈希同一个IP的请求会固定落到同一台后端节点。它解决了一部分会话错乱问题但也有明显的坑大量用户走移动/企业出口同一个公网IP后面可能挤着几万用户ip_hash会把这些用户全压到同一台节点上用户切换网络后IP变了哈希结果也会变会话照样丢节点扩容时ip_hash的映射会部分重排但影响可控。相比ip_hash更平滑的做法是一致性哈希upstream ws_backend { hash $binary_remote_addr consistent; server 10.0.1.10:8080; server 10.0.1.11:8080; }consistent关键字启用一致性哈希节点增删时只影响相邻一小部分连接对长连接业务友好很多。$binary_remote_addr是客户端IP的二进制形式比字符串形式的计算更稳定。但要明确这些都是缓解方案不是根治方案。哈希算法解决的是同一来源尽量落在同一节点一旦用户量上来、节点扩容频繁哈希策略会越来越难维护。4.3 终极方案后端无状态化路由表外置真正适合生产环境的长连接架构应该是让后端节点不记忆用户状态把所有连接状态外置到Redis这类中间件里。思路是这样的每台WebSocket节点只负责持有连接、收发帧不再保存完整的用户会话连接建立时在Redis写一条路由记录例如键ws:online:{uid}值是serverId:connId带TTL并按需续期连接断开时删除对应路由记录推送消息时先查路由表目标用户落在本机就直接写连接落在别的节点通过内部RPC或者Redis Pub/Sub转发过去。这样Nginx层用什么负载均衡算法都无所谓了因为节点不再需要记住用户在不在自己这里路由信息是全局可见的。我见过很多团队为了省事先用ip_hash顶着等到节点扩容、连接歪斜的时候才发现最终还是得做无状态化。如果项目还没上线建议从一开始就按这个思路设计。5. 从单节点到WebSocket集群网关、路由、广播与服务治理5.1 集群拓扑把连接状态从进程内存里挪出去当一个WebSocket服务开始上规模架构一般会变成这样客户端 - 边缘Nginx统一入口、TLS终止、限流、路由 - WebSocket节点池连接在这里维持 - Redis或消息队列路由表、广播、在线状态边缘Nginx的价值是收敛入口。用户不需要知道集群里有多少台节点也不应该直连后端的8080端口。生产环境我会把后端端口只暴露在内网Nginx是唯一对外的入口。这个设计同时给后端加了一层保护即使有人扫描端口也摸不到真正的WebSocket服务。5.2 单点推送与全员广播的两种路径连接状态外置之后消息路由就变得很清晰了。单点推送查路由表判断目标用户连接在哪台节点。在本机就直接写连接对象在其它节点就把消息通过内部通道转过去。全员广播让所有WebSocket节点订阅同一个广播通道发布方只需要往通道里丢消息各个节点收到后遍历本机连接进行推送。Redis Pub/Sub就能实现这个逻辑适合中小规模如果消息量大、需要更可靠的投递保障就上MQ。选型的核心是你能否接受消息丢失Redis Pub/Sub的定位是实时分发不保证可靠投递重要消息建议走MQ。后端框架这块Gin配gorilla/websocket、Spring的WebSocket、Netty都很常见。无论用哪个多实例广播的思路都一样进程内连接列表加上一个外部事件通道。Spring的SimpMessagingTemplate做多实例广播时底层也离不开这个模式。5.3 鉴权、限流、wss落地这些事该放在哪一层WebSocket的鉴权有个特殊性握手是整个连接生命周期里唯一的一次HTTP请求。Token必须在握手阶段就带上要么放在URL参数里要么放在Header里后续的WebSocket帧里没法补做鉴权。网关层可以用Nginx的auth_request模块在握手前发一个子请求到认证服务校验Token后端也要在升级连接前再做一次校验。这种网关粗校验后端严校验的双层模式能防止有人绕过Nginx直连后端端口。限流方面Nginx的limit_conn很适合限制同一IP的最大并发连接数。WebSocket长连接场景下主要就是防止有人用大量连接打满资源这个指令比limit_req更对症。然后是wss落地。HTTPS页面发起ws://的连接会被浏览器按混合内容直接拦截所以只要网站是HTTPSWebSocket就必须走wss://server { listen 443 ssl; server_name ws.example.com; ssl_certificate /path/to/fullchain.pem; ssl_certificate_key /path/to/privkey.pem; location /ws { proxy_pass http://ws_cluster; # 核心配置同上不重复 } }证书链不完整、配置的证书文件路径错误是wss握手失败最常见的原因排错时先看Nginx error.log。5.4 常见打包成App就连不上的正确姿势网上经常看到vue加了websocket打包成App就连接不了的问题。大多数情况下这不是Nginx的锅而是环境不匹配页面跑在HTTPS里但连接用的是ws://被WebView拦截App的WebView里强制启用了混合内容限制或者Android的明文流量没有打开服务端只监听了127.0.0.1App根本访问不到SSL证书链不完整wss握手失败。从Nginx视角能做的就是确认监听端口对外可达、SSL证书链完整、日志里能看到来自App的握手请求以及返回的status。如果Nginx日志里压根没有这条请求说明请求根本没到Nginx问题出在客户端或网络层。6. 维持生产级监控指标、优雅发布与容量规划6.1 三张必须每天看的表连接数、错误日志、状态码长连接系统上线之后我习惯把下面这些信息当成日常巡检的三张表连接数ss -s看系统总连接数ss -tan state established ( sport :8080 )看单端口连接数错误日志Nginx error.log里过滤prematurely closedaccess.log里统计499状态码的出现频率业务侧断开原因按日志里的关闭码聚合1006代表异常断开1001代表服务端主动关闭。如果1006占比突然上升大概率又有人在乱断连接。还可以用命令查看Nginx和后端各自的连接数分布判断负载是否倾斜。连接数长期不均衡说明单纯靠轮询建连无法自动收敛需要考虑健康检查或调度策略调整。6.2 滚动发布怎么不让存量长连接遭殃WebSocket服务发布是最容易引发线上事故的环节。后端一重启所有长连接全断客户端集体重连瞬间打满网关。我最常用的方式是发布前先把节点从upstream里摘除upstream ws_backend { server 10.0.1.10:8080 down; server 10.0.1.11:8080; }执行nginx -s reload之后新连接不会再进10.0.1.10存量连接继续由老进程维持等连接数降到0再重启服务。注意发完记得把配置改回来。如果业务要求更快下线后端收到关闭信号时主动给客户端发一个Close帧关闭码用1001意思是服务端要走了。客户端收到1001后主动发起重连而不是被动等TCP超时用户感知会小很多。这个优雅关闭逻辑做得好不好直接决定发布时用户会不会在群里骂人。6.3 容量粗估从fd数到worker_connections做容量规划前先记住一个关键数字一条用户WebSocket连接经过Nginx时Nginx到客户端占一个文件描述符fdNginx到后端再占一个也就是说一条连接要消耗两个fd。按照1万在线连接来算Nginx至少需要2万个fd。所以这几个参数必须调worker_processes auto; worker_rlimit_nofile 65535; events { worker_connections 65535; }worker_connections表示单个worker进程能处理的最大连接数worker_processes乘以worker_connections要大于预估峰值连接数的两倍才够用。系统层面还要把ulimit和fs.file-max放开。内存方面Nginx每个连接占用从几KB到几十KB不等Go的goroutine大概4KB起步Java Netty一条连接含各种Buffer可能几百KB估算容量时按语言特性留足余量。如果并发量大还要关注net.ipv4.ip_local_port_range和tcp_tw_reuse这些内核参数因为它们影响Nginx向后端发起的大量出站连接。6.4 顺手一提Windows环境的Nginx定位搜索记录里经常看到win server 11修改nginx端口号windows server 2016部署nginx这类问题。Windows上的Nginx只建议用来开发调试不建议生产环境使用。原因主要有两点Windows下Nginx走的是不同的事件模型性能比Linux差不少部分指令和行为在Windows上支持不完整出了线上问题很难排查。如果只是在Windows上临时改个端口修改conf里的listen后执行nginx -s reload就行但正经业务还是尽早放到Linux环境跑吧。最后分享一个我自己的习惯每次上线WebSocket前用JMeter的WebSocket Sampler先压一轮不用太久跑30分钟以上重点看两个数据断线率、服务端主动关闭连接的数量。很多连接层面的问题压测比人肉点页面更容易暴露。尤其当压测脚本报出stream disconnected before completion: failed to send websocket request这类错误时不要急着改脚本顺着报错往回查大概率能揪出真实的连接回收方。压测能过的系统上线才睡得着觉。
返回列表