排查 WebSocket "Invalid frame header" 的一次复盘

技术栈: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: closeContent-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 最省心。

相关推荐
m0_535817552 小时前
告别海外账号!Claude Code Windows完整部署指南:从Node.js到api对接(附避坑)
windows·gpt·node.js·api·claude·claudecode·88api
网络点点滴2 小时前
Node.js基础-进程、线程、线程池
node.js
鼎道开发者联盟2 小时前
让多端同步看到 OpenClaw tool 事件:两种无需大改源码的实现方案
websocket·openclaw
河阿里2 小时前
WebSocket:从零开始到实战项目
网络·websocket·网络协议
七牛云行业应用3 小时前
MCP 服务器本地部署实战【2026】:Python/Node.js 搭建 + Claude/Cursor/TRAE
服务器·python·node.js
大力夯3 小时前
macOS 使用 n 模块管理 Node.js 版本
vue.js·macos·node.js
Hello--_--World4 小时前
vite:什么是热更新?vite 和 webpack 有什么区别?vite常见配置和优化手段?
前端·webpack·node.js
Rooting++4 小时前
vue2+webpack打包优化的相关问题
前端·webpack·node.js