尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Nginx反向代理WebSocket:Upgrade协议透传与长连接稳定配置

Nginx反向代理WebSocket:Upgrade协议透传与长连接稳定配置 干过前后端联调的人多半都经历过这种灵异事件本地开发环境里 WebSocket 连得好好的消息收发行云流水一旦把服务部署到服务器、前端统一走 Nginx 反向代理访问客户端就开始疯狂掉线。运气好一点是连接几秒一断反复重连运气差一点直接在握手阶段就甩你一个unexpected status 502 bad gateway: unknown error, url: http://127.0.0.1:1572浏览器 Network 面板里一片红。这个 502 最迷惑人的地方在于后端服务进程明明活着你在服务器上直接 curl 后端地址也有响应服务日志干净得不像话。但只要客户端一经过 Nginx握手就是失败。我见过不少同事在这个问题上排查大半天从防火墙查到端口占用最后发现根子就在 Nginx 的 HTTP 反向代理配置上——Upgrade和Connection这两个头没有被正确透传。这篇文章就把WebSocket 服务放在 Nginx 反向代理后面这件事一次讲透。我会从 HTTP Upgrade 机制拆起给出一份最小可用配置再讲清楚长连接超时、心跳、多实例负载均衡、WSS 证书和排错方法。新手可以直接抄配置被线上掉线折磨过的老手也能在踩坑部分找到点共鸣。1. 为什么 WebSocket 一到 Nginx 后面就断线HTTP Upgrade 机制拆解1.1 从一次真实的 502 说起先还原一下当时的场景。前端页面在浏览器里访问ws://example.com/ws浏览器会先发出一个 HTTP GET 握手请求给 NginxNginx 再把请求连同各种头一起转给后端的 1572 端口。如果转发时Upgrade和Connection头被过滤掉了后端 WebSocket 服务拿到的就是一个普通GET /ws。大部分 WebSocket 服务端框架都只在检测到 Upgrade 头的时候才走 WebSocket 路由遇到普通 GET 就按普通接口处理返回 404 或 400严格一点的框架干脆直接断开连接。Nginx 拿不到预期的101 Switching Protocols响应或者上游连接被重置于是对外表现为 502。这里藏着一个很多人不了解的底层逻辑Connection头在 HTTP 规范里属于 hop-by-hop 头逐跳头只对当前一跳有意义。Nginx 作为中间代理有责任在转发时移除这类头除非你在配置里明确声明要传给它。这既是规范要求也是一层安全设计——避免客户端通过Connection头操纵代理与上游之间的连接行为。理解了这一层你就能明白为什么网上所有 Nginx WebSocket 配置都长得差不多因为缺了那三行握手就是过不去。1.2 握手不是普通请求Connection: Upgrade 与 Upgrade: websocketWebSocket 不是从第一条报文开始就是WebSocket的。它借用 HTTP 完成一次握手之后才升级为独立的帧协议。整个生命周期可以拆成两个阶段。第一阶段是握手。客户端发一个普通 GET请求头和普通请求的最大区别是多了几个字段GET /ws HTTP/1.1 Host: example.com Connection: Upgrade Upgrade: websocket Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw Sec-WebSocket-Version: 13服务端校验通过后返回HTTP/1.1 101 Switching Protocols Connection: Upgrade Upgrade: websocket Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk第二阶段是数据传输。从101状态码之后开始这条 TCP 连接上跑的不再是 HTTP 报文而是 WebSocket 帧。客户端和服务端可以随时主动发数据这就是实时双向数据传输的底层基础。这里有一个关键点升级前后是同一根 TCP 连接。代理Nginx要做的不只是转发一个请求而是把这条连接接住并在之后很长一段时间里持续透传两个方向的数据包。拿打电话类比最直观握手就像拨号后说我是某某对方确认哦是你啊然后两边不挂电话直接聊天。反向代理是那个接线员它不能中途挂断电话否则通话立刻中断。这也是 Nginx 处理 WebSocket 和普通 HTTP 最大的不同——普通请求转发完响应就可以扔了WebSocket 连接却要一直吊在代理上。1.3 为什么普通 proxy_pass 不够回到配置层面。一个最普通的proxy_pass配置能转发普通 HTTP 请求但在 WebSocket 面前会失灵原因有两层。第一层Nginx 转发请求时会重新组织请求头默认不保留Connection和Upgrade除非你显式设置。第二层Nginx 默认使用 HTTP/1.0 与上游通信而 HTTP/1.0 协议里根本没有 Upgrade 语义就算把头传过去了后端也不知道还能升级这一说。所以要加的三行老三样proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade;逐行说proxy_http_version 1.1;把 Nginx 与上游通信的协议版本改成 1.1。这是支持 Upgrade 机制的前提。proxy_set_header Upgrade $http_upgrade;把客户端请求里的Upgrade: websocket头原样传给上游。$http_upgrade是 Nginx 内置变量取的是客户端请求头中Upgrade字段的值客户端没带时这个变量就是空字符串。proxy_set_header Connection $connection_upgrade;把Connection头动态设置为 upgrade 或 close用的是map指令生成的变量。可能会有朋友问Connection为什么不直接写死成upgrade因为如果这个 location 同时还要承担普通 HTTP 请求很多后端框架允许 REST 和 WebSocket 共用路径普通请求没有Upgrade头此时Connection强制写成 upgrade会让上游误判成升级请求可能引发一连串奇怪的 keep-alive 问题。所以一般配合 map 做动态判断map $http_upgrade $connection_upgrade { default upgrade; close; }这段 map 的含义很直白客户端带了Upgrade头就把Connection设成 upgrade没带就设成 close。这个map块只能放在http层通常在 nginx.conf 的http {}内不能塞进server块否则 reload 会直接报错。2. 最小可用反向代理配置把握手和转发跑通2.1 先按这份配置来别急着自己发挥我把最简可用的配置贴在这里你可以直接放到conf.d/websocket.conf或nginx.conf的http块里。map $http_upgrade $connection_upgrade { default upgrade; close; } upstream ws_backend { server 127.0.0.1:1572; } server { listen 80; server_name example.com; location /ws { proxy_pass http://ws_backend; 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 15s; proxy_read_timeout 120s; proxy_send_timeout 120s; } }逐项说一下方便你按自己情况改location /ws路径匹配。前端连接地址写ws://example.com/ws。如果你的 WebSocket 服务注册在别的路径比如/socket.io/或/chat这里跟着改。proxy_pass http://ws_backend;转发到 upstream 组。注意http://前缀不能省upstream 组的名字可以随便起但得和下方upstream块保持一致。四个proxy_set_header里前两个是握手成功的关键后三个属于常规反向代理转发头。Host头尤其重要后端如果做域名校验、虚拟主机路由或同源校验拿不到正确的 Host 就会 404 或者拒绝连接。X-Forwarded-For让后端能拿到真实客户端 IPX-Forwarded-Proto让后端知道客户端进来时是 http 还是 https。三个 timeout 参数建议从一开始就写上默认 60 秒对低频消息的 WebSocket 太短后面第 3 节会细讲。2.2 两个最容易踩的小坑端口漏写和 map 缺失这份配置看起来简单但我在帮别人排查时最常见到两个低级错误。一个是端口漏写。proxy_pass http://127.0.0.1;等价于转发到127.0.0.1:80。如果你的后端 WebSocket 服务监听的是 1572 端口upstream 块里写的是server 127.0.0.1:1572;而 proxy_pass 却漏写了端口结果必然是 502。这个错误隐蔽在配置看着没问题但就是不通的假象里。另一个是只贴了 location 里的三行忘了把map块加到http层。reload 时会直接报错nginx: [emerg] unknown variable connection_upgrade这个报错信息已经足够明确顺着它去找就能定位到 map 没定义。2.3 用 curl 验证握手是否真的成功配置改完别急着让前端去连先用 curl 验证握手链路。我自己比较喜欢用下面这条命令手动模拟一次 WebSocket 握手curl -i -N \ -H Connection: Upgrade \ -H Upgrade: websocket \ -H Sec-WebSocket-Version: 13 \ -H Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ \ http://127.0.0.1/ws关键看返回状态101 Switching Protocols说明 Nginx 已经正确透传了升级头握手链路通了。502 Bad Gateway说明 Nginx 连不上上游或上游没有按 WebSocket 方式响应去翻 error.log。404 Not Found大概率是路径映射问题后端没有/ws这个路由或者 proxy_pass 的路径改写不对。400 Bad Request多半是头部格式有问题比如Sec-WebSocket-Key缺失或协议版本不对。curl 这种方式只能验证握手不能真正测双向收发。要连起来测一遍最简单是自备一个 echo 服务。我常用 Node 临时起一个const WebSocket require(ws); const wss new WebSocket.Server({ port: 1572 }); wss.on(connection, ws { ws.on(message, data { ws.send(echo: data); }); });然后打开 Postman 的 WebSocket 客户端连ws://127.0.0.1/ws发一条消息能收到echo: xxx就说明整条链路完全通畅。别小看这一步它能帮你把Nginx 配置问题和业务代码问题干净利落地切开。2.4 reload 配置的正确姿势改完 Nginx 配置一定要先测语法再 reloadnginx -t nginx -s reloadnginx -t会告诉你配置文件有没有语法错误比如上面提到的 map 缺失问题在这一步就会暴露。reload是平滑重载正在传输的请求和已经建立的 WebSocket 连接不会中断——Nginx 的新 worker 进程加载新配置老 worker 进程继续服务存量连接直到它们关闭才退出。这也是 Nginx 能当生产环境代理的底气之一。需要提醒的是map块属于http层配置修改后必须 reload 才生效不存在改完即时生效这种好事。3. 连接老是自动断开超时、心跳与缓冲区的真相3.1 先搞懂 proxy_read_timeout 到底在计时什么连接能握手成功但过一会儿就断这是仅次于 502 的高频问题。很多人第一反应是后端程序有问题结果后端日志里什么都没留下。这时候要重点怀疑三个时间参数。参数默认值含义proxy_connect_timeout60sNginx 与上游建立 TCP 连接的超时时间proxy_read_timeout60sNginx 从上游连续两次读取数据之间的间隔超时proxy_send_timeout60sNginx 向上游连续两次发送数据之间的间隔超时这里最容易误解的就是proxy_read_timeout。它不是连接的总时长上限而是两次读操作之间的空闲等待时间。换句话说如果上游在 60 秒内没有往这条连接上写任何数据Nginx 就认为连接闲置直接掐断。对 WebSocket 来说如果没有消息往来也没有心跳包连接天然就是静默的。默认 60 秒一到Nginx 先动手断开前端和后端几乎都感知不到是谁先断的——于是你看到的现象就是连接隔一分钟左右掉一次。这个时间间隔非常有辨识度如果你遇到的现象正是准点掉线先查超时参数基本一抓一个准。打电话来类比你和朋友通话如果两边都不说话超过 60 秒运营商Nginx就把电话挂了。不是因为你打了超过 60 秒而是因为沉默太久。3.2 治本方案让连接保持活动而不是把超时调到无限大把proxy_read_timeout调到 3600 甚至更大是很多人第一反应但这是治标不治本。你只是在把一分钟掉一次变成一小时掉一次问题依然存在而且超时时间设得过大一旦上游真的出了问题故障发现时间也会被拉长。正路是让连接保持活动。WebSocket 协议本身有专门的心跳机制——Ping/Pong 帧。服务端可以定时发 Ping客户端浏览器会按照规范自动回 Pong不需要你写任何 JavaScript 去处理。Spring WebSocket 里有setIdleTimeoutSocket.IO 里有pingInterval和pingTimeout都是干这个用的。常见的合理组合是心跳间隔30 到 60 秒Nginxproxy_read_timeout90 到 120 秒至少是心跳间隔的两倍以上。这样即使一次心跳包在网络抖动中丢了还有一次机会Nginx 不会因为瞬时波动直接把连接掐断。我在生产环境里用的多是60 秒心跳 120 秒超时的组合实测下来很稳。只要你的服务端框架支持心跳配置优先去开心跳而不是改 Nginx 超时。把超时留作兜底即可。3.3 前端重连逻辑任何代理都挡不住物理断网代理层配置得再好也挡不住移动网络切换、服务器重启、机房抖动这些物理层面的断连。所以前端必须做自动重连而且推荐带指数退避。一个相对简单的实现let retries 0; function connect() { const ws new WebSocket(wss://example.com/ws); ws.onopen () { retries 0; }; ws.onclose () { const delay Math.min(1000 * 2 ** retries, 15000); retries 1; setTimeout(connect, delay); }; } connect();指数退避的意义在于如果后端短暂故障导致大量客户端同时掉线所有客户端立刻用同样的间隔重连会在同一瞬间把服务端冲垮——这就是常说的重连风暴。退避加随机抖动能有效避免这个问题。另外重连成功后建议重新携带鉴权信息token因为 WebSocket 连接重建后服务端无法确认你还是不是之前那个合法用户。3.4 proxy_buffering 对 WebSocket 的影响关于proxy_buffering很多文章直接说WebSocket 一定要关掉缓冲这话不全对。WebSocket 握手返回 101 之后Nginx 会自动进入隧道模式在这个模式下数据是即时透传的不再走缓冲逻辑。所以对于纯 WebSocket 连接proxy_buffering的默认行为其实不会造成数据延迟。但握手响应阶段确实可能因为缓冲而延迟而且如果你的location里同时混了 SSEServer-Sent Events或者长轮询接口缓冲就真的会造成消息不是及时推送。所以实践上在 WebSocket 相关的 location 里显式加一句proxy_buffering off;是稳妥的成本可以忽略还能少一桩心事。3.5 从日志里快速辨认掉线原因定位掉线原因最直接的办法是看 Nginx 错误日志tail -f /var/log/nginx/error.log几种典型的日志特征upstream timed out (110: Connection timed out) while reading response header from upstream——这就是proxy_read_timeout超时先检查心跳配置。connect() failed (111: Connection refused) while connecting to upstream——后端没有监听在指定端口或者进程挂了。no live upstreams while connecting to upstream——upstream 组里所有节点都不可用。worker_connections are not enough——Nginx worker 连接数不够用需要调大配置。这些日志信息看着吓人其实每一条都对应着非常具体的配置项顺着关键词去查基本不会跑偏。4. 多实例部署负载均衡、会话保持与故障转移4.1 WebSocket 场景下默认轮询为什么不够用单实例的 WebSocket 服务配置到上面那一步就已经能跑了。但生产环境为了高可用后端至少两个节点。这时候upstream的默认轮询策略就会暴露一个问题WebSocket 建立的是一条持续几分钟甚至几小时的长连接连接的会话状态默认保存在后端进程内存里。当客户端掉线重连时Nginx 按轮询把新连接分到了另一个节点而那个节点没有这个会话的内存状态于是客户端要么被迫重新登录要么在业务逻辑上丢状态。对实时聊天、协同编辑这类应用来说这个体验是致命的。解法有两个方向优先级不同应用层无状态化。把会话状态、用户上下文全部扔到 Redis 或数据库里节点之间随便漂移这是最彻底的方案。代理层会话保持。如果短期内没精力改造后端可以在 Nginx 的 upstream 块里加ip_hash让同一来源 IP 的请求固定落到同一节点。upstream ws_backend { ip_hash; server 127.0.0.1:1572; server 127.0.0.1:1573; }ip_hash的实现是取客户端 IP 的前三段做哈希配置成本极低适合快速止血。但它也有明显的副作用一个公司或一个学校的所有人都从同一个出口 IP 访问时这些连接会被压到同一个后端节点造成严重的负载倾斜。这一点一定要提前评估。4.2 least_conn 为什么不适合 WebSocket有些人习惯给 HTTP 服务配least_conn最少连接想着 WebSocket 长连接数多也应该用这个策略。这是个误区。least_conn衡量的是当前活跃连接数但 WebSocket 长连接是长期挂着的连接数多不代表实时负载高——有的连接挂了两小时一句话没说有的连接每秒刷屏。把新连接导给连接数最少但消息吞吐已经饱和的节点反而容易造成雪崩。对 WebSocket 这种场景轮询和ip_hash的行为都更可预测least_conn更适合短而密集的 HTTP 请求场景。如果你希望更精细的会话保持比如基于 cookie 的 sticky开源版 Nginx 没有内置的sticky指令需要用hash指令配合自定义键比如$http_zeus或者 cookie 值或者直接上 Nginx Plus。绝大多数场景下ip_hash加应用层无状态化已经能覆盖 90% 的需求。4.3 节点上下线与滚动发布别让存量连接瞬间全断WebSocket 长连接没法平滑迁移。节点重启时挂在它上面的所有连接会瞬间断开对用户来说就是全员掉线。应对这件事有一套固定的操作节奏。以滚动发布为例。假设三个节点要升级业务代码先从 Nginx upstream 配置里注释掉或删除节点 A执行nginx -t nginx -s reload。Nginx reload 是平滑的老 worker 进程会继续维护节点 A 上已建立的连接直到这些连接自然关闭。所以已经连上的用户不会立刻掉线新握手不会再分到节点 A。等节点 A 上的存量连接逐渐减少可以观察连接数趋近于 0重启节点 A 上的业务进程部署新代码。将节点 A 加回 upstream 配置再次 reload节点 A 开始承接新连接。依次处理节点 B、C。这套流程的关键是等待存量连接自然结束。如果业务上等不了太久可以在后端做优雅停机——进程收到退出信号后先停止接受新连接等已有连接达到某个超时阈值或客户端主动断开后再真正退出。这需要后端框架配合但值得做。4.4 微服务与网关场景代理链上每一跳都不能丢头如果你用的是微服务架构比如 Sprng Cloud Gateway 这类网关前端实际要经过浏览器 → Nginx → 网关 → WebSocket 服务这条链路。Nginx 这一层配好了还不够网关自己也必须支持 WebSocket 路由转发。我见过不少项目Nginx 配置完全正确curl 握手也通了但前端就是连不上。最后定位发现是网关层把Upgrade头又丢了一次。检查方法也很简单逐层验证先用 curl 直连网关的 WebSocket 路由地址再用 curl 走 Nginx 转发对比两次握手响应立刻能定位是哪一层出了问题。另外如果你的项目用了 Socket.IO得知道它的连接机制比较特殊先做一次 HTTP 长轮询握手拿到sid之后再升级到 WebSocket。这意味着在这个过程中Nginx 不仅要透传 Upgrade 头HTTP 长轮询请求本身也不能被拦截或改写。配置上其实不用额外做什么只要别把/socket.io/路径错误重写就行。4.5 连接数监控长连接是按 FD 算的多实例部署之后监控也得跟上。一条 WebSocket 连接在 Nginx 上要占两个文件描述符一个是客户端侧的一个是上游侧的。假如 Nginx worker 连接上限是 1024理论上最多同时撑住 512 条 WebSocket 连接因为每条要占两个连接槽位。常用命令# 查看当前到后端的 WebSocket 连接数 netstat -ant | grep 1572 | grep ESTABLISHED | wc -l # 查看系统整体连接状况 ss -s如果错误日志里出现worker_connections are not enough需要同时调大worker_connections和worker_rlimit_nofile后者是系统层面能给 Nginx 进程打开的 FD 上限。调整完记得nginx -s reload必要时重启进程。这个细节很多人踩过长连接服务和短请求服务的连接模型完全不是一回事。5. 踩坑盘点路径重写、WSS、日志排查与调试工具5.1 路径转发斜杠决定命运proxy_pass的路径处理有个经典规则理解它就能少踩一半的路径坑proxy_pass后面不带 URI也就是 URL 路径部分原路径完整转发。比如location /ws/ { proxy_pass http://backend; }请求/ws/chat会被原样转发成/ws/chat。proxy_pass后面带 URI比如http://backend/location匹配到的部分会被 URI 替换。比如location /ws/ { proxy_pass http://backend/; }请求/ws/chat会被改写成/chat。最常见的翻车现场是前端连的是/ws/chat后端 WebSocket 服务监听的路由是/chat/Nginx 配置写成了proxy_pass http://backend;不带斜杠于是请求以/ws/chat到了后端后端匹配不到路由返回 404。解决方案是改成proxy_pass http://backend/;或者让前端和后端约定好统一路径。能用路径对齐解决的问题就不要引入 rewrite。加一层魔法就多一个坑这是我在生产环境里悟出来的原则。5.2 WSSHTTPS 页面下的浏览器安全策略浏览器有一条铁律HTTPS 页面里只能发起wss://如果用ws://会被直接拦截。控制台里的报错类似was loaded over an insecure connection本质是混合内容阻止策略。所以生产环境只要前端页面是 HTTPSWebSocket 就必须走 WSS。Nginx 做 TLS 终结后端保持普通 ws 明文是最常见的架构。配置如下server { listen 443 ssl; http2 on; server_name example.com; ssl_certificate /etc/nginx/ssl/example.crt; ssl_certificate_key /etc/nginx/ssl/example.key; location /ws { proxy_pass http://ws_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; } }内部联调需要自签名证书时可以用这条命令临时生成openssl req -x509 -nodes -newkey rsa:2048 -days 365 \ -keyout example.key -out example.crt \ -subj /CNexample.com但自签名证书浏览器会报警本地联调可以生产环境还是用受信任的证书机构签发的证书比较稳妥。另外补充一个容易混淆的点WebSocket 代理在 Nginx 里走的是 HTTP/1.1 的 Upgrade 流程http2开关并不影响 WebSocket 的代理链路。你在listen 443 ssl上开不开http2对于/ws这个 location 的处理逻辑没有区别没必要在这个问题上纠结。5.3 排错三板斧curl、浏览器、日志线上 WebSocket 出问题我的排查顺序基本固定分享出来供参考。第一板斧是 curl 手动握手验证链路。前面 2.3 节已经写过重点看返回是不是101。这一步能快速区分Nginx 转发问题和后端业务问题。第二板斧是浏览器 DevTools。打开 Network 面板刷新页面找到类型为 WebSocket 的请求点开看握手的响应码。响应码含义对照状态码含义101握手成功连接建立502Nginx 到上游失败优先查 Nginx error.log400升级头或协议版本不合法403鉴权失败、IP 限制或同源校验不通过404路径映射错误前后端路径没对齐第三板斧是日志。Nginx 的 access.log 里能看到 WebSocket 握手请求的完整记录一条GET /ws后跟101说明链路没问题error.log 里能看到更详细的报错原因。如果后端是 Java 应用还要同时看后端日志和网关日志确认请求是否真的到达。5.4 前端本地开发代理Vue 项目的 ws 开关最后说一个前端开发环境的小坑很多 Vue 项目在本地联调时 WebSocket 连不上不是后端问题而是vue.config.js里的devServer.proxy没有开 WebSocket 支持。http-proxy-middleware代理 HTTP 请求和 WebSocket 是两套逻辑必须显式加ws: truedevServer: { proxy: { /ws: { target: http://127.0.0.1:1572, ws: true, }, }, },少了这一行本地开发时 WebSocket 会一直握手失败而线上走了 Nginx 反而正常。这种本地不行线上行的灵异现象多半就是开发代理配置缺了ws开关。还有一种容易忽略的情况如果 Nginx 在 TLS 终结后转成 http 回源后端通过X-Forwarded-Proto头判断请求原本是 https 还是 http。有些 WebSocket 服务端会基于这个头生成回调地址或者做安全校验忘了设置proxy_set_header X-Forwarded-Proto $scheme;后端就会误判来源协议行为变得很诡异。这种问题日志里不一定有明显报错排起来特别费时间。我个人的习惯是每次调 WebSocket 相关问题先把握手有没有 101这件事确认掉再往下查业务。90% 的线上问题都出在升级头没透传、超时太短、路径不匹配这三件事上。另外上线前一定把心跳、重连、优雅停机这三件事都当功能需求来测别等线上用户来帮自己做压力测试。
返回列表