作为一个常年跟 Nginx 打交道的人,我太清楚 WebSocket 代理这个需求为什么会有这么多人搜了。前后端分离的项目一多,WebSocket 服务往往不会跟前端页面跑在同一个端口上,甚至压根不在同一台服务器,这时候 Nginx 反向代理几乎成了绕不开的一环。但我发现网上很多教程只丢给你一段配置,然后说“复制粘贴就能用”,完全不解释为什么这么写,结果大家照抄之后遇到 502、连接秒断、握手失败,只能干瞪眼。
这篇东西我不会只给你一个配置模板,而是把 WebSocket 代理的底层逻辑、每个配置项背后的原因、以及我实际踩过的坑全部掰开揉碎讲清楚。不管你是临时搭个 demo,还是给生产环境做负载均衡,看完这篇你应该都能心里有数。
1. WebSocket 代理的底层逻辑,以及 Nginx 为什么不是“配个 proxy_pass 就行”
1.1 一次普通 HTTP 代理和一个 WebSocket 请求的本质差异
先说个最简单的场景:你把一个后端服务挂在 Nginx 后面,配置大概是这样的:
location /api/ { proxy_pass http://backend_server; }这个配置对普通 HTTP 请求完全没问题。因为普通 HTTP 请求的生命周期很短——客户端发一个请求,Nginx 转发给后端,后端返回响应,连接就结束了(HTTP/1.1 里长连接也只是复用 TCP,但语义上还是“一问一答”)。
但 WebSocket 不一样。WebSocket 是全双工长连接,客户端和后端建立连接之后,双方可以随时互相推数据,这个连接要维持很久,甚至几十分钟、几个小时。这就带来了两个问题:
- 连接升级:WebSocket 的建立过程,本质上是客户端先发一个普通的 HTTP 请求,然后通过
Upgrade和Connection两个 Header 请求协议切换,从 HTTP 升级到 WebSocket。 - 长连接维持:一旦升级成功,这个 TCP 连接就不能被 Nginx 随便掐断,也不能像普通 HTTP 请求那样“转发完响应就完事”。
所以,Nginx 代理 WebSocket 的核心,在于两点:正确传递升级相关的 Header,以及正确配置超时时间。
1.2 Nginx 是从哪个版本开始支持 WebSocket 代理的
这里有个背景知识很多人不知道——Nginx 一直到 1.3.13 版本才开始正式支持 WebSocket 代理,此前一直是不行的。如果你还在用很老版本的 Nginx(比如 CentOS 6/7 自带的 1.4 或者 1.8),想要代理 WebSocket 会遇到各种诡异问题,建议先升级到 1.18+ 或 1.20+。
当然现在主流系统源里基本都是 1.18 以上了,这个版本问题在 2023 年之后基本不用太担心,但如果你用的是某个古董项目的锁死版本,这个点值得留意。
1.3 Nginx 在 WebSocket 代理里到底扮演一个什么角色
你可以把 Nginx 理解成一个“传话筒”:
- 客户端对 Nginx 发起 WebSocket 握手请求;
- Nginx 把握手请求原样转发给后端;
- 后端返回 101 Switching Protocols;
- 之后 Nginx 就不再关心应用层协议了,它只负责双向转发字节流,直到某一方断开。
这就是为什么代理 WebSocket 的配置,核心就是保证握手阶段的信息完整传递,然后在proxy_read_timeout和proxy_send_timeout这两个参数上做好长连接保活。
2. 最基础的 WebSocket 代理配置拆解:每个配置项到底是干嘛的
2.1 一份能跑通的最小配置
假设你的 WebSocket 后端跑在127.0.0.1:8080,前端要访问的路径是ws://your-domain.com/ws,那么最小可用的配置是这样的:
map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream websocket_backend { server 127.0.0.1:8080; } server { listen 80; server_name your-domain.com; location /ws/ { 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; } }这一段配置里,缺了任何一个都不行,下面逐个说。
2.2 map 指令:为什么不能直接写死 Connection 的值
这段配置里最不好理解的就是开头的 map。很多第一次接触的人会问:我直接在proxy_set_header Connection "upgrade"写死不行吗?非要 map 干嘛?
答案是:不行,除非你确定这个 location 下永远只有 WebSocket 请求。
map $http_upgrade $connection_upgrade的意思是:根据客户端请求里的Upgrade头,动态决定我们要转发的Connection头的值。
- 如果客户端带了
Upgrade: websocket,说明这是一个 WebSocket 握手请求,那我们就转发Connection: upgrade; - 如果客户端没有带 Upgrade 头(比如普通 HTTP 请求),那我们就转发
Connection: close。
如果你直接写死Connection: upgrade,那么所有经过这个 location 的普通 HTTP 请求也会被标成 upgrade。虽然大部分后端不会真的理你,但在某些严格校验 Header 的后端框架下,HTTP 请求会直接 400 或者 502。我在 OpenResty 和某些 Java 网关后面都踩过这种坑——同一个 location 既要处理普通 API 又要处理 WebSocket 的时候,map 是必须的。
2.3 proxy_http_version 1.1:老生常谈但别踩
proxy_http_version 1.1;这个是发给上游后端的 HTTP 版本。Nginx 默认用的是 1.0,而 HTTP/1.0 协议里Connection 头的语义和 1.1 完全不同。WebSocket 握手是在 HTTP/1.1 之上定义的,如果 Nginx 用 1.0 去跟后端通信,Upgrade 机制根本没法正常工作。
实际表现就是你看到 Nginx 返回 502 Bad Gateway,而后端日志里什么都没有——因为后端可能压根没收到一个它能识别的握手请求。
2.4 必须显式传递的 Header:Host、X-Real-IP、X-Forwarded-For
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 握手是否成功:
- Host:后端有些框架(比如某些版本的 Spring WebSocket、Socket.IO)会校验 Host 头,如果你不显式设置,Nginx 默认会把后端地址填进 Host,导致后端校验失败。
- X-Real-IP / X-Forwarded-For:后端需要拿到客户端真实 IP 来做鉴权、限流、日志统计。尤其 WebSocket 连接一旦建立就是长连接,日志里如果全是 Nginx 内网 IP,排查线上问题会非常痛苦。
2.5 关键的 timeout 配置:为什么你的连接几秒钟就断
如果你用上面的最小配置跑通了握手,但发现连上之后 10 秒、20 秒就会被断开,问题出在 Nginx 的两个默认超时参数上:
proxy_read_timeout默认 60sproxy_send_timeout默认 60s
对于普通 HTTP 请求,60 秒完全够用。但 WebSocket 连接建好之后,如果一分钟内双方都没有数据交互,Nginx 就会主动把这个连接断掉。这不是后端的问题,是 Nginx 认为这个连接“空闲超时”了。
所以生产环境一般要调大:
proxy_read_timeout 3600s; proxy_send_timeout 3600s;我个人的习惯是设 3600 秒(一小时),然后配合应用层心跳来保活。为什么不是干脆设成 86400 秒?因为如果客户端断网、或者进程被杀,TCP 连接不一定能立刻感知到。设一个合理的心跳 + 超时机制,能让 Nginx 在客户端失联后主动回收连接,避免后端堆积一堆半死不活的连接。
注意:超时时间调大不等于连接永远不断。WebSocket 协议本身没有强制心跳,但实际开发中一定要在应用层实现 ping/pong 心跳机制,否则网关层、防火墙、云服务商的连接空闲超时都会掐掉你的连接,Nginx 只是其中的一环。
3. 进阶场景:多实例负载均衡、WSS、路径重写
3.1 多后端实例下的负载均衡策略:为什么不能用默认轮询
WebSocket 服务上了规模之后,肯定不止一个后端实例,于是你的 upstream 会变成这样:
upstream websocket_backend { server 127.0.0.1:8080; server 127.0.0.1:8081; server 127.0.0.1:8082; }默认的负载均衡策略是轮询(round-robin),这在普通 HTTP 场景下很好,但在 WebSocket 场景下天然有缺陷。
问题在于:WebSocket 连接建立之后会一直维持,如果客户端首次握手被分到了 8080,后续这个连接的所有双向流量都必须在 8080 上处理。但 Nginx 的轮询只发生在握手阶段——如果某个客户端断开重连,或者多个新客户端同时进来,轮询能保证连接在三个实例之间平均分布吗?能,但有一个隐患:每次重连可能会落到不同的实例上。
拿聊天室场景举例:某个用户连接断了,自动重连,结果被分到了一个不同的后端实例,而这个实例上没有他在原来的实例上的会话状态(比如房间列表、用户身份缓存),那他就会掉线。这不是 Nginx 的错,是后端没有做状态同步的问题,但一个副作用是——同一用户的多次重连会打到不同的实例上,让问题暴露得特别明显。
解决方案有两种:
方案一:ip_hash
upstream websocket_backend { ip_hash; server 127.0.0.1:8080; server 127.0.0.1:8081; server 127.0.0.1:8082; }ip_hash会基于客户端 IP 做哈希,同一 IP 的请求永远分到同一个后端实例。对于聊天室、消息推送这类应用,能最大限度保证重连后落在同一个实例上。
方案二:sticky sticky
upstream websocket_backend { sticky; server 127.0.0.1:8080; server 127.0.0.1:8081; server 127.0.0.1:8082; }这是 Nginx 商业版(Nginx Plus)里的指令,开源版默认没有。社区版如果想做会话保持,要么用 ip_hash,要么自己基于 Cookie 做路由。碰到这种需求,我一般直接建议后端把状态做成跨实例共享(Redis pub/sub、分布式消息队列),把“连接落在哪个实例”变成一个不重要的信息。这样即使 Nginx 轮询也无所谓。
3.2 WSS(WebSocket over TLS)代理配置
如果你的站点已经上了 HTTPS,那么前端代码里用的就是wss://your-domain.com/ws,这个时候 Nginx 要监听 443 并配置 SSL 证书,然后在 location 里同样设置代理。配置长这样:
server { listen 443 ssl; server_name your-domain.com; ssl_certificate /etc/nginx/ssl/your-domain.crt; ssl_certificate_key /etc/nginx/ssl/your-domain.key; location /ws/ { 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_set_header X-Forwarded-Proto $scheme; } }注意:proxy_pass用的是http://而不是https://,因为 SSL 已经由 Nginx 终止了,Nginx 和后端之间走的是内网明文 HTTP,这是非常标准的做法。除非你的后端服务自己也要求 HTTPS 通信,那才需要用到proxy_pass https://。绝大多数情况下你不应该让 Nginx 到后端的链路上再走一遍 TLS,纯属浪费性能。
3.3 路径重写的情况:前端连的地址和实际后端路径不一致
还有一种常见场景:前端代码里写的是ws://your-domain.com/socket,但后端 WebSocket 的挂载路径是/ws,这就要用 Nginx 的路径重写能力。
location /socket { proxy_pass http://websocket_backend/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; }proxy_pass后面的 URI 部分(/ws)会覆盖掉location匹配到的原始路径。这个机制和普通 HTTP 的proxy_pass路径替换是一样的,WebSocket 场景下也适用,没啥额外的坑,唯一的注意事项是:Rewrite 之后的 URI 也会作为握手请求的 path 发给后端,所以后端必须确实在/ws这个路径上监听,否则握手会 404。
4. 常见问题与排查技巧实录
4.1 问题一:握手返回 502 Bad Gateway
这是最经典的问题。排查思路按顺序来:
- 先确认后端 WebSocket 服务本身是通的:在 Nginx 所在服务器上直接用
curl带 Upgrade 头测试:
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \ http://127.0.0.1:8080/ws/如果后端正常,你会看到返回101 Switching Protocols。如果这一步就 502 或者连接拒绝,问题在后端,不在 Nginx。
- 再确认 Nginx 的 error.log。重点看有没有
upstream prematurely closed connection之类的报错。这个报错通常意味着Nginx 用 1.0 协议去跟后端握手,后端直接把连接关了——检查是否配了proxy_http_version 1.1。
4.2 问题二:握手成功但 1 分钟之后必断
这个我前面已经讲过了,核心检查两个点:
proxy_read_timeout 3600s; proxy_send_timeout 3600s;再就是看应用层有没有心跳。如果你用的是 Python 的websockets库、Node 的ws库,建议自行实现 ping/pong 机制。Nginx 只是最后一道防线,不是保活机制。
另外注意一点:就算你把 Nginx 超时调到一小时,两次心跳的间隔一定不能超过一小时,而且最好留足余量——比如每 30-50 秒发一次 ping。因为有时候网络抖动,一个 ping 丢了,如果下一次心跳间隔太久,Nginx 就已经断连了。
4.3 问题三:握手返回 403 Forbidden
这个比较隐性。有些后端框架(比如较老版本的 Socket.IO)会对请求来源做 Origin 校验。你通过 Nginx 代理后,前端页面所在的域名和后端地址不一致,如果 Nginx 没有正确的 Host 传递,或者后端配置了 CORS/Origin 白名单,握手请求会被后端拒绝。
检查方法:
- 看后端日志里的 Origin 值;
- 看 Nginx 里
proxy_set_header Host $host有没有配; - 在后端安全配置里把
your-domain.com加进 Origin 白名单。
还有一种情况是云防火墙或者安全组拦截了 WebSocket 的 Upgrade 请求,表现为 curl 直连 Nginx 正常,但从公网访问失败——这时候检查安全组规则,确保不是只开放了 TCP 80/443 而没有放行其他端口(不过一般 WebSocket 代理场景都是走 80/443,这种概率不大)。
4.4 问题四:HTTP/2 和 WebSocket 的兼容性坑
如果你的站点开了 HTTP/2:
listen 443 ssl http2;然后有些浏览器会报错或者握手失败。原因在于 HTTP/2 最初规范里没有定义 Upgrade 机制(HTTP/2 有自己的连接建立方式)。虽然后来 RFC 8441 补充了扩展的 CONNECT 方法支持 WebSocket over HTTP/2,但浏览器实现参差不齐。
实际生产中我见过的做法普遍是:
- 要么直接关闭 HTTP/2,因为 WebSocket 之后本来就不依赖 HTTP/2 的多路复用优势;
- 要么在 Nginx 1.19+ 上配置
listen 443 ssl http2;但把代理设置为 HTTP/1.1 传给后端(因为大多数后端 WebSocket 库也不支持 HTTP/2 的 WebSocket)。
目前主流做法是 Nginx 到浏览器这层可以走 HTTP/2(握手本身没问题),但 Nginx 到后端一定走 HTTP/1.1。只要proxy_http_version 1.1在,Nginx 会自动处理其中的折衷,多数场景不会出大问题。真要遇到个别浏览器握手异常,可以临时关闭 HTTP/2 做 A/B 对比验证是不是协议版本的锅。
5. 日志、监控与安全加固建议
5.1 怎么确认 Nginx 真的代理了 WebSocket,而不是在做普通转发
最直接的方法是看日志。把 Nginx 的 access log 加上$http_upgrade字段,并单独为 WebSocket 相关的 location 设置日志格式:
log_format ws_logger '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_upgrade"'; server { access_log /var/log/nginx/ws_access.log ws_logger; location /ws/ { proxy_pass http://websocket_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } }然后在客户端发起 WebSocket 连接,去日志里看"$http_upgrade"字段是不是websocket。如果全是空值,说明你的客户端根本没发 Upgrade 请求,问题在前端 JS。如果握手成功但一直接收不到数据,再配合 tcpdump 抓包看后端是否正常返回数据。
5.2 WebSocket 场景下要不要调整的应用层参数
这里我说的不是 Nginx 参数,而是操作系统内核参数。一台服务器上有大量 WebSocket 长连接时,会占用大量文件描述符,你可以监控这个:
ulimit -n生产环境建议把worker_rlimit_nofile调大:
worker_rlimit_nofile 65535;以及在系统层面把每个进程的文件描述符上限调大(修改/etc/security/limits.conf)。这不是 WebSocket 专属,但 WebSocket 长连接比普通 HTTP 请求更容易吃满 fd——因为每个连接都长期占用,不像普通请求处理完就释放。
5.3 安全加固的几个值得注意的细节
WebSocket 代理之后,因为连接是长连接,容易成为攻击目标。我在实践中会至少做这三件事:
第一:限流和并发限制。用limit_conn_zone限制单 IP 的连接数:
limit_conn_zone $binary_remote_addr zone=websocket_conn:10m; server { location /ws/ { limit_conn websocket_conn 10; # 其他配置... } }第二:只允许特定路径走 WebSocket 代理。不要把整个 server 块都套上 Upgrade 头,严格限定在/ws、/socket.io这样的专属路径下。
第三:TLS 优先。能上 WSS 就尽量不要用明文 WS,毕竟 WebSocket 连接一旦建立就是持续性双向通信,中间被截获的话,泄露的数据量远大于普通的一次性 HTTP 请求。
5.4 一个生产环境的完整参考配置
最后给你一个我实际用过比较稳的生产配置模板,可以直接改成自己的域名和后端地址用:
map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream ws_backend { ip_hash; server 10.0.0.11:8080 max_fails=3 fail_timeout=30s; server 10.0.0.12:8080 max_fails=3 fail_timeout=30s; } server { listen 443 ssl; server_name ws.example.com; ssl_certificate /etc/nginx/certs/ws.example.com.crt; ssl_certificate_key /etc/nginx/certs/ws.example.com.key; limit_conn_zone $binary_remote_addr zone=ws_conn:10m; access_log /var/log/nginx/ws_access.log ws_logger; location /ws/ { limit_conn ws_conn 20; 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_read_timeout 3600s; proxy_send_timeout 3600s; } }这里提一个很容易忽略的小细节:upstream 里server地址如果有端口就写端口,后端如果直接监听 80 就不要写端口(写了对端口的默认值,但很容易因为两个后端一个监听一个没监听导致负载不均),每次上线新实例之前务必确认后端进程真的监听在你填写的端口上。我见过好几次排错半天,最后发现是 upstream 里写了个 8081,但新实例只起了 8080。
6. 从一次真实排障聊聊配置之外的心得
这篇文章写到这儿,配置、原理、坑都讲得差不多了。最后分享一次我印象特别深的排障经历,跟配置本身关系不大,但我觉得比任何配置项都更能说明 WebSocket 代理这件事的本质。
有一次线上 WebSocket 服务每隔一段时间就会出现一批连接断开。我检查 Nginx 配置,proxy_read_timeout已经调到 3600 秒,后端心跳也正常,日志里没有任何报错,服务器负载也不高。后来排查到最后发现,是云服务商负载均衡那一层默认连接空闲超时只有 300 秒。
也就是说,你 Nginx 配得再合理,你的流量在到达 Nginx 之前,可能已经过了一层云 LB、一层防火墙、一层 CDN,每一层都有自己的空闲连接超时策略。WebSocket 代理从来不只是 Nginx 一个环节的事情,而是一条完整链路的问题。
所以后来我做 WebSocket 项目,一定会做的第一件事就是画一条访问链路图:客户端 -> CDN(有没有)-> 云 LB(有没有)-> Nginx -> 后端。然后逐个确认每一层对空闲连接的处理方式,再统一设计心跳频率。
这也是为什么我一直强调,应用层心跳必须做,而且心跳间隔要小于所有链路节点中最小的超时时间。这不是 Nginx 配置可以替代的,反而是 Nginx 配置给了你一个兜底的保护,让你即使心跳有一两次丢失也不会立刻断连。
WebSocket 代理这个事,配置说复杂也复杂,说简单也简单。复杂是因为涉及到 HTTP 协议升级、长连接超时、负载均衡会话保持这么多层面;简单是因为只要你理解了它的本质——Nginx 在握手阶段只是一个聪明的传话筒,在连接维持阶段只是一个透明的字节转发器——那么上面所有配置就都顺理成章了。希望这篇文章能让你少踩一些我当年踩过的坑。