ARTICLE DETAIL

资讯详情

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

WebSocket跨域与Nginx反代排查:从原理到生产实战

WebSocket跨域与Nginx反代排查:从原理到生产实战 刚接手一个老项目时我花了整整一个下午才定位到线上WebSocket连接频繁掉线的根因——既不是网络抖动也不是服务端负载过高而是Nginx反代没有透传Upgrade头。类似的坑我踩过不少。WebSocket的跨域问题和普通HTTP接口的跨域看着相似实则是两套完全不同的机制很多前端同学用配置CORS的思路去解决WebSocket跨域结果越配越乱。这篇文章把我这几年的实战排查经验整理出来从原理到代码再到生产环境里最容易忽略的几个隐蔽问题一次讲透。1. 一次线上事故复盘WebSocket连接被“静默拦截”的完整过程1.1 事故现场监控告警与页面假死那是一次版本上线后的半小时运维群里突然连续弹出告警网关服务WebSocket连接数从正常的两千多直接掉到不足一百紧接着客服系统页面开始出现大面积“消息发送失败”的提示。用户在页面上的操作没有报错弹窗但聊天消息一直转圈随后页面自动刷新重连成功后再掉线陷入死循环。我打开浏览器控制台看到的是这样一段日志WebSocket connection to wss://api.example.com/ws failed: Error during WebSocket handshake: Unexpected response code: 200这个报错非常典型但也很有迷惑性。它说的是“握手失败响应码是200”。很多人第一反应是去看后端接口怀疑是服务端逻辑出了问题。但实际上这是Nginx把WebSocket握手请求当成普通HTTP请求转发给了后端后端拿到的根本不是Upgrade请求自然就按普通接口返回了200响应。浏览器期望的是101 Switching Protocols收到200就认为握手失败。1.2 排查链路从Network面板到服务端日志当时我按顺序做了几步排查这也是我后来处理所有WebSocket连接问题的标准动作第一步打开DevTools的Network面板筛选WS类型找到握手请求查看Response Headers里的状态码。如果看到的是101说明协议切换成功问题多半在后续的数据传输阶段如果看到200、403、502这类状态码说明握手阶段就挂了。第二步看请求头里有没有Upgrade: websocket和Connection: Upgrade。如果浏览器发出的请求头里根本没有这两个头那问题很可能出在中间层如果请求头有但响应头变成了普通HTTP响应那问题就出在反代或网关。第三步直接绕过Nginx用本机连后端服务的原始端口验证后端WebSocket服务本身是否正常。这一步能快速区分问题在应用层还是网络代理层。第四步查Nginx错误日志和后端访问日志。那次我们在Nginx的access log里发现请求到达时携带的Connection头已经变成了keep-alive而Upgrade头直接消失——Nginx默认不会把这两个头原样转发给上游必须显式配置。1.3 一个反直觉的结论CORS配置正确WebSocket却依然连不上那次事故的根本原因是反代配置缺失不是跨域问题。但排查过程中我注意到一个更普遍的现象很多同事在本地联调时前端跑在http://localhost:5173后端WebSocket跑在http://localhost:3000明明已经在后端加了一堆CORS中间件Access-Control-Allow-Origin头也正常返回了但WebSocket就是连不上或者连上了发消息报错。原因很简单WebSocket的跨域控制根本不走CORS那套浏览器拦截机制而是由服务端检查Origin头来决定是否接受连接。浏览器对普通XHR/fetch请求的跨域限制是通过预检请求和CORS响应头配合完成的而WebSocket握手请求本身就是个普通HTTP GET浏览器会主动带上Origin头但不会因为服务器没返回CORS头就拦下这个连接。服务端如果不检查Origin跨域连接照样能建立成功。这就是为什么“明明配了CORS却没用”——你配置的响应头在HTTP接口场景下有效在WebSocket场景下压根不是决定因素。2. 原理拆解WebSocket跨域和HTTP跨域的根本差异2.1 握手请求本质上是一个普通的HTTP GETWebSocket的连接建立过程可以理解为一次“协议升级”。浏览器先发一个包含Upgrade: websocket头的HTTP GET请求服务端如果同意切换协议返回101状态码之后这条TCP连接上的数据就开始按WebSocket帧格式传输。这意味着什么意味着握手阶段所有规则都遵循HTTP协议。请求会带上Origin头、Cookie头会走代理、会命中也可能被Nginx缓存所有这些你平时调HTTP接口遇到的中间层问题WebSocket握手时一个都躲不掉。不同之处在于握手成功后连接从HTTP切换到了WS协议中间层不能再按HTTP规则去解析后续数据。2.2 浏览器在这里做了什么Origin头与同源策略的边界很多人把同源策略想象成一道“防火墙”以为浏览器会阻止所有跨域请求。其实更准确的理解是浏览器阻止的是“跨域请求被本地JS读取响应”这件事而不是阻止请求发出。对于普通XHR/fetch跨域请求会发出去只不过浏览器在JS拿不到响应前拦截了你。WebSocket的情况更特殊。浏览器在发起握手时会自动带上Origin头这个头告诉服务端“我是从哪个页面发起的连接”。但浏览器本身不会因为Origin跨域就阻断握手过程它把最终决定权交给了服务端。服务端如果允许连接就建立如果服务端不检查那跨域WebSocket连接也会成功并且JS可以自由收发消息。这就是WebSocket跨域和HTTP跨域最核心的差异HTTP跨域是浏览器强制执行的WebSocket跨域是服务端自行决定的。理解了这一点你就知道解决WebSocket跨域问题的正确方向——不是在浏览器层面想办法而是去服务端和中间层配置Origin校验。2.3 为什么“什么都没配却能连上”才是更危险的事我见过不少开发者在本地调试时发现前端代码里new WebSocket(ws://192.168.x.x:8080)后端啥都没配连接竟然成功了。有人觉得这是好事说明WebSocket没跨域限制那是大错特错。这个现象暴露的是服务端安全配置的缺失。如果你的WebSocket服务允许任意来源连接就意味着任何网站都可以在用户浏览器里悄悄向你的服务发起WebSocket连接然后通过消息协议尝试读取或操作用户数据。这被称为“跨站WebSocket劫持”Cross-Site WebSocket Hijacking本质是CSRF在WebSocket场景下的变种。所以一个正确的服务端实现必须显式校验握手中的Origin头允许的来源列表里没有的直接拒绝连接。现实中很多框架默认不校验或者网上很多教程为了省事直接写成echo Access-Control-Allow-Origin: *。在我维护的项目里这个位置是安全审查的重点。2.4 混淆点为什么本地联调报跨域生产环境却没事还有一个让很多人困惑的现象开发环境前端和后端端口不同频繁遇到跨域问题生产环境前端静态资源放在CDN或同域Nginx下反代把/ws路径转发到后端页面和接口同源了一切正常。这不是跨域问题消失了而是生产环境通过反代把跨域变成了同域。大部分团队的生产环境都是这个架构所以WebSocket跨域的坑往往只在本地联调阶段暴露线上反而因为“同域”而掩盖了配置缺失。但一旦你有多个子域名、多个机房、或者第三方接入方需要直连这个问题就会立刻炸出来。这也是我建议在开发阶段就按生产标准配置Origin白名单的原因别图省事直接放行所有来源。3. 从零搭建一套生产可用的跨域WebSocket方案3.1 后端Node.js ws库显式校验Origin和token先说明一个选型问题。Node.js生态里主流的WebSocket库是wsExpress的express-ws底层也是它。ws库默认不校验Origin所以我们必须自己写。我用的是WebSocketServer({ noServer: true })配合HTTP Server的upgrade事件来做手动校验好处是可以把鉴权和Origin检查统一放在握手之前逻辑清晰且可控。const http require(http); const { WebSocketServer } require(ws); // 白名单生产环境建议从配置中心读取不要写死在代码里 const ALLOWED_ORIGINS new Set([ https://app.example.com, https://admin.example.com, ]); // token校验函数实际项目里可以换成JWT验证 function verifyToken(token) { return token token process.env.WS_TOKEN; } const server http.createServer((req, res) { res.writeHead(200, { Content-Type: text/plain }); res.end(ws server running); }); const wss new WebSocketServer({ noServer: true }); server.on(upgrade, (req, socket, head) { const origin req.headers.origin; const url new URL(req.url, http://localhost); const token url.searchParams.get(token); // 1. 校验Origin if (!origin || !ALLOWED_ORIGINS.has(origin)) { socket.write(HTTP/1.1 403 Forbidden\r\n\r\n); socket.destroy(); return; } // 2. 校验token if (!verifyToken(token)) { socket.write(HTTP/1.1 401 Unauthorized\r\n\r\n); socket.destroy(); return; } // 3. 手动升级连接 wss.handleUpgrade(req, socket, head, (ws) { wss.emit(connection, ws, req); }); }); wss.on(connection, (ws, req) { console.log(客户端已连接, origin:, req.headers.origin); ws.on(message, (data) { // 这里处理业务消息记得用JSON.parse包一层try/catch ws.send(echo: ${data}); }); ws.on(close, () { console.log(客户端断开); }); }); server.listen(3000, () { console.log(WebSocket服务已启动: ws://localhost:3000); });这段代码里有几个细节值得注意。ALLOWED_ORIGINS用Set而不是数组是因为Set.has()是O(1)查询虽然白名单规模一般很小但语义上更合适。校验顺序先Origin再token因为Origin校验的成本低可以先挡住大量无效请求减轻token校验的压力。url.searchParams.get(token)是从查询参数里取token。WebSocket的浏览器API没法自定义Header所以token通常只能放在query里或者子协议Sec-WebSocket-Protocol里后者的用法更规范但兼容性需要额外处理为了简单我一般先用query方案。socket.write(HTTP/1.1 403 Forbidden\r\n\r\n)这一段是手动返回HTTP错误响应。注意这里必须自己写完整的状态行和空行之后立即destroy()销毁socket。如果你不写这个响应就直接destroy客户端看到的会是连接被重置而不是清晰的403排查问题时少一个线索。3.2 前端连接逻辑与错误分类前端这边我见过太多人把WebSocket的错误处理写得非常粗糙ws.onerror里只打一行日志ws.onclose里无脑重连。这样做的后果是服务端因为token过期拒绝连接时客户端会陷入无限重连的死循环白白消耗资源。实践中我建议把连接逻辑封装成一个带状态管理的类class WsClient { constructor({ url, token, onMessage, onStatusChange }) { this.url url; this.token token; this.onMessage onMessage; this.onStatusChange onStatusChange; this.ws null; this.reconnectAttempts 0; this.maxReconnectAttempts 5; this.shouldReconnect true; } connect() { const wsUrl new URL(this.url); wsUrl.searchParams.set(token, this.token); this.ws new WebSocket(wsUrl.toString()); this.ws.onopen () { this.reconnectAttempts 0; this.onStatusChange?.(connected); // 连接成功后立即发送一次心跳确认链路畅通 this.startHeartbeat(); }; this.ws.onmessage (event) { this.onMessage?.(JSON.parse(event.data)); }; this.ws.onerror () { // onerror之后必定会触发onclose所以不要在onerror里重连 this.onStatusChange?.(error); }; this.ws.onclose (evt) { this.stopHeartbeat(); this.onStatusChange?.(disconnected); // 1008是服务端主动拒绝说明token或Origin有问题 // 这种情况重连没有意义必须停下来等用户干预 if (evt.code 1008) { this.shouldReconnect false; console.warn(连接被服务端拒绝请检查token或联系管理员); return; } // 1006是异常断开可能是网络问题或服务端重启 if (evt.code 1006 this.shouldReconnect) { this.scheduleReconnect(); } }; } scheduleReconnect() { if (this.reconnectAttempts this.maxReconnectAttempts) { console.error(重连次数过多停止重连); this.onStatusChange?.(failed); return; } // 指数退避1s, 2s, 4s, 8s, 16s const delay Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); this.reconnectAttempts 1; setTimeout(() { if (this.shouldReconnect) { this.connect(); } }, delay); } close() { this.shouldReconnect false; this.stopHeartbeat(); this.ws?.close(1000, client closed); } }这里有几个设计决策需要解释。第一onerror里不要重连。WebSocket的错误回调后面一定会跟一个close回调如果在onerror里就触发重连那么onclose里又会触发一次导致重连请求翻倍。我只在onclose里处理重连逻辑onerror只负责状态上报。第二区分1006和1008。1008是服务端返回403后浏览器映射出来的关闭码表示“策略违规”几乎可以肯定是token失效、Origin被拒绝或者权限不足。这种情况重连一百次也没用。1006是异常断开掉线原因未知才需要重连。很多初学者的代码不区分这两个码导致服务端主动踢人的时候客户端反而疯狂重连如同僵尸。第三指数退避。第一次重连延迟1秒第二次2秒第三次4秒以此类推最大30秒。这个策略既能保证网络抖动后快速恢复又不会在服务端故障时打爆网关。而且我设了最大重连次数超过5次就彻底停止避免死循环。3.3 前后端联调时的配置对齐清单跨域WebSocket联调失败八成是前后端之间的配置没对齐。我把需要确认的点整理成了清单每次联调前过一遍检查项前端配置后端配置常见错误连接地址ws://localhost:3000/ws监听端口3000地址写错协议ws和wss混用Origin白名单页面地址http://localhost:5173白名单包含该地址漏掉端口浏览器Origin带端口注意匹配token存在localStorage或内存握手时校验通过前后端token字段名不一致反向代理wss://api.example.com/wsNginx把/ws转发到3000代理层没配Upgrade头透传心跳间隔客户端定时发ping服务端超时断开两端心跳周期不一致导致服务端误杀这里面最容易出问题的是第二项——端口。浏览器的Origin是完整的scheme://host:porthttp://localhost:5173和http://localhost:5174是两个完全不同的来源。如果你在本地起了多个前端项目白名单配错一个数字排查起来非常痛苦。我的建议是开发环境直接配一个正则或者把整个localhost段都放行生产环境再用严格白名单。4. 生产环境最容易踩的四个隐蔽坑4.1 反代不传Upgrade头导致握手卡死文章开头的那次事故根源就是Nginx配置缺了两行。location /ws { proxy_pass http://backend_servers; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }这里的核心是proxy_http_version 1.1和proxy_set_header Connection upgrade。Nginx默认和上游通信时用的是HTTP/1.0而且会重新改写Connection头如果你不显式设置Upgrade头根本到不了后端。我自己排查这个问题时发现一个规律后端日志里如果看到请求没有Upgrade头八成就是反代层吃掉了如果Upgrade头还在但响应不是101,才是后端应用的问题。另外proxy_read_timeout和proxy_send_timeout也很重要。WebSocket连接是长连接如果这两项不调大Nginx默认的60秒超时会把空闲连接直接断开。生产环境我一般设置成3600秒再配合应用层心跳确保连接不会被中间层静默回收。4.2 鉴权方案从Cookie到token的迁移细节很多团队早期做WebSocket鉴权时直接用Cookie因为握手是HTTP请求Cookie会自动带上后端从请求头里读取即可。这套方案在浏览器同域场景下没问题但一旦跨域Cookie的SameSite属性就会来捣乱。浏览器对跨域WebSocket握手时携带Cookie的行为遵循和普通第三方Cookie一样的规则。如果Cookie设置了SameSiteLax跨域握手里它不会发送如果设置SameSiteNone; Secure那么可以发送但要求连接必须是HTTPS。这就导致一个尴尬局面本地开发环境全是HTTPSecureCookie根本写不进去。我的做法是放弃Cookie统一用token。前端从登录接口拿token后存内存或localStorage建立WebSocket时拼在URL的query参数里。这样做还有一个好处就是方便在服务端做细粒度的权限校验比如不同的token对应不同的频道订阅权限而Cookie方案只能区分“已登录”和“未登录”。要注意的是token放在query里会出现在Nginx access log、网关日志以及浏览器开发者工具中所以生产环境建议用短期token比如15分钟过期配合心跳和重连机制在连接生命周期内刷新。4.3 心跳与断线重连的状态机设计WebSocket的一个默认行为是如果没有数据传输它不发送任何保活报文。这就导致一个现象网络链路中某个节点比如运营商NAT、云厂商LB默默地断开了这条连接但两端都不知情。你以为连接还在实际上数据已经发不过去了。解决方法是设计心跳机制。我推荐的做法是服务端主动周期ping客户端被动回pong。WebSocket协议原生支持ping/pong帧Node.jsws库和浏览器端都内置支持在浏览器端不必自己处理pong帧浏览器会自动回。服务端代码const HEARTBEAT_INTERVAL 30000; // 30秒 wss.on(connection, (ws) { ws.isAlive true; ws.on(pong, () { ws.isAlive true; }); const interval setInterval(() { if (ws.isAlive false) { // 上一次ping没收到pong判定为死链 ws.terminate(); clearInterval(interval); return; } ws.isAlive false; ws.ping(); }, HEARTBEAT_INTERVAL); ws.on(close, () clearInterval(interval)); });这套逻辑的核心思路是每隔30秒ping一次如果在下一个周期到来之前没有收到pong就认为连接已经死了直接断开。这个方案比客户端定时发送心跳可靠因为浏览器限制了你无法直接监听pong事件但自动回pong是协议层面的行为不用写代码。前端的心跳则是另一层保险。我会在onopen之后启动一个定时器每隔25秒发送一个业务层的ping消息比如{type: ping}服务端收到后返回{type: pong}。这样即使中间层对协议帧做了奇怪的缓存或过滤业务层心跳也能验证整个消息链路是否通畅。如果连续几次没收到pong就主动close触发重连。前端的重连还要注意一个细节页面从后台切回前台时浏览器会暂停定时器可能导致连接的真正状态和本地状态不一致。所以我在页面visibilitychange事件里会主动做一次连接状态检查如果发现连接断裂立即重连而不是干等下一次心跳。4.4 连接数上限与监控告警的容量规划最后一个经常被忽略的问题是容量。每一条WebSocket连接在服务端都是一个持久的TCP连接占用一个文件描述符。如果你的服务跑在容器里默认的ulimit限制是1024这意味着同时在线用户超过1024个新的连接就无法建立。我在一个项目里就遇到过这种情况。服务本身性能没问题CPU和内存都很低但用户一多就报连接失败。排查下来发现是ulimit -n太小。解决方法是在容器启动脚本里调大文件描述符上限同时配合负载均衡把连接分散到多个实例。监控方面除了常规的CPU和内存WebSocket服务一定要加两个指标当前连接数和消息往返延迟。连接数反映服务容量水位延迟反映链路质量。我习惯在服务端每10秒统计一次连接数写入监控系统并设置一个接近上限的告警阈值。延迟则通过心跳pong的耗时来估算如果发现pong延迟持续升高大概率是网络链路或中间层出了问题需要提前介入。写在最后的一点经验跨域WebSocket的问题本质上不是“怎么配置跨域”而是“怎么把连接的安全性、稳定性和可控性都做到位”。从我这些年的经验看标准的HTTP CORS解决方案在WebSocket这里并不直接适用真正起作用的是服务端主动校验Origin、token、以及完善的连接生命周期管理。如果你现在正被某个诡异的WebSocket连接问题困扰建议先抓住握手的HTTP请求看状态码和响应头再沿着链路逐层排查大部分问题都能在几分钟内定位到是浏览器、反代还是服务端应用层的问题。
返回列表