症状
一个请求就能定案:带 Upgrade/Connection/Sec-WebSocket-Key 头打代理路径,返回 400 即代理拒绝了升级;同参数直连应用返回 101 Switching Protocols 配 Sec-WebSocket-Accept 即路径健康。“直连成功、经代理在数据流动之前失败”的两跳分歧,直接指认代理的升级配置。第二幕在修好握手后才上演:连接恰好每隔 proxy_read_timeout 掉线——空闲期没有帧流动,读计时器开火。浏览器报 “WebSocket connection to … failed”,是这整套故事的用户侧包装。
诊断思路
先用 curl 模拟握手对比直连与代理路径的响应码;再检查代理的升级配置三要素——proxy_http_version 1.1、Upgrade 头转发、Connection 头转发,缺任何一项握手按定义失败;最后把掉线时长与超时配置对齐,精确等于 idle timeout 的关闭是保活缺口而非故障。WAF 或中间件剥离逐跳头、拒绝 101 响应,是更隐蔽的变体。
根因
WebSocket 握手是一次 HTTP/1.1 Upgrade 交换,使用代理默认剥离的逐跳头(Connection、Upgrade)。既不转发这些头、又不对上游讲 HTTP/1.1 的代理,永远不会送达协议切换请求,握手在任何 WebSocket 帧开始之前就以 400 失败。
修复与验证
给代理路径配升级三件套:proxy_http_version 1.1、proxy_set_header Upgrade $http_upgrade、proxy_set_header Connection “upgrade”(显式字面量),加 proxy_read_timeout 覆盖空闲窗口、关缓冲。应用侧让连接“可证明地活着”——每 30 秒一个 ping 帧,既喂读计时器又让 NAT/LB 状态表保留流。验证时全路径握手返回 101 且 Sec-WebSocket-Accept 合法、长空闲连接活过原先的掉线边界、重连测试干净恢复。超长读超时会让死连接占住代理资源——与应用心跳及客户端重连逻辑成对使用。
代码示例
$ curl -s -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://app.example.com/ws
HTTP/1.1 400 Bad Request <- proxy refused the upgrade
location /ws/ {
proxy_pass http://app;
proxy_http_version 1.1; # Upgrade requires 1.1
proxy_set_header Upgrade $http_upgrade; # forward hop-by-hop
proxy_set_header Connection "upgrade"; # explicitly, not $connection
proxy_set_header Host $host;
proxy_read_timeout 3600s; # survive idle windows
proxy_buffering off;
}