接手一个WebSocket消息服务时,我第一次在Nginx后面配置代理,想当然地以为直接抄一段普通的location转发就能跑通。结果浏览器控制台一直报连接失败,后端日志里清清楚楚能看到握手请求已经进来了,可前端就是连不上。后来才明白,WebSocket在Nginx的代理逻辑里和普通HTTP是两套完全不同的处理路径——问题不是Nginx支不支持,而是你根本没告诉它“这不是一次普通的HTTP请求”。
这篇配置笔记围绕Nginx中WebSocket代理的配置展开,从HTTP升级协议的握手细节讲到线上超时故障的排查,再到多实例部署时的会话保持。适合两种人看:一种是刚把WebSocket服务跑起来,准备在前面挂一层Nginx做域名转发和负载均衡;另一种是已经配好了,但时不时出现“连不上”“过几分钟掉线”“多台后端丢消息”这些问题,需要一份能对照着排查的经验。文中的配置示例都是我实际落地用过、验证通过的版本,可以直接抄。
1. WebSocket代理和普通HTTP代理到底差在哪里
1.1 HTTP Upgrade:WebSocket握手的“敲门砖”
很多人以为WebSocket和HTTP是两种完全独立的协议,所以Nginx要支持WebSocket必须装什么特殊模块。实际上完全不是。Nginx本身就具备转发WebSocket的能力,前提是你得理解它握手的特殊之处。
WebSocket连接的第一步,仍然是一个最普通的HTTP GET请求,只不过请求头里多带了几项关键信息:
| Header | 值 | 作用 |
|---|---|---|
| Connection | Upgrade | 告诉服务端,这个连接准备升级 |
| Upgrade | websocket | 指定升级目标协议 |
| Sec-WebSocket-Key | 随机base64字符串 | 用于完成握手校验 |
| Sec-WebSocket-Version | 13 | 协议版本号 |
服务端看到这些头,如果同意升级,就返回HTTP/1.1 101 Switching Protocols。到这一步,TCP连接才从HTTP协议切换到WebSocket帧协议,之后双方可以随时向对方发数据帧。
Nginx作为反向代理,本来应该把Upgrade和Connection这两个头原样传给后端。但问题在于,Nginx有自己的“小聪明”:它默认会在向后端发起请求时,把Connection头设置成close。这是一个HTTP/1.0时代的遗留行为,目的是保证每个请求结束后连接能自动关闭。这样一搞,后端根本没收到升级请求,自然也就不会返回101。
打个比方:你去服务台办事,跟接待员说“我要进VIP会议室”,接待员嘴上答应,但拿起对讲机跟后面说的却是“普通访客一位”。后面的人当然不会打开VIP会议室的门。
1.2 握手完成之后,Nginx从“转发请求”变成“搬运字节流”
普通HTTP请求的生命周期很干脆:Nginx把客户端的请求转发给后端,后端回一次响应,连接结束。但WebSocket握手成功之后,这条TCP连接会一直存在,不仅可以同时双向传输数据,而且不再有“请求-响应”的边界——客户端可以连续发帧,服务端也可以随时主动推帧。
这个时候,Nginx的真正角色变成了一个字节级中继:上游发来的WebSocket数据帧,Nginx原样转发给客户端;客户端发来的帧,Nginx原样转发给上游。对Nginx来说,它不解析WebSocket帧的具体内容,不关心是文本消息还是Ping/Pong帧,只管在两个socket之间搬数据。
这也是为什么WebSocket代理相关的配置里,总绕不开proxy_read_timeout和proxy_send_timeout这两个参数。连接建立是瞬时动作,超时参数管的才是长连接在“空闲”状态下能撑多久。Nginx默认的读超时是60秒,也就是说,一条WebSocket连接如果60秒内没有任何数据流动,Nginx会主动把连接断开——很多线上“隔一段时间就掉线”的故障,根子就在这里。
既然WebSocket和普通HTTP是两种完全不同的处理路径,Nginx配置里就得想办法区分开来:普通请求按旧的逻辑处理,WebSocket升级请求按长连接逻辑处理。这个区分动作通常用一个map指令完成,下面这一节直接给出完整配置。
2. 一份能直接上线的WebSocket代理配置与逐行拆解
2.1 最小可用配置样例
直接上配置,这段我平时建新项目就是这么用的:
map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream ws_backend { server 127.0.0.1:8080; } server { listen 80; server_name ws.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_read_timeout 300s; proxy_send_timeout 300s; } }别小看这几行,每一行都有用处。先整体说一句:Nginx对WebSocket的代理,核心就三件事——把升级请求正确递到后端,用HTTP/1.1协议向后端发起连接,给这条长连接设置合理的空闲超时。剩下的都是附加项。
配置里的灵魂是那段map。$http_upgrade是Nginx内置变量,内容来自客户端请求头里的Upgrade字段。普通HTTP请求没有这个头,$http_upgrade为空,于是$connection_upgrade取close;WebSocket升级请求带了Upgrade: websocket,$connection_upgrade就取upgrade。这样设计的好处是,同一个location既能服务普通HTTP请求,也能服务WebSocket升级请求,互不干扰。
2.2 每个参数为什么要这么写
proxy_http_version 1.1是最容易漏的一行。Nginx向upstream后端发起连接时,默认使用的协议是HTTP/1.0。HTTP/1.0没有连接升级和长连接的概念,后端会认为这个请求结束就该断开了。只有显式改成1.1,后端的Upgrade机制才能真正走通。
proxy_set_header Upgrade $http_upgrade和proxy_set_header Connection $connection_upgrade这两行,解决的就是第一章说的握手头问题。前者把客户端请求里的Upgrade原样带给后端,后者替换掉Nginx默认强加的close。这里尤其提醒一句:Connection头一定要用变量$connection_upgrade,不要图省事直接写死成字符串"upgrade"。如果写死,普通HTTP请求也会被后端当成升级请求,整个链路会冒出一堆莫名其妙的问题。
proxy_read_timeout和proxy_send_timeout我习惯设成300秒,如果后端有稳定的应用层心跳,这个值改成120秒也够。注意单位是秒,不要写成300ms之类,Nginx并不会按毫秒解析这个指令。至于Host、X-Real-IP、X-Forwarded-For这三行,主要为了让后端拿得到真实域名和客户端IP,尤其是做用户连接绑定、限流、审计这类业务时,拿不到真实IP后面会很痛苦。
2.3 生产环境建议增加的配置项
如果WebSocket流量量级不小,我会在location里再补两行:
proxy_buffering off; proxy_set_header X-Forwarded-Proto $scheme;proxy_buffering off是为了让传输更“实时”。WebSocket场景下大多数数据帧都很小,如果Nginx开着缓冲,上游先把数据积攒一段再一次性吐给客户端,前端收到的帧会有明显延迟,体感像卡住。关掉之后,帧到达就转发,交互延迟低很多。X-Forwarded-Proto是为了让后端知道客户端用的是http还是https,某些后端框架在生成回调地址时会用到这个头。
如果服务是HTTPS域名对外,WSS配置也很简单,把server块监听443即可:
server { listen 443 ssl http2; server_name ws.example.com; ssl_certificate /path/to/fullchain.pem; ssl_certificate_key /path/to/privkey.pem; 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 300s; proxy_send_timeout 300s; proxy_buffering off; } }这里有一个值得注意的细节:同一个server块如果同时开启HTTP/2(也就是listen 443 ssl http2),不同客户端对“WebSocket over HTTP/2”的支持并不一致。WebSocket在HTTP/2下的协商方式和HTTP/1.1的Upgrade机制不一样,跨浏览器表现也比较分裂。实测下来,稳妥的做法是给WebSocket单独放一个server块,监听独立的端口或独立的域名,让握手始终走HTTP/1.1路径,避免踩到兼容性暗坑。这不是说h2一定不可用,而是为了少给自己找麻烦。
3. 一次线上“每60秒断一次”的故障:从日志到根因
3.1 故障现象与第一判断
之前维护的模拟项目X是一个内部消息推送服务,前端页面通过Nginx代理连后端的WebSocket推送网关。某天中午开始不断有用户反馈:页面打开后能正常收到消息,但大概过了一两分钟,推送就彻底停了,必须刷新页面才恢复。
当时的排查顺序其实绕了弯子。先检查了页面端代码,确认前端没有主动断开的逻辑;然后翻后端网关日志,也没发现任何服务端主动关闭连接的记录。问题看起来就像“连接凭空消失”。后来把Nginx的error.log打开,才发现根本原因早就躺在里面。
这种故障排起来,我的经验是先看接入层(Nginx)、再看客户端、最后才看后端业务。因为WebSocket连接跨了这么多个环节,谁先断开谁的日志最可能有线索。后端业务日志往往只记录业务消息,连接层断开未必会打点。
3.2 error.log定位:一行关键日志直接锁死范围
打开Nginx错误日志,会看到类似下面这样的记录不断刷屏:
2024/06/12 14:03:21 [error] 12345#0: *67890 upstream timed out (110: Connection timed out) while reading upstream, client: 10.0.0.8, server: ws.example.com, request: "GET /ws/ HTTP/1.1", upstream: "http://127.0.0.1:8080", host: "ws.example.com"注意upstream timed out (110: Connection timed out) while reading upstream这一段。关键字是reading upstream,意思是Nginx在等待上游(后端网关)传数据时超时了。结合Nginx默认的proxy_read_timeout是60秒,几乎可以确认是“读上游超时”导致连接被Nginx强制断开。
为了确认,我用nginx -T查了当前生效配置,发现配置里确实漏掉了proxy_read_timeout的显式声明,于是它踩着默认值60秒。而后端的业务心跳是每隔90秒才发一次,正好在60秒的空闲窗口内没有任何数据经过,连接自然被掐断。
这个案例的教训是:不是所有“掉线”都要先去改业务代码,先看接入层参数有没有显式声明,往往能少走很多弯路。
3.3 根因与修复:空闲超时不是连接总时长
这里必须把proxy_read_timeout的含义掰开揉碎讲一遍,因为太多人在这上面栽过跟头。这个参数设置的并不是“这条连接最多存活多长时间”,而是“两次连续读操作之间允许的最大空闲时间”。只要超时窗口内发生任何一次数据读取,计时器就会重新归零。换句话说,哪怕把proxy_read_timeout设成3600秒,如果连接在某一秒开始彻底没数据了,那它还是会在3600秒的“安静期”结束后被断开。
修复分成两步。第一步,把超时参数显式写出来,并且给出一个合理值。我在这里用的是300秒:
proxy_read_timeout 300s; proxy_send_timeout 300s;第二步,也是更根本的一步:保证连接里有持续的数据流动。WebSocket协议本身就支持Ping/Pong帧,后端网关可以每30秒向前端发一次心跳。这些心跳帧对Nginx来说就是普通数据,能正常重置proxy_read_timeout的计时器。心跳间隔参考值:如果把Nginx超时设成120秒,心跳就定30秒一次;如果设成300秒,心跳不要超过60秒一次。总体原则是心跳频率明显高于Nginx空闲超时,留出足够余量。
有同事曾提议直接把超时调到几小时,用超大超时省去心跳。我不建议这么做。中间网络设备(比如NAT网关、负载均衡器)往往也有各自的空闲连接回收策略,它们可不知道你的Nginx把超时设得有多大。一旦中间设备先把连接回收掉,两端都感知不到,会出现“看起来连接还在,实际数据早已发不出去”的半开连接状态。所以正确的姿势永远是“合理超时+应用层心跳”双保险。
4. 后端多实例部署时,会话保持怎么做才不踩坑
4.1 为什么轮询会让WebSocket“找不到家”
业务量一大,后端网关不可能只有一台。upstream里配两三个节点跑负载均衡,听着很常规,但对WebSocket这种有状态的长连接来说,默认的轮询策略会带来一个隐蔽的大坑。
WebSocket握手成功后,这条连接在后端是有状态的:后端要把连接和用户会话绑定,在内存里维护一张“谁在线、走哪个虚拟连接”的表。普通HTTP请求每次落到不同节点没关系,因为无状态,重新查一遍就行。但WebSocket一旦握上手,后续所有帧都被限制在“客户端-该后端节点”这条固定链路上,如果下一轮轮询把请求转发到另一台后端,那台后端根本没有这个用户的状态,推送要么失败,要么用户被莫名其妙踢下线。
用快递柜来类比就很好理解:你在A号柜存了包裹,结果每次取件都跑到B号柜,自然取不到。WebSocket长连接也一样,它需要的是“同一个客户端的连接始终交给同一个后端”,这就是会话保持。
4.2 ip_hash的适用场景和几个注意点
最简单的会话保持方案是ip_hash:
upstream ws_backend { ip_hash; server 127.0.0.1:8081; server 127.0.0.1:8082; server 127.0.0.1:8083; }ip_hash会把同一个客户端IP的请求哈希到同一台后端,实现“同IP同节点”。
但这个方案有两个需要注意的地方。第一,Nginx的ip_hash是基于客户端IP的前三个字节做哈希,同一网段的不同IP大概率会落到同一台后端,负载分配并不算均匀。第二,如果客户端前面还有一层别的反向代理或网关,Nginx看到的“客户端IP”其实是代理的IP,所有用户会被当成同一个IP去哈希,效果变成“所有请求全打一台机器”。这种情况需要配合set_real_ip_from和real_ip_header配置,让Nginx从X-Forwarded-For或X-Real-IP里还原真实客户端IP后再做哈希。
4.3 一致性哈希与发布扩容时的平滑变更
如果后端节点数量会变化,我更推荐用一致性哈希:
upstream ws_backend { hash $remote_addr consistent; server 127.0.0.1:8081; server 127.0.0.1:8082; server 127.0.0.1:8083; }consistent参数让Nginx按一致性哈希算法分配连接,它的好处是:后端节点增加或减少时,只有一部分连接需要重新映射,而不像普通ip_hash那样大面积重排。这对长连接服务非常友好,能最大程度减少“发布一次掉一批在线用户”的情况。
发布过程中还有一个容易被忽略的常识:Nginx的reload(nginx -s reload)不会断开已经建立的WebSocket连接,它只是让Nginx重新加载配置并平滑重启worker进程,已有的TCP连接会被旧的worker进程继续维护直到自然结束。因此发布后端服务时,不要直接把节点从upstream里删掉或改IP,那样新连接会全部换节点,而旧连接会因节点不可用而集体报错。稳妥的顺序是:先让节点从负载池“退流”(比如调低权重或标记不可用),等存量连接逐步结束后再重启后端,再视情况把节点加回upstream。
还要提醒一点:不要指望通过proxy_next_upstream对已经升级的WebSocket连接做故障转移。连接握手成功后就变成了双向数据通道,Nginx不会在通道中途把客户端切到另一台上游,协议层面也没有这种无缝切换机制。故障转移只能发生在握手阶段,一旦连接建立,后端出问题就只能靠客户端自己发起重连。
5. 上线前必看的验证方法和高频故障速查
5.1 三种方式确认配置真的生效
配置写完之后不能只看一眼就算完。每次上线前我都会做三步验证。
第一步,语法自检:
nginx -t nginx -Tnginx -t检查语法,nginx -T会把所有include进来的配置合并输出。这一步能确认map、upstream、location这些指令确实按预期落在最终生效的配置里,而不是被某个include覆盖掉。
第二步,用curl手工发一次握手请求:
curl -i --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ --max-time 3 http://127.0.0.1/ws/如果代理配置正确,响应头里会看到HTTP/1.1 101 Switching Protocols。如果看到502、504或者直接超时,基本可以判断问题出在后端连不通或超时参数没覆盖到这一层。注意--max-time 3是用来避免curl在101之后一直挂着,毕竟握手成功之后连接就变成WebSocket了,curl本身不会自己收场。
第三步,浏览器或脚本端到端测试。浏览器里最简单的方法是控制台直接跑:
const ws = new WebSocket("ws://ws.example.com/ws/"); ws.onopen = () => { console.log("connected"); ws.send("ping"); }; ws.onmessage = (e) => console.log("recv", e.data);Network面板里能看到WebSocket握手请求的状态码,打开消息标签页还能逐帧检查收发数据。脚本层面,用Python的websockets库也可以快速验证:
import asyncio import websockets async def check(): async with websockets.connect("ws://127.0.0.1/ws/") as ws: await ws.send("hello") print(await ws.recv()) asyncio.run(check())5.2 高频故障对照速查
把这些年在Nginx WebSocket代理上遇到过的高频问题和处理方向汇总成一个表,线上排查时可以对照着看:
| 现象 | 大概率原因 | 处理方向 |
|---|---|---|
| 浏览器握手失败,报连接错误 | Nginx没把Upgrade/Connection头传给后端 | 检查proxy_set_header和map变量 |
| 后端日志能看到请求,但返回502 | 后端未监听或upstream没有可用节点 | 检查后端进程与网络连通性 |
| 握手成功,但约60秒后掉线 | proxy_read_timeout默认值到期 | 显式调大超时并配合心跳 |
| 握手成功,发消息后迟迟无响应 | 后端没按WebSocket协议回帧,或缓冲未关闭 | 检查后端实现,关proxy_buffering |
| 普通HTTP请求也被当成升级请求 | Connection头被写死为upgrade字符串 | 改用$connection_upgrade变量 |
| 多节点部署时用户掉线、丢消息 | 负载均衡没有会话保持 | 加ip_hash或一致性哈希 |
表中每一项在本文前几章都有完整展开,这里就不再重复解释。需要补充说明的是400 Bad Request这类问题,它多见于请求头异常,比如Sec-WebSocket-Key格式不对、整体请求头过大。如果手头配置怎么调都还是400,可以关注large_client_header_buffers这个参数是否满足要求,但优先还是要检查客户端发出的握手头是否符合规范。
5.3 日志、访问控制与长期维护的细节
排查WebSocket长连接问题时,access.log直接看不太直观,因为它不像普通HTTP请求那样有明确的“结束响应”节点。连接只要不断,access.log里那条记录可能一直是未完成的挂起状态,直到连接关闭才会真正落盘。想排查慢连接、异常断开,可以单独定义一个log_format,把$http_upgrade、$upstream_addr、$upstream_status这几个变量加进去。这样一眼就能看出某个WebSocket来源IP最终被转发到了哪台后端。
访问控制上同样有讲究。比如安全组、网络策略,要对WebSocket的握手路径放行对应的端口和协议;但更细致的是,接入层的限流、鉴权尽量只针对“握手请求”生效,握手成功后不要在Nginx层频繁干涉数据帧,避免给长连接转发增加额外开销。如果确实需要对未登录用户做拦截,优先用Nginx的auth_request在握手阶段完成鉴权,而不是等连接建立后再靠业务层判断。
长期维护的经验再补一句:WebSocket代理出问题时,日志时间戳对齐非常关键。前端、Nginx、后端三层日志如果用了同一个时区,能快速判断“先断的是哪一头”;如果各用各的时区,排查效率会大打折扣。这算是我踩过坑之后才养成的习惯。
最后说一个我自己的习惯:每次改完Nginx配置,不只依赖nginx -t通过,还会用curl手动放一次101握手探活,再去浏览器里跑一条端到端测试。因为WebSocket这种长连接,最容易出问题的地方恰恰是“配置看着没问题,实际没生效”。至于proxy_read_timeout,我不主张调到特别大去硬扛,更推荐保留一个合理值,比如120秒到300秒,把连接的活性交给应用层心跳。这样中间设备即使有连接回收机制,也不容易误伤正常连接,出问题时还能通过timeout日志反推链路上到底哪里“真的没数据”了。希望这份配置和排查思路,能帮你把WebSocket代理的坑一次填平。