WebSocket 服务前面套一层 Nginx 反向代理,最常撞上两个现象:握手直接失败,或者每次都精确地在 60 秒左右断开。两个现象都有明确原因和成熟修法,本文给出可复制的配置和排查路径。
Upgrade 头过不了代理,握手第一步就死
WebSocket 连接从一个普通的 HTTP/1.1 请求开始。客户端发出一个带 Upgrade: websocket 头的请求,服务端如果同意,返回 101 Switching Protocols,之后这条 TCP 连接就不再说 HTTP,改说 WebSocket 帧协议。整个过程叫协议切换(protocol switch),定义在 HTTP/1.1 规范里。
问题出在反向代理这一层:Upgrade 属于逐跳头(hop-by-hop header),语义上只约束当前一跳,代理转发请求时会把它剥掉。后端收不到「客户端想升级协议」的信号,握手必然失败,客户端通常看到 400 Bad Request 或 502。
解法是显式转发这个头。Nginx 从 1.3.13 起支持在收到 101 响应后进入隧道模式,客户端与后端直接透传 WebSocket 帧:
location /ws/ {
proxy_pass http://backend;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
这三行是 WebSocket 反代的最小可用配置。注意 proxy_http_version 必须是 1.1——WebSocket 握手是 HTTP/1.1 特性,Nginx 对后端的默认版本却是 1.0。1.29.7 之后未显式设置时会自动沿用请求行版本,老版本要手动加 proxy_http_version 1.1;。
按需升级:map 写法在生产更稳
上面那个配置有个副作用:Connection: upgrade 被无条件发给后端,哪怕请求没带 Upgrade 头。大多数后端不在意,严格按规范实现的服务会拒绝。生产环境更常用 map 做条件判断——带 Upgrade 头才升级,普通请求传 close:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name example.com;
location /ws/ {
proxy_pass http://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;
}
}
map 块放在 http 层,逻辑直白:$http_upgrade 非空给 upgrade,空给 close。普通页面请求不受影响。顺带配上 Host、X-Real-IP、X-Forwarded-For,后端要拿客户端真实 IP 时不用再改。
改完先验证语法再重载,一个字段拼错整套配置就失效:
sudo nginx -t && sudo systemctl reload nginx
每次精确 60 秒掉线,卡在 proxy_read_timeout
握手成功、连接建立,然后每次都在 60 秒左右断开——这个数字不是巧合。Nginx 的 proxy_read_timeout 默认就是 60 秒,含义是两次读操作之间的最大间隔:连续 60 秒没从后端读到数据,Nginx 就主动断开。
注意判定条件是「间隔」而非「总时长」。链路上有数据流动,连接就一直活着。两条路修复:
方案 A:调大超时。 业务本身长时间静默偶尔来一条消息(比如通知推送)时,直接放宽间隔:
proxy_read_timeout 300s;
proxy_send_timeout 300s;
方案 B:应用层心跳。 WebSocket 自带 ping/pong 控制帧。后端定期发 ping、客户端回 pong,超时计时器不断重置。这类心跳 WebSocket 库都有内建:Socket.IO 里叫 heartbeat,Go 的 gorilla/websocket、Node 的 ws 有现成调度。间隔设 30 秒左右,保活兼探活。
改 Nginx 是一行配置,改应用要动代码。但心跳多解决一个问题:NAT 网关和云负载均衡对空闲 TCP 连接普遍有 1 到 5 分钟的超时。只调超时到 300 秒,掉线可能从 60 秒变成 4 分钟。长期看,应用层心跳是更稳的方案,Nginx 超时只是第一道补丁。
一张表定位断连原因
| 现象 | 原因 | 修法 |
|---|---|---|
| 握手返回 400/502,后端日志无升级请求 | Upgrade 头没转发 | 补 proxy_set_header Upgrade/Connection |
| 握手返回 502,后端日志报协议版本 | Nginx 到后端默认 HTTP/1.0 | proxy_http_version 1.1 |
| 每次精确 60 秒断开 | proxy_read_timeout 默认值 | 调大或上心跳 |
| 连接存活但消息延迟高 | 走了轮询降级(长轮询回退) | 确认握手成功,检查是否被降级到 polling |
| 隔几分钟到十几分钟随机断 | 中间设备空闲超时 | 应用层心跳,间隔短于最严的中间设备超时 |
常见问题
Q:加了两行 proxy_set_header 还是 400?
逐层排查。先 nginx -t 确认配置生效并 reload;再用 curl 模拟握手,应返回 101;最后查后端的 Origin 校验。Sec-WebSocket-Key 缺失时应用层也会拒。
Q:HTTPS 站点下 WebSocket 怎么配?
同一套配置,在 server 块配好 listen 443 ssl 和证书,浏览器侧从 ws:// 换成 wss://。页面是 HTTPS 时必须用 wss,否则浏览器拦截混合内容。证书签发参考站内教程:letsencrypt-nginx。
Q:多个 WebSocket 实例怎么负载均衡?
加 upstream 块配多个后端,用 ip_hash 保证同一客户端固定落到同一实例——WebSocket 会话有状态,中途换实例会话就丢。长连接占 worker 连接数,worker_connections 要按预估并发上调。
Q:wscat 连得上,浏览器连不上?
大概率是 Origin 校验或混合内容问题。wscat 不发 Origin,浏览器会带;页面是 HTTPS 而 WebSocket 是 ws:// 时浏览器直接拒绝。改成 wss 并检查后端 Origin 白名单。
最小可行清单
最小配置就是三件套:proxy_set_header Upgrade、proxy_set_header Connection(配 map 更稳)、proxy_http_version 1.1。断连问题优先用心跳解决。proxy_read_timeout 调大一档作保险。改完 nginx -t,reload,再握手一次——101 出现,这条链路就通了。