Nginx配置WebSocket反向代理:原理、常见坑与实战配置

发布时间:2026/9/18 20:44:58
Nginx配置WebSocket反向代理:原理、常见坑与实战配置
1. 先搞清楚为什么Nginx默认转不了WebSocket做Web开发的朋友应该都遇到过这个场景后端WebSocket服务跑得好好的本地直连一切正常一放到Nginx后面就各种连不上、连上就断、或者直接报错。我自己最早踩这个坑的时候也纳闷——Nginx做HTTP反向代理明明很成熟怎么到了WebSocket这儿就失灵了关键在于WebSocket和普通HTTP的握手机制不一样。普通HTTP请求是“一问一答”客户端发一个请求服务端回一个响应完事儿。但WebSocket不是它需要通过HTTP协议发起一个特殊的“升级”请求把连接从HTTP协议切换到WebSocket协议之后这条TCP连接就变成全双工的长连接客户端和服务端可以随时互相推数据。这个升级动作靠的是HTTP请求头里的两个关键字段Upgrade: websocket和Connection: Upgrade。Nginx在做反向代理的时候默认只会转发常规的HTTP头。Upgrade和Connection这两个头因为涉及到连接语义的变更Nginx默认是不往后端传的。也就是说客户端告诉Nginx“我想升级协议”但Nginx把这句话吞掉了后端收到的就是一个普通HTTP GET请求自然不会返回101 Switching ProtocolsWebSocket握手自然失败。这就好比你打电话给前台说要找技术部前台应该帮你转接过去但这个前台默认只传话、不转接技术部那边接起电话听到的只有“喂”没有前因后果两边根本对不上。Nginx要转WebSocket核心任务只有一个把Upgrade和Connection这两个头正确传给后端。听起来简单但实际操作里还有超时、路径转发、wss证书、负载均衡会话保持等一系列问题下面逐个拆开讲。这篇内容适合谁看后端开发、前端需要联调WebSocket接口的、运维同学以及那种“照着网上配置抄了一遍但还是不行”的朋友。我会从原理讲到实战再把常见的坑一个个排掉尽量做到你看完就能直接上手配置。2. 手写核心配置一条条讲清楚它在干什么2.1 一份能跑的WebSocket转发配置长什么样先给一份最基础、可直接用的Nginx配置后面再逐行解释。假设你的WebSocket后端服务跑在127.0.0.1:8080域名是ws.example.comserver { listen 80; server_name ws.example.com; location /ws { proxy_pass http://backend_ws; 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; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } } upstream backend_ws { server 127.0.0.1:8080; }这段配置的核心就三行proxy_http_version 1.1、proxy_set_header Upgrade $http_upgrade、proxy_set_header Connection upgrade。2.2 为什么必须设置Connection为upgrade先解释proxy_set_header Connection upgrade。之前说了WebSocket握手时客户端会发Connection: Upgrade表示“这条连接之后要升级协议”。Nginx默认在转发请求时会用一个比较保守的策略处理Connection头——因为它自己也要管理连接的复用这个头传不传、怎么传直接影响Nginx和后端之间的连接行为。当你明确设置了Connection upgradeNginx就会知道嘿这条连接我要特殊对待转发完握手请求后不能关闭得保持住让客户端和后端之间的数据能够双向流动。如果不设置这一行Nginx会用自己的连接管理逻辑来处理大概率在握手完成后就把连接关了或者没建立正确的长连接状态表现为客户端一直pending、握手失败、或者连接秒断。proxy_set_header Upgrade $http_upgrade则是把客户端发来的Upgrade头的值原样透传。$http_upgrade是Nginx内置变量代表客户端请求头中Upgrade字段的值。如果客户端发的是Upgrade: websocket那这里就会传websocket给后端。写成变量形式的好处是灵活——如果客户端没发起升级请求比如就是普通HTTP请求这个变量为空Nginx不会强行追加Upgrade头不影响正常HTTP转发。2.3 proxy_http_version 1.1的用意proxy_http_version 1.1很多人会忽略但这行非常关键。Nginx向后端发起请求时默认用的是HTTP/1.0协议。而HTTP/1.0协议里Connection头的语义和1.1不一样1.0默认是短连接每次请求完就关闭。WebSocket握手要求的是HTTP/1.1及以上的协议因为只有HTTP/1.1才定义了Upgrade机制和持久连接的默认行为。如果不设置这一行Nginx用HTTP/1.0去跟后端通信即使你传了Upgrade头后端可能也不认或者连接根本维持不住。这属于那种“配置看起来没问题、但就是不工作”的典型坑。2.4 超时参数必须调大proxy_read_timeout和proxy_send_timeout这两个参数默认值都是60秒。对于普通HTTP请求来说60秒够用了。但WebSocket是长连接建立连接后可能挂很久有的业务场景甚至需要连接保持几个小时不中断。如果还用默认60秒Nginx会在60秒内没收到后端数据时主动断开连接表现就是客户端那边WebSocket莫名其妙断开报错是1006或者连接重置。实际操作中这两个值可以设置为3600s一小时甚至更大具体取决于业务场景。注意这里设置的是“Nginx与后端之间”的超时不是客户端与Nginx之间的。如果客户端那边断开Nginx会感知到并关闭与后端的连接但如果只是客户端与Nginx之间60秒没有数据交互而你在Nginx和后端这边配了3600s超时连接是能保住的。2.5 路径转发时的陷阱配置里location用的/ws意思是以/ws开头的请求路径会被转发到后端。这个路径怎么设计有讲究。很多前端WebSocket的URL是ws://domain/ws?tokenxxx这种带query参数的形式只要location匹配/ws就能命中。但要注意proxy_pass后面的URL如果带了路径比如proxy_pass http://backend_ws/socket那么location匹配的路径会被替换掉容易整出404。最稳妥的做法是proxy_pass不带路径只写到upstream名称让Nginx把原始请求URI原样转发给后端。举个例子客户端请求ws://domain/ws/chatlocation /ws命中proxy_pass http://backend_ws;不带路径时后端实际收到的请求URI是/ws/chat跟客户端发的一样。后端WebSocket服务如果监听的是/ws/chat这个路径就能正确匹配。如果你在proxy_pass里写了http://backend_ws/socket那后端收到的URI就变成了/socket/chat很容易404。这个坑非常隐蔽尤其是在前后端联调的时候两边都以为自己在跟同一个路径通信实际中间被改掉了。3. 实战配置升级从HTTP到HTTPS再到负载均衡3.1 场景一同一域名下同时代理普通接口和WebSocket实际项目中WebSocket很少独立占一个域名更多是跟REST API混在同一个域名下。比如你有一个电商项目/api开头的走HTTP接口/ws开头的走WebSocket。这时候可以把两个location写在一个server里server { listen 80; server_name example.com; # 普通REST接口 location /api/ { proxy_pass http://backend_api; 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长连接 location /ws/ { proxy_pass http://backend_ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }这种配置的好处是前端只需要记住一个域名不用区分什么接口走哪个端口。而且location /ws/这个写法有个特性如果请求路径是/ws不带尾部斜杠会301跳转到/ws/。对WebSocket客户端来说跟随301跳转时能不能正确处理Upgrade头取决于客户端的实现。为了保险起见建议前端地址直接写完整的/ws/路径或者后端路由设计时把/ws和/ws/都兼容掉避免不必要的跳转。3.2 场景二wss安全连接配置WebSocket跑在HTTPS下就变成了wss这是目前生产环境最常见的形态。浏览器有安全策略HTTPS页面里只能发起wss请求不能发起ws请求。所以一旦你的站点上了HTTPSWebSocket也得跟着上wss。Nginx配置wss转发其实很简单跟普通HTTPS配置一样SSL证书照常配关键是location部分跟HTTP版一模一样server { listen 443 ssl; server_name ws.example.com; ssl_certificate /etc/nginx/ssl/ws.example.com.pem; ssl_certificate_key /etc/nginx/ssl/ws.example.com.key; ssl_protocols TLSv1.2 TLSv1.3; location /ws/ { proxy_pass http://backend_ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }注意Nginx监听443端口做SSL终止但向后端转发的协议还是httpNginx和后端之间不需要再走一层TLS除非你刻意追求内网加密。如果不小心把proxy_pass写成了https://backend_ws那又得多配一套证书和SSL握手没必要内网直接走http高效且简单。wss场景有一个特别容易踩的坑后端业务代码里判断请求来源的协议。有些后端框架会读取请求的X-Forwarded-Proto头来判断当前是HTTP还是HTTPS从而生成不同的回调地址或者做安全校验。但Nginx默认不会传这个头导致后端以为自己在HTTP环境下生成了http://开头的回调地址前端一访问就变成混合内容被浏览器拦截。解决方法是把请求协议头也转发过去proxy_set_header X-Forwarded-Proto $scheme;3.3 场景三多后端节点下如何保持会话当WebSocket后端服务做了负载均衡配置方式会复杂一些。先看一个常见的错误配置upstream backend_ws { server 127.0.0.1:8081; server 127.0.0.1:8082; }Nginx默认的负载均衡算法是轮询。一个WebSocket连接进来Nginx把它转发给8081下一条连接可能就转发给8082了。问题在于如果业务里用到了广播消息比如聊天室里A用户连的是8081、B用户连的是8082而8081发广播的时候只知道自己的连接列表不知道8082上的连接B就收不到消息。这属于典型的“分布式WebSocket的会话共享”问题。Nginx层面能做的优化是让同一个客户端的连接始终转发到同一个后端节点这样至少单连接场景下没问题。方法有两种一是用ip_hash根据客户端IP做哈希同一IP的请求总是落到同一台后端upstream backend_ws { ip_hash; server 127.0.0.1:8081; server 127.0.0.1:8082; }但ip_hash粒度粗糙同一个局域网出口IP的所有用户会被分到同一台机器负载不均衡。更精准的做法是用sticky模块基于Nginx生成的cookie做会话保持upstream backend_ws { sticky namesrv_id expires1h; server 127.0.0.1:8081; server 127.0.0.1:8082; }不过sticky模块是Nginx商业版或者OpenResty等发行版才有的功能开源版Nginx默认不带。开源版想实现类似效果可以自己在后端逻辑里配合Redis做连接信息共享或者用hash $http_sec_websocket_key这种方式根据WebSocket握手请求头里的key做哈希也能保证同一个客户端的连接走同一个后端upstream backend_ws { hash $http_sec_websocket_key consistent; server 127.0.0.1:8081; server 127.0.0.1:8082; }$http_sec_websocket_key是浏览器在WebSocket握手时自动生成的一个Base64编码的key每个连接都不同同一个连接重连时会重新生成所以哈希的结果不一定稳定。但配合consistent一致性哈希至少能让集群扩缩容时受影响的连接数最少。说实话多节点WebSocket场景下最可靠的方案还是后端自己做连接注册中心比如Redis Pub/Sub或者MQ广播Nginx只需要做好转发就行。Nginx层的会话保持只是锦上添花不要指望它能解决所有问题。3.4 配置热加载与验证写完配置后先验证再重载顺序不能乱否则容易造成线上服务短暂中断。我的习惯是这样# 第一步检查配置语法 nginx -t # 第二步语法没问题再reload nginx -s reloadnginx -t会检查所有配置文件如果报错会告诉你哪个文件第几行有问题比如upstream指令放错层级啦、少了个分号之类的。没报错再执行nginx -s reload这个操作是平滑重载不会中断现有连接新配置从下一个新连接开始生效。注意WebSocket这种长连接reload不会影响已经建立的连接已经在跑的长连接还是会走旧配置新建立的连接走新配置。所以如果你调了proxy_read_timeout想让它对现有连接生效可能要重启Nginx才能全部生效或者等现有连接自然断开。顺便分享一个排查配置问题的技巧在server块里临时加一行error_log /var/log/nginx/ws_error.log debug;把日志级别调到debug然后重新加载配置发起一次WebSocket连接。debug日志会非常详细地打印出Nginx转发请求的每一个环节包括它把哪些头传给后端、后端返回了什么状态码。看完日志记得把日志级别改回error不然日志量太大会把磁盘打满。这个技巧在我处理“配置看起来完全正确但WebSocket就是连不上”的疑难问题时帮过我不少次。4. 常见报错排查从400到101再到连接秒断4.1 问题速查表整理一份我在实际排障中经常用到的对照表按状态码和表现分门别类遇到问题可以直接对号入座现象可能原因解决方案返回400 Bad Request缺proxy_http_version 1.1或Connection upgrade配置核对WebSocket三件套http_version、Upgrade、Connection返回404 Not Foundproxy_pass带了路径导致URI被改写proxy_pass后不要写路径只写http://upstream名返回502 Bad Gateway后端服务没启动/端口不通/upstream名称写错检查后端进程、netstat端口监听、upstream配置返回503 Service Unavailableupstream里所有server都挂了检查后端服务以及upstream的max_fails设置握手一直pending不返回101防火墙拦截、后端限流、Nginx和后端之间网络不通后端直连测试排除Nginx因素检查防火墙规则连接成功但65秒左右必断proxy_read_timeout还是默认60秒调大proxy_read_timeout和proxy_send_timeout为3600swss页面连不上ws页面是HTTPS环境但WebSocket用了ws://协议改用wss://Nginx监听443配SSL连接被CSP策略拦截前端页面Content-Security-Policy限制连接来源在CSP的connect-src中加入你的WebSocket域名这里重点展开两个我实际排查中花过最多时间的坑。第一个是“明明配了Upgrade头后端还是收不到”。这种情况多半是因为你的Nginx配置里proxy_set_header Upgrade $http_upgrade;这行写在了if块里或者被某个location的继承规则覆盖了。Nginx的proxy_set_header指令有继承特性子location会继承父级server或http块的设置但如果子location里自己又定义了一个proxy_set_header那父级的设置在这个子location里就全部失效需要重新写一遍。这个行为比较反直觉很多人以为子级会合并父级的设置实际是“覆盖”而不是“合并”。所以最稳的写法是要么把公共的头设置放在http或者server块location里不再重复定义任何proxy_set_header要么每个location里把需要的头完整写全不要写一半。第二个坑是“浏览器控制台报Failed to construct WebSocket: The URL ws://... is invalid”。这个看着像是地址写错了其实是因为页面是HTTPS协议但WebSocket地址写成了ws://。浏览器明确禁止在HTTPS页面里发起非加密的WebSocket连接必须写成wss://。这种错误在本地开发时用http://localhost没问题一上到测试环境HTTPS就暴露了。解决方式就是前端判断当前页面协议动态生成WebSocket地址const wsProtocol window.location.protocol https: ? wss:// : ws://; const wsUrl ${wsProtocol}${window.location.host}/ws/; const socket new WebSocket(wsUrl);这样本地开发、测试环境、生产环境都不用改代码自动适配。4.2 后端日志怎么配合排查Nginx日志能帮你确认“请求到底有没有到Nginx”但“后端有没有收到”就得看后端日志了。我一般这样配合排查先在Nginx的access log里找WebSocket请求的记录。正常WebSocket握手成功后access log里会有一条状态码为101的记录。如果你看到的都是200或者404说明握手就没成功问题出在Nginx转发或者后端路由上。如果access log里根本没有记录那说明请求都没到Nginx可能是DNS解析、防火墙、或者客户端连错了地址。后端日志方面Node.js的ws库、Java的Spring WebSocket、Python的websockets库都有各自的握手日志。比如Node.js里只要在创建WebSocketServer时加一个connection事件监听接收不到连接就意味着请求根本没穿过来。做一个最简单的日志输出const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); wss.on(connection, (ws, req) { console.log(新连接来源路径:, req.url); });如果启动后端后这个回调一直不触发而Nginx那边显示101那问题就在Nginx和后端之间比如upstream指错了端口、后端服务没监听在Nginx连接的地址上。4.3 一个真实堵车案例CDN层把WebSocket搞挂了还有一个比较容易忽略的场景域名前面挂了CDN。国内很多站点会用CDN加速静态资源但如果把WebSocket的域名也解析到CDN上CDN节点不一定支持WebSocket协议转发或者会对连接做超时限制。我之前帮朋友排查过一个线上问题WebSocket每隔几分钟就自动断开一次后端日志显示连接是被正常关闭的Nginx日志也正常最后发现问题出在CDN节点CDN默认对无数据传输的连接60秒就断掉了而业务场景是低频消息推送经常超过60秒没有数据交互。解决方案是WebSocket域名不要走CDN直接解析到源站或者使用支持WebSocket的CDN厂商并在控制台开启相关功能。如果你的业务必须走CDN前端在WebSocket层还要自己做心跳保活比如每30秒发一个ping消息让CDN和Nginx都能感知到连接活跃。但心跳只是缓解根本办法还是让长连接不走CDN。5. 进阶给Nginx加安全防护与业务优化5.1 限制WebSocket连接来源WebSocket接口一旦上线就容易成为攻击目标最常见的是有人拿脚本疯狂建立连接把你的后端连接数打满。Nginx层可以做几层防护。最基础的是IP限流用limit_conn限制同一IP的同时连接数limit_conn_zone $binary_remote_addr zonews_conn:10m; location /ws/ { limit_conn ws_conn 10; # ...其他WebSocket配置 }这个配置的意思是同一个IP最多同时建立10条WebSocket连接。limit_conn_zone后面的10m是共享内存大小用来记录IP连接计数10M大概能存十几万个IP的状态个人项目完全够用。还可以对握手请求做频率限制防止短时间内大量握手请求打进来limit_req_zone $binary_remote_addr zonews_req:10m rate10r/s; location /ws/ { limit_req zonews_req burst20; # ...其他WebSocket配置 }这里rate10r/s表示每秒最多允许10个握手请求burst20允许短暂突发20个请求排队。注意这个频率限制既包含成功的握手也包含失败的请求。如果前端有自动重连机制断线后瞬间会有大量重连请求涌入频率限制可能把正常业务的重连也拦住了所以burst要设得宽裕一点或者把限流策略做成只拦截“超过阈值的连接”而不是“所有请求排队”。5.2 使用Nginx变量记录与监控连接状态排查问题的时候Nginx的$connection和$connection_requests变量可以帮助你了解连接的使用情况。在access log里加上这两个字段可以统计每一轮Nginx收到了多少次请求以及连接复用的状态log_format ws_log $remote_addr [$time_local] $request $status $body_bytes_sent conn$connection req$connection_requests;对于WebSocket长连接$connection_requests通常是1因为握手就一次。如果这个值大于1说明同一连接上发了多次请求对于纯WebSocket场景不太正常可能是客户端在同一个连接上重复初始化了WebSocket对象。监控方面我习惯在Nginx的status模块stub_status里看连接数变化趋势。启用的方式是在配置文件里加location /nginx_status { stub_status on; access_log off; }然后在浏览器访问/nginx_status就能看到类似下面的信息Active connections: 32 server accepts handled requests 18237 18237 18453 Reading: 0 Writing: 3 Waiting: 29对WebSocket场景重点看Active connections和Waiting这两个值。Waiting表示空闲的keep-alive连接数。大量WebSocket长连接处于Waiting状态是正常的但如果Active connections在持续上涨且Writing一直很高说明数据交互频繁要留意后端负载。5.3 日志中记录真实客户端IP默认情况下Nginx转发的请求后端的日志里记录的来源IP是Nginx服务器的IP不是真实用户的IP。排查问题的时候比如你想看某个用户是不是频繁断开重连日志里全是同一个IP根本定位不到人这就非常难受。在Nginx配置里加上X-Forwarded-For头后后端日志再配置一下就能记录到真实IP。Nginx侧设置前面已经提到过proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;$proxy_add_x_forwarded_for会把已有的X-Forwarded-For加上当前连接的来源IP拼接成一个完整的链路这样如果有CDN或者多层代理每一层的IP都会记录进去。后端接收的时候以Node.js为例可以从请求头里取出这个字段wss.on(connection, (ws, req) { const realIp req.headers[x-forwarded-for]?.split(,)[0].trim() || req.socket.remoteAddress; console.log(真实客户端IP:, realIp); });取第一段是因为最左边的IP是最接近客户端的真实IP后面几段可能是CDN节点或者Nginx的IP。不过这里有一个安全注意点X-Forwarded-For头是客户端可以伪造的直接信任它可能会被绕过限制或者伪造来源。如果服务只对公网开放建议在Nginx层把客户端传上来的X-Forwarded-For覆盖掉只保留Nginx自己解析出来的IP防止伪造。5.4 心跳机制与Nginx超时怎么配合前面多次提到超时时间要调大但光调大超时不是万能的。如果业务确实长时间没有数据交互网络链路中的任何一层防火墙、负载均衡、云厂商的网关都可能默默断开空闲连接。所以成熟的WebSocket应用一定要有心跳机制前端定期发ping后端回pong连接保持活跃这个动作既能让Nginx认为连接一直在用也能及时发现死连接。前端实现心跳的做法是在setInterval里定时发送一个特定的消息const HEARTBEAT_INTERVAL 30000; // 30秒一次 const socket new WebSocket(wsUrl); let heartbeatTimer null; socket.onopen () { heartbeatTimer setInterval(() { if (socket.readyState WebSocket.OPEN) { socket.send(JSON.stringify({ type: ping })); } }, HEARTBEAT_INTERVAL); }; socket.onclose () { clearInterval(heartbeatTimer); };后端收到ping消息回一个pong或者直接复用WebSocket协议层的ping/pong帧浏览器API层面没有直接暴露但大多数后端库支持。心跳间隔建议小于Nginx的proxy_read_timeout一般30秒一次Nginx超时设3600秒即使某个心跳包丢了、中间有短暂网络抖动也能在超时时间内续命。另外这里再提醒一个容易忽略的点调整proxy_read_timeout之后记得确认链路中所有环节的超时时间都大于你设置的值。如果云厂商的负载均衡器有默认的4分钟空闲超时你就算Nginx设了3600秒也没用连接一样会被云厂商断开。这个我在生产环境里遇到过不止一次每次排查到最后才发现是基础设施层的默认策略在作怪。6. 结尾踩过几次坑之后的体会WebSocket的Nginx转发配置技术点并不复杂核心就是那三行配置但实际落地过程中牵扯到的细节远比想象中多。我自己评估一个WebSocket架构是否健壮看的不是握手成功率高不高而是长连接稳定性、异常断开后的恢复速度、以及链路中每一层的超时配置是否协调一致。这些只有真实上线跑过才知道坑在哪文档里不会写。如果你想在生产环境上配置Nginx转发WebSocket我的建议是先做最小化验证用一条直连命令测试后端能不能通再挂上Nginx测试转发确认握手成功后再加wss、负载均衡、限流这些周边配置一层一层往上叠出问题能立刻定位到是哪一层引入的。另外尽量把WebSocket路径独立出来不要跟REST接口混在同一个location规则里后面排查和维护都会轻松得多。最后生产环境的Nginx版本建议选官方主线版本老版本对HTTP/1.1和Upgrade的支持不如新版本完善升级一次可能就省掉很多不必要的排障时间。