Nginx配置WebSocket反向代理:Upgrade头、连接超时与负载均衡最佳实践

发布时间:2026/10/1 3:43:15
Nginx配置WebSocket反向代理:Upgrade头、连接超时与负载均衡最佳实践
1. 为什么WebSocket转发不能按普通HTTP那套来配WebSocket这玩意儿早就不再是什么新鲜技术了从在线客服、实时弹幕、协作白板到行情推送、IM通讯、物联网指令下发基本上凡是需要服务端主动往客户端推数据的场景都会第一时间想到它。但在实际生产环境里WebSocket服务几乎不会裸奔对外前面总要挂一层Nginx做域名接入、TLS终止、负载均衡这时候问题就来了Nginx默认配置能转发HTTP请求却不一定能转发WebSocket很多人第一次配的时候明明按普通反向代理写好了配置浏览器里却一直报WebSocket connection to ws://xxx failed服务端日志里连个握手记录都没有。之所以会这样原因得从WebSocket的握手机制说起。WebSocket连接建立的第一步其实是一次普通的HTTP GET请求只不过这个请求带上了两个关键的头Upgrade: websocket和Connection: Upgrade。服务端看到这两个头之后如果同意升级协议就返回HTTP/1.1 101 Switching Protocols之后这条TCP连接就从HTTP协议切换成WebSocket协议双方开始双向通信。问题恰恰出在这里Nginx作为反向代理默认情况下会按照HTTP协议来处理请求它不会主动把Upgrade和Connection这两个头原样传给后端服务。如果这两个头在半路丢了后端就永远不知道客户端想升级协议自然就一直回200 OK而不是101浏览器拿到200发现不对直接报错。说白了普通HTTP请求是“一问一答答完断开”而WebSocket是“先握手再保持长连接双向聊天”。Nginx默认配置是为前者设计的所以要转发WebSocket必须告诉Nginx这个连接我需要特殊对待。这也是整篇配置的核心逻辑所在——并不是Nginx不支持WebSocket而是默认行为没有启用它。这篇内容适合所有手里有WebSocket服务、准备接入Nginx做反向代理的人无论你后端是Node.js的ws库、Java的Netty、Python的FastAPI还是Go的gorilla/websocket配置思路完全一致。配完之后你就能理解改的那几行header到底在干什么遇到问题也知道往哪个方向排查。2. WebSocket转发配置的底层拆解就那几个头2.1 Upgrade和Connection握手的“通行证”先直接看一段最基础的Nginx配置假设后端WebSocket服务跑在127.0.0.1:8080我们要把访问ws://yourdomain.com/socket的请求转发过去map $http_upgrade $connection_upgrade { default upgrade; close; } upstream websocket_backend { server 127.0.0.1:8080; } server { listen 80; server_name yourdomain.com; location /socket { proxy_pass http://websocket_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_read_timeout 60s; } }这里面最关键的就是三行proxy_http_version 1.1;— 强制Nginx和后端通信时使用HTTP/1.1协议。HTTP/1.0不支持Upgrade头部机制不写这行的话即使你设置了Upgrade头后端也可能不认。proxy_set_header Upgrade $http_upgrade;— 把客户端请求里的Upgrade头原样转发给后端。注意$http_upgrade是Nginx内置变量自动读取客户端请求头中的Upgrade值客户端不发这个头时它为空字符串。proxy_set_header Connection $connection_upgrade;— 这个稍微绕一点。HTTP协议里Connection头是用来控制逐跳hop-by-hop行为的Nginx默认会把客户端发来的Connection头处理掉因为对于普通请求来说这个头只对“当前这一段TCP连接”有意义不应该传给后端。但WebSocket握手恰恰需要它。直接用proxy_set_header Connection $http_connection行不行理论上可以但风险是客户端传什么就原样传什么如果客户端传来的是Connection: keep-alive这种值握手也会失败。所以更稳妥的做法是用map写一个映射有Upgrade就传upgrade没有就传close。2.2 map指令的含义与边界情况上面配置里的map很多人第一次看会懵这里单独拆开说map $http_upgrade $connection_upgrade { default upgrade; close; }这段的意思很直白定义一个名为$connection_upgrade的变量它的值取决于$http_upgrade。如果客户端请求头里带了Upgrade不管值是不是websocket$connection_upgrade就取值upgrade如果客户端没带Upgrade头也就是$http_upgrade为空字符串那$connection_upgrade就取值close。为什么要绕这一层因为同一个Nginx服务下你大概率不止转发WebSocket还同时转发普通的HTTP API请求。如果直接在location里写死proxy_set_header Connection upgrade;那么普通HTTP请求传到后端时也会带着Connection: upgrade虽然不是致命的错误但会让后端困惑有些严格的网关还会直接报错。用map做动态映射让普通请求传close只有真正的WebSocket握手请求才传upgrade这样一套配置同时兼容两种流量非常干净。注意map指令必须放在http块内不能放在server或location里这一点新手特别容易踩。放在stream块里也不行它是http上下文的专属指令。另外还有一个细节并不是只有Upgrade: websocket才算WebSocket握手比如Upgrade: h2c是HTTP/2明文升级Upgrade: mqtt也有类似场景。上面用default upgrade而不是写死websocket就是为了让这类升级协议也能通配属于“顺手做了一层兼容”。2.3 超时参数连接不断才是长连接WebSocket建立之后整个生命周期里数据流量可能并不均匀——用户可能挂着页面半天不说话也可能突然高频收发消息。Nginx默认的proxy_read_timeout是60秒意思是60秒内如果后端没有任何响应Nginx就主动断开连接。对于普通的HTTP请求这完全够用但WebSocket哪受得了这个用户挂着页面超过60秒没发消息连接就被Nginx掐了前端那边只能收到一个莫名其妙的断开事件。这就是为什么上面配置里写了proxy_read_timeout 60s;实际生产环境建议根据业务来调如果是IM类、客服类需要长期挂机的场景可以设成3600s甚至更长如果是行情推送这类高频场景60秒其实已经够用因为数据流动频繁超时计数器会不断重置。另外还有proxy_send_timeout控制Nginx向后端发送数据的超时WebSocket场景一般保持默认值就好但如果你发现后端长时间不发数据、Nginx却报超时断开可以两个参数一起调整。proxy_read_timeout 3600s; proxy_send_timeout 3600s;这里的“读取超时”和“发送超时”要区分开proxy_read_timeout是Nginx从后端读取数据的超时时间proxy_send_timeout是Nginx向后端发送数据的超时时间。微信小程序、浏览器端如果长时间静默建议两端配合心跳机制定期发ping帧这样既能保活也能更早发现死链。3. 从零开始完整配置实例与参数计算3.1 单后端节点最小可用配置先给一个“裸奔可跑”的最小配置适合本地开发调试。假设后端用的是Node.js的ws库启动在localhost:3001server { listen 80; server_name ws.example.com; location / { proxy_pass http://127.0.0.1:3001; 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-Forwarded-For $remote_addr; } }这个配置里proxy_set_header Connection upgrade直接写死了值因为整个server块只服务WebSocket流量不存在和普通HTTP请求共存的场景写死反而更清晰。Host头一定要带否则后端基于虚拟主机做路由时可能拿到错误的host比如通过IP直接访问Nginx时后端收到的主机名可能不是你期望的域名。3.2 多节点负载均衡session粘滞是关键当WebSocket服务不止一个节点时就轮到upstream上场了upstream ws_cluster { ip_hash; server 10.0.1.11:8080 weight1 max_fails3 fail_timeout30s; server 10.0.1.12:8080 weight1 max_fails3 fail_timeout30s; server 10.0.1.13:8080 weight2 max_fails3 fail_timeout30s; } server { listen 443 ssl http2; server_name ws.example.com; ssl_certificate /etc/nginx/ssl/ws.example.com.pem; ssl_certificate_key /etc/nginx/ssl/ws.example.com.key; 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_read_timeout 300s; proxy_send_timeout 300s; } }这个配置里藏着一个关键决策ip_hash。WebSocket是长连接一旦建连后续所有业务消息都在同一条连接上跑。如果Nginx按默认的轮询策略把连接打到不同后端节点后端的业务状态就全乱了——用户在A节点建立连接、状态存在A节点内存里下一条消息却被转发到B节点B节点一脸懵。ip_hash能保证同一个客户端的请求始终落在同一个后端节点上这是当前最简单粗暴的WebSocket粘滞方案。不过ip_hash也有局限如果用户来自同一个出口IP比如整个公司共用一个NAT网关那所有员工都会被分到同一个节点负载不均衡。更精细的方案是用sticky模块做cookie粘滞但开源版Nginx不带这个模块需要自己编译或者用商业版配置前得先确认自己的Nginx装了哪些模块nginx -V 21 | grep -o sticky另外无论怎么粘滞WebSocket长连接本身就容易造成节点负载不均因为有些用户挂了半天不走连接一直占着。这也是为什么很多团队选择用Spring Cloud Gateway、Envoy这类更精细的网关来做WebSocket入口Nginx在这种场景下能做的其实有限但它胜在稳定、配置简单、性能足够中小规模项目完全够用。3.3 WSS接入安全传输的完整链路现代浏览器对ws://的限制越来越严格HTTPS页面里混用明文WebSocket会被浏览器直接拦下来所以生产环境基本都要上wss://。WSS的配置和HTTPS的配置几乎一样Nginx负责终止TLS然后把解密后的明文WebSocket代理给后端这样后端不需要处理证书省事很多。上面那段配置里listen 443 ssl再加证书路径就是一个完整的WSS接入。唯一需要额外注意的地方是X-Forwarded-Proto $scheme这个头很多后端框架比如Spring WebSocket的握手检查会依赖这个头来判断客户端请求是http还是https如果漏传后端可能会拒绝握手或者生成错误的重定向地址。还有个容易忽略的点如果Nginx的SSL用的是http2配置需要注意HTTP/2和WebSocket的关系。正常来说Nginx终止了TLS后向下游后端走的是HTTP/1.1明文协议不受HTTP/2影响。但浏览器和Nginx之间如果协商成了h2协议Nginx对WebSocket的Upgrade处理逻辑——也就是RFC 8441定义的Extended CONNECT——和普通HTTP/1.1的Upgrade是两套东西。好在主流Nginx版本已经能正确处理h2下的WebSocket扩展不会出问题但如果你的Nginx版本很老1.25之前的某些小版本建议实测一遍wss握手是否正常。稳妥起见拿不准版本行为时可以只写listen 443 ssl;去掉http2参数等确认无误再开。3.4 按路径分流一套Nginx同时处理HTTP和WebSocket实际项目里同一个域名下往往既有REST API又有WebSocket。最典型的就是/api走普通HTTP/ws走WebSocket。配置起来也很简单就是用多个location块做路由server { listen 80; server_name api.example.com; # REST API普通反向代理 location /api/ { proxy_pass http://api_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # WebSocket带Upgrade头转发 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_read_timeout 600s; } }这里需要在映射上下文中建好map并且注意location的匹配优先级。location /ws/是前缀匹配location /ws这种才是精确匹配如果客户端连接的是ws://api.example.com/ws不带斜杠前缀匹配也能命中/ws/因为Nginx会把/ws和/ws/视为前缀匹配关系。但如果你的后端路径是/socket.io这类建议把location写成location /socket.io/并确认后端路由和Nginx的路径拼接方式一致。关于proxy_pass末尾是否加斜杠、location路径是否会带上原路径一起传给后端这是Nginx反向代理最容易搞混的点之一建议配置完用curl -I或者直接看后端访问日志验证。关于路径传递多说一句proxy_pass http://ws_backend;后面不带斜杠时Nginx会把完整的原始URI传给后端如果写成proxy_pass http://ws_backend/;则匹配到的location前缀会被替换掉。比如请求/ws/chat前者后端收到的是/ws/chat后者后端收到的是/chat。WebSocket场景下握手请求的路径往往就是业务路由改错一个斜杠就可能让后端返回404。4. 常见问题与排查技巧实录4.1 握手失败总是收到200而不是101这是最典型的WebSocket转发问题。浏览器控制台报Error during WebSocket handshake: Unexpected response code: 200说明Nginx把请求转发给后端了但后端没有返回101 Switching Protocols而是回了200 OK。排查顺序一般是这样的先确认Upgrade和Connection头有没有传到后端。在Nginx配置里临时加一段调试日志或者在向后端转发时用add_header验证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 X-Debug-Upgrade $http_upgrade; }然后到后端对应的Nginx access log里看$http_upgrade这个变量的值到底有没有传过去。如果是空的检查map是不是放在http块内或者变量名有没有拼错。检查后端服务本身是否支持WebSocket。有些后端框架默认没有开启WebSocket支持比如Java内置的HttpServer默认不支持Spring Boot则需要先注册WebSocketHandler。可以先绕过Nginx直接用ws://连后端原始地址如果直连也失败说明问题在后端而不是Nginx。检查Nginx和后端之间的HTTP版本。如果没写proxy_http_version 1.1;Nginx默认用HTTP/1.0和后端通信而HTTP/1.0规范里没有Upgrade头机制后端自然不认。直连测试这个手段极其重要它能帮你快速划定“问题在Nginx还是后端”的责任边界。我见过不少同事调了半天Nginx最后发现是后端的WebSocket路径压根写错了——Nginx转发完全正常是后端路由没有这个path。4.2 连接建立后又秒断超时参数没调如果握手成功状态码101正常返回但连接建立后几秒或几十秒就被断开优先怀疑两个方向proxy_read_timeout设置太短。默认60秒如果客户端超过60秒没发数据、后端也没推数据Nginx就断开连接。解决方案前面说过调大超时或者让前端加心跳。后端自己有空闲超时逻辑。比如某些WebSocket框架默认空闲几分钟就自动断开这不是Nginx的问题。用lsof -i :端口看连接状态或者在后端打印断开原因能很快定位。一个实用的排查命令# 用curl配合 --include 观察握手响应头 curl -i -N \ -H Connection: Upgrade \ -H Upgrade: websocket \ -H Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ \ -H Sec-WebSocket-Version: 13 \ http://yourdomain.com/ws如果看到HTTP/1.1 101 Switching Protocols说明Nginx这层没问题如果卡住或者返回其他状态码就把Nginx往后端的转发链路单独测一遍。4.3 跨域问题Origin校验不过WebSocket同样有跨域限制浏览器在握手请求里会带上Origin头后端可以选择校验这个头来决定是否接受连接。如果你的前端页面在https://app.example.comWebSocket服务在wss://ws.example.com这属于跨域后端如果写死了Origin白名单就可能拒绝握手。这类问题的处理有两个层面如果后端Origin校验太严可以校验放行在Nginx层用map把可信的Origin映射出来或者后端修改白名单配置。我个人建议优先改后端让后端把app.example.com加进白名单Nginx层做透明转发就好不要在后端外层随便加头。如果业务允许任意来源连接比如公开的行情推送服务后端通常有setAllowedOrigins(*)之类的配置。但要注意*通配符配不了withCredentials场景带Cookie的跨域WebSocket请求需要明确指定Origin。4.4 多节点负载下的“串线”问题前端明明连上了但发消息给对端经常出现“对方不在线”或者消息丢失而且过一会儿又恢复了这多半是负载均衡没做粘滞或者粘滞策略失效了。ip_hash虽然好用但有个坑如果客户端经过多个层级的代理比如公司出口网关、CDN每次请求的源IP可能是变化的ip_hash就会失效。更稳妥的做法是让前端在握手时携带一个固定的业务标识比如userId然后后端自行做节点路由或者用Redis Pub/Sub做全节点消息广播让消息不依赖“某个节点上的连接”这种本地状态。WebSocket服务要做到多节点高可用本质问题不在Nginx而在后端连接的分布式管理Nginx的粘滞只是缓解方案治标不治本。4.5 错误配置速查表现象可能原因解决方案握手返回200未设置Upgrade/Connection头检查map和proxy_set_header握手返回400 Bad Requestnginx未开启HTTP/1.1加proxy_http_version 1.1;连接一会儿就断proxy_read_timeout太短调大到300s或更长同一客户端来回切换节点ip_hash失效/未配置配置ip_hash或cookie stickywss握手失败证书问题或TLS协商问题检查证书链、Nginx版本对h2扩展支持后端拿不到客户端真实IP未传X-Forwarded-For增加proxy_set_header X-Forwarded-For浏览器报跨域Origin校验未通过后端白名单加入前端域名这张表我建议直接收藏绝大多数WebSocket转发问题都跑不出这几类。排查的时候按“握手阶段问题→连接保持问题→业务路由问题”的顺序来一层层缩小范围避免一上来就怀疑Nginx版本、操作系统网络参数之类的次要因素。5. Nginx版本与基础环境准备配置WebSocket转发之前先确认自己的Nginx版本别太老。WebSocket的Upgrade代理功能在Nginx 1.3.13版本就已经支持了所以现在市面上的发行版基本都满足要求。但不同的版本在细节行为上还是有差异比如$connection_upgrade变量的使用习惯、map的匹配规则这些在1.10和1.24里的表现都一致真正有差异的是对HTTP/2和WebSocket并发时的处理方式。建议至少使用1.18以上的版本Ubuntu 20.04、CentOS 8、Debian 11自带的软件源里一般都能满足。安装Nginx的步骤很简单这里快速带一下# Debian/Ubuntu sudo apt update sudo apt install -y nginx # CentOS/RHEL sudo yum install -y nginx装完之后用nginx -v查看版本用nginx -t测试配置语法。注意nginx -t只能检查语法检查不了转发逻辑真正验证还是要靠实际请求。改完配置记得nginx -s reloadWebSocket长连接不会因为reload而断开这一点Nginx做得很优秀可以放心重载。如果你是用Docker部署Nginx也建议用官方镜像nginx:1.24-alpine这类基于Alpine的版本省内存配置挂载也方便。Docker部署时记得把宿主机端口映射到容器的80/443并且用--network host或者自定义网络让Nginx能访问到后端服务。不少人卡在Docker里连不上后端的127.0.0.1:8080就是忘了容器和宿主机网络是隔离的后端不在容器里的话要写宿主机在Docker网络中的IP通常是172.17.0.1或者直接改用宿主机网络模式。6. 调试之路从生手到熟练的几条体会说实话Nginx配置WebSocket转发这个事难点从来不在“写配置”本身而在“出了问题知道去哪找原因”。配置就那几行网上一搜一大把但真正上线跑起来之后你会遇到各种奇怪的现象有的浏览器能连上有的连不上、连接断断续续、后端偶发报错……这些才真正考验人对整个链路各环节的理解。我的建议是始终要有一个“链路思维”。从浏览器发起握手到Nginx接收再到Nginx转发给后端最后后端响应整个链路分成四段每一段都有自己的日志可以查。浏览器端看DevTools的Network面板能拿到握手请求和响应头Nginx层看access log和error log必要时在location里临时加access_log的详细格式后端看自己的应用日志和TCP连接状态。逐段定位永远比瞎猜要快。还有一个小技巧是在调试阶段把Nginx的错误日志级别调到info或debug能看到更详细的代理转发过程error_log /var/log/nginx/error.log debug;生产环境千万别开debug日志量会爆炸但调试阶段这个信息密度是无可替代的尤其是当你想确认Nginx到底有没有把Upgrade头发给后端的时候。另一个容易被忽略的点是防火墙和云安全组。WebSocket服务往往和HTTP共用80/443端口所以只要HTTP能通WebSocket握手一般也能通。但如果你用的不是标准端口比如后端WebSocket监听在8081Nginx监听在8081对外提供服务一定要检查云服务器的安全组是否放行了这个端口。这个不算Nginx配置的坑却是实战中排查时间占比最高的“非技术问题”。最后再说一个关于心跳的体会。不管Nginx的超时时间调多大客户端和服务端之间都应该有心跳机制。WebSocket协议本身有Ping/Pong帧前端库比如浏览器原生WebSocket、Socket.IO、原生ws库都支持定时发ping。心跳的价值不只是保活更重要的是能快速探测“假死连接”——比如用户手机从WiFi切到4GTCP连接其实已经断了但没有任何一方能感知如果不做心跳这条死链会一直占着Nginx和后端的资源。合理的心跳间隔一般是30秒到60秒发一次ping服务端如果在两三个周期内没收到pong就主动断开前端收到断开事件后自动重连。这套机制配合Nginx的proxy_read_timeout能让整个长连接体系既稳定又干净。Nginx转发WebSocket的配置本身不复杂但它牵扯到的知识面很宽——HTTP协议、TCP长连接、TLS、负载均衡策略、容器网络每个环节都可能成为瓶颈。希望这篇内容能帮你把这条路走通少踩几个我已经替你踩过的坑。