去年我把实时推送服务从联调环境迁到正式域名,前端代码在本地跑得好好的,ws://localhost:8080 一切正常,换上生产地址后控制台就一直报 WebSocket connection failed。第一反应是防火墙没放行后端端口,查了一圈发现 8080 能通,后端日志也没有异常。最后才找到元凶:Nginx 上只写了普通 HTTP 反向代理,压根没处理 WebSocket 的 Upgrade 握手。
如果你也在搜 “Nginx 中如何配置 WebSocket 代理”,大概率是踩进了同一个坑。这篇文章我会从握手原理、最小配置、超时心跳、故障排查一直写到 wss、负载均衡和路径分流,结合我自己真实踩坑的经历来讲。刚接触 Nginx 的朋友可以照着抄配置,已有基础的朋友重点看第三、四节的细节,那里才是生产环境真正会出问题的地方。
1. WebSocket代理为什么不能照搬普通HTTP反代
1.1 客户端真正发送的是什么
很多同学以为 ws:// 和 http:// 是两套完全不同的协议,所以代理配置也应该差别很大,其实恰恰相反。WebSocket 的连接建立阶段,客户端发出去的就是一个普通的 HTTP GET 请求,只不过这个 GET 带上了几个用于“升级协议”的 Header。
我抓过一次包,请求长这样:
http复制GET /socket HTTP/1.1
Host: ws.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13
服务端如果同意切换协议,会返回这样的响应:
http复制HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
关键就在 101 Switching Protocols。这个状态码意味着 TCP 连接从“HTTP 会话”切换成了“WebSocket 双向通信隧道”。从那之后,双方不再遵循 HTTP 的请求-响应模型,而是可以随时互相推数据。
所以反向代理要做的事就很清楚了:它必须把这个带 Upgrade 头的请求原样转给后端,等后端返回 101 之后,再把自己接到这条 TCP 隧道上,后续字节流直接透传。
1.2 Nginx默认行为为什么会让握手失败
Nginx 的 HTTP 代理模块很强,但默认配置是为普通 HTTP 设计的,不会主动帮你转发升级相关的头部。这里有两个关键默认行为:
第一,proxy_pass 默认使用 HTTP/1.0 与上游通信。HTTP/1.0 协议里没有 Upgrade 这套机制,就算你把 Header 硬塞过去,后端也无法正确完成协议切换。
第二,Nginx 默认不会转发客户端请求里的 Upgrade 和 Connection 头。浏览器发出的请求明明是“我要升级成 WebSocket”,但 Nginx 转给后端时,这两个头已经被丢掉了。后端看到的是一个普通 GET,自然回一个 200 的常规响应。浏览器收到 200,根本不会把它当作成功的 WebSocket 握手,于是连接失败。
这也是为什么很多人只在 Nginx 里写了:
nginx复制location / {
proxy_pass http://127.0.0.1:8080;
}
然后发现 WebSocket 死活连不上,但普通 HTTP 接口完全正常。不是 Nginx 坏了,也不是后端挂了,只是握手升级这一步没有被代理层支持。
1.3 升级成功之后Nginx在做什么
有一段时间我很困惑:Nginx 既然是反向代理,WebSocket 建立后它还在中间转发,那它是不是要理解 WebSocket 帧?
其实不需要。一旦后端返回 101,Nginx 会把这条连接从普通的 HTTP 处理流程里摘出来,切换成“隧道模式”。在这个模式下,Nginx 不再解析协议内容,只做双向字节流搬运:客户端发来的数据包原样转给后端,后端推给客户端的数据原样转回去。
这个机制带来的结果是什么?所有影响连接存活、速度、稳定性的参数,都要在握手成功之前配置好,因为一旦升级成隧道,你再改 proxy_read_timeout 也不会立刻作用于已经建立的那些连接了。所以下面的配置思路基本都是围绕“握手阶段要把该设置的都设置好”来展开的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 能跑通的最简配置:先让握手建立起来
2.1 最小可用配置
先说结论。Nginx 1.3.13 以上的版本才支持 WebSocket 反向代理的完整 Upgrade 转发,使用老版本的同学先升级。这里给一份我在测试环境跑通的最小配置:
nginx复制upstream ws_backend {
server 127.0.0.1:8080;
}
server {
listen 80;
server_name ws.example.com;
location / {
proxy_pass http://ws_backend;
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_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
这份配置里,真正让 WebSocket 代理生效的核心是四行:
nginx复制proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
其他几行 Host、X-Real-IP 是反向代理的常规操作,不加也能跑,但加上更稳妥,尤其是 Host,有些后端会用它做域名校验。
2.2 关键参数逐个拆解
先讲 proxy_http_version 1.1。Nginx 默认用 HTTP/1.0 去连上游,HTTP/1.0 没有 Upgrade 机制,所以必须显式改成 1.1。这是我见过最多人漏掉的一行,漏了之后的表现就是:配置看起来全对,返回却一直不是 101。
再讲 proxy_set_header Upgrade $http_upgrade。$http_upgrade 是 Nginx 内置变量,表示客户端请求头里的 Upgrade 字段值。如果客户端请求里带了 Upgrade: websocket,那 $http_upgrade 就等于 websocket,Nginx 会把这个值原样发给上游。这样后端才能判断出客户端想升级成 WebSocket。
然后是 proxy_set_header Connection "upgrade"。HTTP 协议里的 Connection 头属于逐跳头部,默认情况下代理不会转发。这里手动设置成 upgrade,就是告诉上游:这条连接需要升级。这个动作要和 Upgrade 头配合使用,缺一个后端都没法正确响应 101。
这里有个比较容易被喷的点:直接把 Connection 固定为 "upgrade" 会影响同一个 server 下的普通 HTTP 请求。如果只是测试环境无所谓,但生产环境我更推荐用第三节讲到的 map 变量方式,会更干净。
2.3 如何判断配置生效:101状态码验证
配置写完之后,别急着直接开前端。先保存配置并检查:
bash复制nginx -t
systemctl reload nginx
然后用 curl 模拟一次 WebSocket 握手请求:
bash复制curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
http://127.0.0.1/socket
如果配置正确,后端 WebSocket 服务也正常,响应第一行应该是:
http复制HTTP/1.1 101 Switching Protocols
如果你看到的是 200、502 甚至 404,说明还有环节没通,可以对照第四节的故障排查继续查。用这个方式验证,比直接打开浏览器看控制台要直观得多,因为它可以让你快速判断问题出在 Nginx 层还是后端服务层。
3. demo到生产:决定长连接稳不稳的四个细节
3.1 proxy_read_timeout默认60秒:长连接的隐形杀手
能握手成功只是第一步。很多同学配置完 WebSocket 代理,一开始连得挺好,但过一会儿就断了。最典型的现象是:连接在 60 秒左右准时断开,没有任何错误提示,刷新页面又好了,再等 60 秒又断。
这个“60 秒魔咒”基本就是 proxy_read_timeout 搞的鬼。
Nginx 的 proxy_read_timeout 定义了 Nginx 从上游服务器读取响应的超时时间,默认值是 60 秒。对于普通 HTTP 请求,每个请求通常在几秒内就完成了,60 秒绰绰有余。但 WebSocket 是长连接,握手成功后可能很久都没有数据流动。如果客户端和后端之间没有心跳,Nginx 会认为这条连接已经“空闲超时”,主动把它关掉。
处理方式分两种:
如果业务本身有应用层心跳,比如每 30 秒发一个 ping,那超时时间应该大于心跳间隔,建议至少是心跳间隔的两倍:
nginx复制proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
如果业务没有心跳,直接设置成一个比较大的值,比如 3600 秒,保证不会因为短暂的沉默被 Nginx 掐断。我个人的习惯是:无论有没有心跳,都会把 proxy_read_timeout 和 proxy_send_timeout 单独设置,而不是用默认值。因为只要你想在 Nginx 后面跑 WebSocket,默认值就注定不适合长连接场景。
3.2 心跳与Nginx超时时间必须对齐
刚才提到心跳,这里展开说说。
WebSocket 协议本身有 Ping/Pong 帧,服务端可以主动发 Ping,客户端回 Pong;也可以客户端发 Ping,服务端回 Pong。问题在于,很多前端同学并没有在浏览器里实现自动心跳,因为浏览器 WebSocket API 没有内置的 ping/pong 管理,需要靠 setInterval 自己写逻辑。
我之前排查过一个连接 90 秒断一次的问题,前端心跳是 30 秒一次,Nginx 的 proxy_read_timeout 默认 60 秒。理论上 30 秒一次心跳应该能续命,但实际断连时间在 90 秒左右。为什么?因为那条业务的心跳消息是客户端通过业务协议发送的,但后端服务处理心跳时没有真正触发一次可被 Nginx 感知的底层数据交换,导致 Nginx 层的“读超时”计时并没有被有效重置。
这个案例给我的教训是:Nginx 的超时设置,不能只看应用层心跳间隔,还要确认心跳真的能让 TCP 连接产生数据流动。最稳妥的办法是把 Nginx 超时时间设得足够长,而不是压着心跳间隔去计算。如果后端服务有真正的 WebSocket 协议级 Ping/Pong,那可以稍微紧一点;如果只是业务层假心跳,建议直接设置 3600 秒,省得后面排查到怀疑人生。
3.3 用map管理Connection头,别让全站请求都被upgrade
第二节给的配置里,proxy_set_header Connection "upgrade" 是固定写死的。如果这个 Nginx server 下面只有 WebSocket 服务,这么写没问题。但如果你用同一个 server 同时代理普通 HTTP API 和 WebSocket,就要小心了。
普通的 HTTP API 请求不会带 Upgrade 头,但 Nginx 转发时还是会强行加上 Connection: upgrade,等于告诉后端“这条连接要升级”。大多数后端框架能容忍这种多余的头,但有些严格校验的框架会直接返回 400,或者表现出一些奇怪的行为。
更好的做法是利用 map 指令,根据客户端是否真的发起了 Upgrade 来决定 Connection 的值:
nginx复制map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
这段配置要放在 http 块里,不能在 server 或 location 里。它的逻辑是:如果客户端请求头里有 Upgrade 字段,$connection_upgrade 的值就是 upgrade;如果为空,就用 close。
然后在代理配置里这样引用:
nginx复制proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
这样普通 HTTP 请求转发时不会被误加 Upgrade 语义,只有在真正的 WebSocket 握手请求到达时,Nginx 才会带上正确的升级头。这套写法是我目前在生产环境最推荐的方式,虽然有 map 代码,但一行不多,能省很多后患。
3.4 关闭proxy_buffering,让实时消息第一时间到达浏览器
WebSocket 建立之后,数据流是双向透明的,但 Nginx 的 HTTP 代理仍然有一些默认行为会影响体验,最典型的是 proxy_buffering。
Nginx 默认会缓冲上游返回的数据,缓冲满了再一次性发给客户端。这种做法对普通 HTTP 响应可以降低上游压力,但对实时消息系统就是灾难:后端推了一条消息,本应该毫秒级到达浏览器,结果被 Nginx 缓冲住,可能攒了一段时间才发出去,实时性严重受损,甚至会让人觉得服务不稳定。
处理方式很简单,在 WebSocket 对应的 location 里加一行:
nginx复制proxy_buffering off;
同时建议把 proxy_cache 相关的东西也关掉,WebSocket 业务不需要缓存,开着反而会引入各种奇怪的时序问题。
顺带一提,如果你的 Nginx 后面还挂了 CDN 或其他 LVS/负载均衡层,需要确认它们也支持长连接透传,否则即使 Nginx 配置没问题,数据也会在中间某一层被缓冲或者超时掐断。
4. 实战故障复盘:四次排查告诉我配置不是抄过来就结束
4.1 报错:404 Not Found,请求根本没到WebSocket服务
有一次同事来找我,说 WebSocket 代理配好了,但前端连接一直 404。我看了他的 Nginx 配置,location /ws/ 和 proxy_pass 都写对了,Upgrade 头也加了,一时看不出问题。
后来打开 access log 才发现,实际请求根本没有落到他写的那个 location /ws/,而是被另一个 location 接住了。他的 server 里还配了 Vue 项目的 history 路由:
nginx复制location / {
try_files $uri /index.html;
}
前端连接的是 ws://example.com/ws,而 Nginx 里写的是 location /ws/。按 Nginx 的匹配规则,location / 是所有前缀里最短的,location /ws 更长,理论上应该优先匹配 /ws,但因为同事加了一个正则 location,比如:
nginx复制location ~ \.(gif|jpg|png|js|css)$ {
...
}
或者更激进的一个正则把 /ws 也匹配了,导致请求被正则 location 接管,直接返回了静态资源的 404。
排查方式其实很朴素:tail -f /var/log/nginx/access.log,看请求到底进了哪个 location,再对着配置检查匹配优先级。我自己的习惯是 WebSocket 路径单独用一个域名或者一个独立 server 块,不要和静态资源服务混在同一个 location 体系里,否则规则一复杂,早晚会出问题。
4.2 报错:握手成功但连接秒断,我查了三天
这个案例很有代表性。用 curl 验证 Nginx 配置时,能看到 101 Switching Protocols,说明握手已经成功。但浏览器一连接,WebSocket 状态变成 OPEN 之后立刻又触发 close,连接生命周期不到一秒。
刚开始我怀疑是 Nginx 超时设置太短,检查发现并不是。后来直连后端服务,发现后端同样会主动断开连接,说明问题根本不在 Nginx,而在后端服务本身。
继续看后端日志才发现,应用启动时监听的是 127.0.0.1:8080,但代码里校验了请求的 Host 头。浏览器通过正式域名连接时,Nginx 虽然设置了 proxy_set_header Host $host,但后端校验逻辑要求 Host 必须是固定的内网地址,不匹配就直接拒绝。
折腾一圈,最后改的是后端代码里的 Host 白名单。这个案例给我的教训是:出现“握手成功但秒断”,优先排查后端服务层的主动断开策略,不要一上来就折腾 Nginx。可以先直连后端 WebSocket 地址,如果直连也秒断,说明是后端业务问题;如果直连正常,走 Nginx 才断,再往代理层查。
4.3 报错:502 Bad Gateway 和 upstream prematurely closed connection
还有一种高频错误是 502,Nginx error log 里写着 upstream prematurely closed connection while reading response header from upstream。
这条报错的字面意思是:Nginx 正在等待上游返回响应头,但上游连接提前关闭了。常见场景是后端服务没起来、端口写错,或者后端服务因为某些原因拒绝了 Nginx 的上游连接。
排查顺序我一般是这样:
先执行:
bash复制ss -lntp | grep 8080
确认后端进程真的在监听对应端口。如果没有监听,那就是服务没起来,或者监听地址写成了 IPv6 而 Nginx 配置里写的 IPv4。
如果端口正常,用 curl 直连后端:
bash复制curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
http://127.0.0.1:8080/socket
如果直连也 502 或者直接拒绝,说明问题在后端。如果直连能返回 101,再带上 Host 头走一遍 Nginx,逐层缩小范围。
还有一次遇到 502 是因为后端服务对 HTTP/1.1 支持不完整。Nginx 已经设置了 proxy_http_version 1.1,但后端某个中间件只实现了 HTTP/1.0,收到带 Upgrade 头的请求后直接断连。这种情况只能换后端组件版本,或者检查是不是有老旧的反向代理库在中间拦截。
4.4 隐蔽问题:default_server把WebSocket请求转给了错误站点
最后一个故障最隐蔽,排查过程也最长。现象是:同一个 Nginx 上跑了两个网站,A 站是 WebSocket 服务,B 站是普通官网。访问 A 站的 WebSocket 地址时偶尔会连到 B 站去,返回的是 B 站的一堆 HTML 或者 404。
问题出在 listen 80 default_server。
如果某个 server 块被标记为 default_server,当请求的 Host 头没有匹配到任何 server_name 时,Nginx 会把这个请求转发给 default_server。我当时配置了一个空 Host 的默认站点,用来拦截乱解析到这台服务器的域名,结果 WebSocket 服务所在的 server_name 因为 SSL 证书更新临时缺了一段配置,导致请求落到了默认站点上。
排查时建议先查看当前 Nginx 实际生效的 server 块:
bash复制nginx -T
这个命令会把所有解析后的配置打出来,重点看 listen 和 server_name 的对应关系。如果发现 WebSocket 域名没有正确落到预期 server 块,就要检查是否有 default_server 抢走了流量,或者 server_name 的书写是否有空格、下划线之类的细节问题。
这种问题很难通过看配置一眼定位,因为配置散落在多个文件里,很可能 include 的顺序不同,最终解析结果就不一样。我的习惯是:涉及 WebSocket 的域名,单独打一个 server 块文件,放到 /etc/nginx/conf.d/ 下面,和其他业务配置隔离,避免相互干扰。
5. 扩展场景:wss、负载均衡和路径分流
5.1 WSS:从ws改成wss需要多配哪些内容
WebSocket 和 HTTPS 一样,加了 TLS 之后就是 wss://。配置 WSS 代理并不复杂,关键就是先配置好 SSL,再在 location 里维持 WebSocket 的升级头。
完整的 WSS 配置片段:
nginx复制server {
listen 443 ssl;
server_name ws.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
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-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_buffering off;
}
}
注意 X-Forwarded-Proto $scheme 这个头,后端服务如果想判断客户端是通过 ws 还是 wss 访问,就需要这个头。$scheme 在 443 端口下会是 https,后端就能据此区分。
Nginx 上的 TLS 终止之后,Nginx 与后端之间走的是普通 ws:// 明文。如果内网环境也不是完全可信,可以让 Nginx 用 https:// 去连后端,但那样需要额外处理证书信任和上游证书校验,配置复杂度会上升不少。绝大多数场景下,Nginx 到后端走内网明文 ws 是可以接受的。
5.2 多后端实例与粘性问题
WebSocket 长连接和普通 HTTP 请求最大的区别是:普通请求可以随意打到任何一台后端,因为每个请求是独立的;但 WebSocket 连接一旦建立,后续的业务消息都依赖这条连接状态。如果前端通过负载均衡随机连到了 A 实例,而后端的某些业务状态又只保存在 A 实例内存里,那这条连接后续的所有消息都应当由 A 实例处理。
Nginx 的默认轮询算法会把不同请求分发到不同后端,但 WebSocket 隧道只有一个 TCP 连接,一旦连接建立就不存在“后续请求被分流”的问题,因为字节流始终走同一条隧道。这里真正的风险是:如果浏览器发起多次重连,每次建立新连接时可能被分配到不同的后端实例,而后端如果做了单机内存推送状态,重连后的连接落在 B 实例上,A 实例上的旧状态就丢了。
解决办法是给 upstream 配置粘性策略,最简单的就是 ip_hash:
nginx复制upstream ws_backend {
ip_hash;
server 10.0.0.1:8080;
server 10.0.0.2:8080;
}
ip_hash 会根据客户端 IP 计算哈希,同一个 IP 的请求总是落到同一台后端。缺点是如果客户端 NAT 出口 IP 变化较大,或者有大楼统一出口 IP,Hash 可能导致负载不均。生产规模比较大时,可以考虑使用 Nginx Plus 的 sticky 模块或者基于 Cookie 的一致性哈希,但中小规模下 ip_hash 已经够用。
如果你的后端是分布式架构,所有实例共享一套会话状态,推送消息可以通过 Redis 等中间件广播,那也可以不用粘性配置,直接轮询即可。先想清楚后端的会话模型,再决定要不要加这层配置。
5.3 同一入口同时代理HTTP API与WebSocket的路径分流
实际项目里,很少会单独给 WebSocket 开一台 Nginx,更多是同一个域名的 /api/ 走普通 HTTP,/ws/ 走 WebSocket。这种情况下按路径分流就很重要。
看一个我在多项目里用过的结构:
nginx复制upstream http_api {
server 127.0.0.1:3000;
}
upstream ws_service {
server 127.0.0.1:8080;
}
server {
listen 80;
server_name example.com;
# 普通 HTTP API 不需要 Upgrade 头
location /api/ {
proxy_pass http://http_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# WebSocket 服务单独一个路径
location /ws/ {
proxy_pass http://ws_service;
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 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
前端连接地址就写成 ws://example.com/ws,HTTP 接口的地址是 http://example.com/api/xxx。这样两个服务在 Nginx 层被清晰地区分开,/api/ 不会带上无意义的 Upgrade 头,/ws/ 也不会被普通 HTTP 的缓存策略干扰。
这里有一个配置层级的小提醒:如果用了 map $http_upgrade $connection_upgrade,一定要放在 http 块内、server 块之外,否则 Nginx 会直接报错。我见过不少同学把 map 写进某个 server 文件里,重载配置后发现 unknown "connection_upgrade" variable,其实就是层级错了。
再补充一点关于 path 的细节:location /ws/ 只会匹配以 /ws/ 开头的请求,如果前端连接的是 /ws,不带最后的斜杠,是不会命中这个 location 的。要么前端统一用 /ws/,要么 Nginx 再补一个精确匹配:
nginx复制location = /ws {
return 301 /ws/;
}
别小看这个斜杠,我曾经就因为前端和后端约定不一致,浪费了半个下午排查为什么握手请求走到了静态资源逻辑。
最后分享一个我验证这类配置的习惯:先直连后端跑一次握手请求,确认后端能返回 101;再带完整 Host 头走 Nginx 跑一遍,确认代理层也能返回 101;最后挂机观察超过心跳周期后连接是否还活着。三步都通过了,再让前端同学把页面刷新起来联调,基本一次过。这套流程看起来简单,但能帮你把 Nginx 配置和后端问题快速分成两个层面,排查效率会高很多。
