Nginx 反代 WebSocket 每 62 秒必断,我排查了三天,最后发现是三年前自己埋的雷

🔑 关键词:nginx websocket 连接断开, proxy_read_timeout 设置无效, websocket 心跳保活, nginx 反代 websocket 配置, websocket 1006 错误

📖 摘要:生产环境 WebSocket 连接每隔 62 秒准点断开,Chrome 报 1006,前后端日志都干净。折腾三天后发现是 Nginx proxy_read_timeout 的默认值、服务端没发心跳、以及 SLB 空闲超时三层叠加。附完整配置和踩坑过程。

Nginx 反代 WebSocket 每 62 秒必断,我排查了三天,最后发现是三年前自己埋的雷

图片

先说结论,省得你们跟我一样熬夜:Nginx 反代 WebSocket 时,proxy_read_timeout 默认是 60 秒,这个时间算的不是「连接总共能活多久」,而是「两次从上游成功读到数据的最大间隔」。我的 Node 服务在没人说话的时候一个字节都不吐,Nginx 等满 60 秒,认为上游死了,把连接掐掉。生产环境的表现规律得吓人——连上第 62 秒断开,误差不超过 1 秒。改法其实就一行 proxy_read_timeout 300s;,但如果你看到这里就关页面,我后面两天半的夜就白熬了。

把时间倒回上上周四。我们那套在线客服系统,前端是 Vue 2 的聊天窗口,后端 Node.js + 自研的一套基于 ws 7.4.5 的长连接网关,中间隔着阿里云 SLB 和一台 Nginx 1.18.0。那周开始,客服反馈「聊着聊着对方就不回了」,刷新一下又好了。我打开 Chrome DevTools 的 Network 面板过滤 WS,看到的就是一条一条干干净净的 1006,关闭原因那一栏空着,什么都没有。1006 这个码最恶心的地方在于,它是浏览器告诉你「连接异常关闭且我拿不到关闭帧」,等于什么都没说。

第一反应我肯定是怀疑前端。毕竟那个心跳逻辑是去年双十一前一个实习生写的,setInterval(send, 30000),我盯着那段代码看了半小时,写了注释、清了定时器、重连也补了退避,改完上线,第二天照断。然后我怀疑 ws 库,想着会不会 7.4.5 有已知 bug,翻 GitHub issue 翻到凌晨一点半,有个老哥说 8.x 修了某个 close 事件的问题,我升级上去,还是断。第三天早上我在工位上一边吃凉掉的包子一边想,不对,本地开发环境从来没断过,测试环境也是,测试环境我挂了一晚上都没断,为什么只有生产断?

图片

这里插一句,我们测试环境和生产最大的差别是两个:生产的 Nginx 是新配置的,测试环境压根没走 Nginx,是前端直连 Node;生产的 SLB 是去年扩容时换的新实例,监听协议从 HTTP 改成了 TCP。这个信息我当时没当回事,现在回头看就该抽自己。第二天下午我 SSH 上服务器抓包,tcpdump -i eth0 port 443 -w ws.pcap,挂了两分钟,导出来用 Wireshark 一看,清清楚楚:第 62 秒,是 Nginx 这边主动发的 FIN,不是客户端,也不是 Node 先跑的。也就是说,杀人凶手站在 Nginx 那一层。

找到这一层,剩下的事就快了。proxy_read_timeout 默认为 60 秒,proxy_send_timeout 同样是 60 秒,我那个 location 块里写了 proxy_http_version 1.1UpgradeConnection 三个头,唯独没写超时。为什么是三年前的雷?因为那份配置是我 2021 年从上一个项目 copy 过来的,那个项目里服务端每 15 秒主动推一次行情,永远喂得饱 Nginx,所以从来没暴露过。而客服系统坐在那里没人说话是常态。

但改完 proxy_read_timeout 300s 之后并没有万事大吉,这里才是真正的坑。第一,WebSocket 的 Connection 头千万别硬编码。我一开始写的是 proxy_set_header Connection "upgrade";,结果发现同一个 location 下的普通 HTTP 请求(我们复用了同一个路径做握手鉴权)也被塞了 upgrade 头,Nginx 会直接返 400。正确姿势是用 map:

图片

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

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_read_timeout 300s;
    proxy_send_timeout 300s;
    proxy_buffering off;
}

第二,Nginx 超时只是三层里最里面那一层。往外还有 SLB:阿里云 TCP 监听的连接空闲超时是 900 秒,你要是拿 Nginx 的 300s 去配其实没问题,但如果反过来把 Nginx 配成 1800s,SLB 会在 900 秒先把连接干掉,你在 Nginx 里怎么调都白搭。顺序必须是外层超时大于内层,否则内层的配置永远等不到生效的那一刻。第三,也是我最后才补上的——服务端自己得发心跳。Nginx 的 proxy_read_timeout 断的是「上游没数据」,你服务端如果 300 秒都不说话,那把超时改到 3000 秒也只是把断线时间从 62 秒推到 3002 秒而已,治标不治本。

图片

Node 这边我是这么写的,ws 库的 ping/pong 默认不开启,得手动来:

const HEARTBEAT_INTERVAL = 30000;



![图片](http://img0.baidu.com/it/u=4282466816,3589652598&fm=253&fmt=auto&app=138&f=JPEG?w=693&h=500)


const timer = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, HEARTBEAT_INTERVAL);

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; });
});

30 秒一 ping,配合 Nginx 的 300 秒读超时,容错空间是 10 次心跳,绰绰有余。前端那边我把 30 秒的心跳改成了 25 秒,并且加了 visibilitychange 监听——页面切到后台时浏览器会节流定时器,Chrome 对后台标签页的 setInterval 最低能压到 1 分钟一次,这也是为什么客服「切出去看别的系统再切回来」的时候断得特别勤。这个问题跟 Nginx 一毛钱关系没有,但它和 Nginx 的超时叠在一起,能让你在排查时怀疑人生。

图片

改完上线那天是周五下午四点,我拿着笔记本坐在会议室里盯了 40 分钟,连接数稳定在 780 到 830 之间波动,没有 1006。旁边同事问我为啥不改完就走,我说我等第 62 秒,我得亲眼看着它活过 62 秒。那天生产环境 800 多个长连接,断线率从每小时 100% 掉到了 0.3% 以下,剩下那 0.3% 是用户自己关网页。

回头看,这事其实挺羞耻的。我三天里改过的每一处代码都对,前端心跳、库版本、重连逻辑,全是正确的改动,只有一个地方错了——我一开始就认定问题在应用层,从没想过去怀疑一层配置文件。WebSocket 这东西的特殊性就在这儿:它是 HTTP 握手之后转成 TCP 长连接,而链路上每一层(浏览器、CDN、SLB、Nginx、Node、防火墙)都各自有自己的空闲超时,任何一层先到点,上面所有层的努力都是白费。我的建议很土,但真管用:把链路上每一层的超时值都写进一份文档,按「外层 > 内层」排好,然后让心跳间隔小于最内层超时的一半。

最后吐槽一句,那个 proxy_read_timeout 的默认 60 秒,是 Nginx 用来保护自己不被慢后端拖死的,设计上没毛病。真正有毛病的是我们抄配置的时候从来不去看哪些参数是「默认够用」哪些是「必须改」。我那份配置在旧项目里跑了两年没出事,靠的不是它写得好,是运气好。

🏷️ 标签: