技术栈:Node.js 18 + Express + ws@8 + Nginx 1.18 + Docker,部署在阿里云 ECS。
本文按 现象 → 排查路径 → 根因 → 解决 四段式展开,希望能帮你少走弯路。
一、现象
Monitor Dashboard 页面打开后,浏览器控制台持续报错:
rust
WebSocket connection to 'wss://xxx.com/monitor-api/ws?token=...' failed: Invalid frame header
F12 Network 面板显示握手请求的 状态码是 101 Switching Protocols,响应头也完全正常------说明 WebSocket 握手已经成功了,但随后连接立即断开。
Dashboard 因此无法接收实时推送数据,整个监控页面失去核心功能。
二、排查路径
第一步:检查 Nginx 反向代理配置
最先怀疑的是 Nginx 没有正确处理 WebSocket 升级。常见的坑是缺少以下三行:
ini
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
登录 ECS 检查 /etc/nginx/conf.d/nginx.conf,发现 这三行已经存在。Nginx 配置没有问题。
小结:排除了 Nginx 配置缺失这个最常见的原因。
第二步:用 curl 验证握手
bash
curl -s -D - -o /dev/null \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
"https://xxx.com/monitor-api/ws?token=xxx"
返回:
makefile
HTTP/1.1 101 Switching Protocols
Connection: upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
101 + 正确的 Sec-WebSocket-Accept,握手完全正常。
小结:服务端 WebSocket 握手逻辑没问题,问题出在握手之后的阶段。
第三步:用 Node.js ws 客户端实际连接
curl 只能验证握手,无法模拟完整的 WebSocket 通信。用 ws 库的客户端做真实验证:
javascript
const WebSocket = require('ws');
const ws = new WebSocket('wss://xxx.com/monitor-api/ws?token=xxx');
ws.on('open', () => console.log('OPEN'));
ws.on('message', (d) => console.log('MSG:', d.toString().substring(0, 100)));
ws.on('error', (err) => console.log('ERROR:', err.message));
结果:
arduino
OPEN
ERROR: Invalid WebSocket frame: must be clear
连接成功打开了(OPEN),但收到第一条消息时就报错:RSV1 must be clear。
RSV1 是什么? WebSocket 帧的第一个字节中有 3 个保留位(RSV1/RSV2/RSV3)。正常情况下应该全为 0。RSV1=1 表示启用了
permessage-deflate压缩扩展。如果双方没协商过压缩就收到 RSV1=1 的帧,就是协议违规。
第四步:第一个错误假设 --- permessage-deflate 协商不一致
查看浏览器发送的请求头:
css
sec-websocket-extensions: permessage-deflate; client_max_window_bits
但服务端 101 响应中 没有 sec-websocket-extensions 头。
这意味着:客户端请求了压缩扩展,但没收到服务端的确认。如果服务端单方面启用了压缩(RSV1=1),客户端不知道要解压,就会报 "RSV1 must be clear"。
推测 Nginx 吞掉了 101 响应中的 Sec-WebSocket-Extensions 头。
于是尝试在 WebSocket Server 上禁用压缩:
php
const wss = new WebSocket.Server({
server,
path: '/monitor-api/ws',
perMessageDeflate: false // ← 新增
});
部署后测试------依然报同样的错。
即使客户端也显式禁用压缩(perMessageDeflate: false),错误依旧:
arduino
OPEN
ERROR: Invalid WebSocket frame: RSV1 must be clear
小结 :
permessage-deflate不是根因。压缩已关闭,但收到的数据仍然不合法。需要更深入地看数据。
第五步:抓取原始字节,发现真相
用 Node.js 原生 https 模块手动发起升级,直接读取 socket 上的原始数据:
javascript
const req = https.request(url, { headers: { Upgrade: 'websocket', ... } });
req.on('upgrade', (res, socket, head) => {
console.log('Status:', res.statusCode); // 101
socket.on('data', (chunk) => {
console.log('Raw hex:', chunk.toString('hex'));
});
});
输出:
yaml
Status: 101
Raw hex: 485454502f312e31203430302042616420526571756573740d0a...
解码这段 hex:
makefile
HTTP/1.1 400 Bad Request
Connection: close
Content-Type: text/html
Content-Length: 11
Bad Request
真相大白! 在 101 Switching Protocols 之后,同一个 socket 上又收到一个 HTTP 400 Bad Request 响应。
浏览器把 400 响应的首字节 H(ASCII 0x48,二进制 01001000)当作 WebSocket 帧头解析:
yaml
0 1 0 0 1 0 0 0
│ │ │ │ └───┘
│ │ │ │ └─ opcode: 0x8 (close)
│ │ │ └─ RSV3: 0
│ │ └─ RSV2: 0
│ └─ RSV1: 1 ← 这就是 "RSV1 must be clear" 的来源
└─ FIN: 0
H 的 RSV1 位恰好为 1,触发了两种报错:
- 浏览器:
Invalid frame header - Node.js ws 客户端:
RSV1 must be clear
核心问题变成了:谁在 101 之后发送了这个 400?
三、根因
这行 400 Bad Request 的格式是 Node.js HTTP Server 的默认错误响应(无 Server 头、Connection: close、Content-Type: text/html),不是 Nginx 生成的。
回看 WebSocket Server 的创建方式:
php
// 问题代码
const wss = new WebSocket.Server({ server, path: '/monitor-api/ws' });
当使用 { server, path } 模式时,ws 库会监听 HTTP Server 的 upgrade 事件,但 Node.js 的 HTTP Parser 并不会因为 upgrade 事件触发就自动从 socket 上解绑。
实际发生了这样的时序:
markdown
1. 客户端发送 WebSocket Upgrade 请求
2. Nginx 转发到 Node.js (127.0.0.1:3000)
3. ws 库捕获 upgrade 事件,发送 101 Switching Protocols ✓
4. 连接升级完成,ws 库开始接管 socket
5. 但 Node.js HTTP Parser 仍然绑定在同一个 socket 上!
6. HTTP Parser 读到了后续的数据(可能是 ws 的帧数据或心跳)
7. HTTP Parser 无法将其解析为合法 HTTP 请求
8. HTTP Parser 自动发送 "HTTP/1.1 400 Bad Request" 响应
9. 这个 400 响应通过 Nginx 转发给浏览器
10. 浏览器收到 400,把首字节当 WebSocket 帧头解析 → Invalid frame header
本质是 ws 库的 { server, path } 模式和 Node.js 18 的 HTTP Server 之间存在 socket 生命周期管理的冲突------HTTP Parser 没有在 upgrade 后正确解绑。
四、解决方案
将 WebSocket Server 从共享 HTTP Server 模式切换到 noServer 模式,完全绕开 HTTP Server 的 upgrade 事件分发机制,由我们自己控制 socket 的归属:
javascript
// 修复后
function setup(server: Server): void {
const wss = new WebSocket.Server({ noServer: true, perMessageDeflate: false });
const appWss = new WebSocket.Server({ noServer: true, perMessageDeflate: false });
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url || '/', `http://${req.headers.host}`);
if (url.pathname === '/monitor-api/ws') {
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
} else if (url.pathname === '/api/ws') {
appWss.handleUpgrade(req, socket, head, (ws) => appWss.emit('connection', ws, req));
} else {
socket.destroy();
}
});
wss.on('connection', handleConnection);
appWss.on('connection', handleAppConnection);
// ...心跳、定时推送等逻辑不变
}
关键差异:
{ server, path } 模式 |
{ noServer: true } 模式 |
|
|---|---|---|
| upgrade 事件 | ws 库内部监听,与 HTTP Server 共享 socket |
我们手动监听,完全控制 socket |
| HTTP Parser | 升级后仍可能绑定在 socket 上 | handleUpgrade 直接接管,Parser 被绕过 |
| 路由分发 | ws 库按 path 匹配 |
我们自己按 pathname 分发 |
| 可靠性 | 依赖 ws 库与 Node.js 的协作 |
完全独立,不依赖 HTTP Server 行为 |
部署后验证:
javascript
// Node.js 客户端测试
const ws = new WebSocket('wss://xxx.com/monitor-api/ws?token=xxx');
ws.on('open', () => console.log('OPEN'));
ws.on('message', (d) => console.log('MSG:', d.toString().substring(0, 100)));
输出:
ruby
OPEN
MSG: {"type":"dashboard:update","data":{"cpu":[],"memory":[],...}}
连接成功,数据正常接收。Dashboard 实时推送恢复正常。
五、反思与收获
1. "握手成功"不代表"连接可用"
WebSocket 的 101 只代表 HTTP → WS 的协议切换完成。如果后续的帧数据被污染,连接仍然会断开。排查时不能只看状态码,必须检查 握手后的实际数据流。
2. 二进制数据分析是终极武器
"Invalid frame header" 这个报错信息很容易把人引导到压缩扩展的方向。只有抓取原始 hex 字节才能看到真相------那个 0x48 开头的根本不是 WebSocket 帧,而是一个 HTTP 400 响应。
3. noServer 模式更可控
如果你的 WebSocket 服务和 Express 共享同一个 HTTP Server(这在单进程部署中很常见),推荐直接使用 noServer: true。它让你完全掌控 upgrade 的生命周期,避免了 Node.js HTTP Parser 的副作用。
4. Nginx permessage-deflate 的坑是真实存在的
虽然这次不是根因,但 Nginx 1.18 确实会吞掉 101 响应中的 Sec-WebSocket-Extensions 头。如果你的服务端需要压缩,要么升级 Nginx,要么在 Nginx 层显式透传该头。低流量场景直接 perMessageDeflate: false 最省心。