Nginx如何配置WebSocket反向代理?长连接、超时与升级头设置

把在线聊天、实时推送、协同编辑、监控大屏这类 WebSocket 应用放到 Nginx 后面时,很多人会遇到一个经典现象:通过 Nginx 访问,连接建立后每隔 60 秒左右就断开一次,反复重连,但直连后端却一切正常。原因几乎总是同一类——Nginx 默认把它当成普通的 HTTP 请求,而 WebSocket 是一次"先 HTTP 握手、再升级为长连接"的特殊协议。

这篇文章讲清楚 WebSocket 协议和普通 HTTP 的区别,重点说清 Nginx 里那三个"缺一不可"的请求头(Upgrade、Connection、Host),解释 proxy_read_timeout 为什么会造成周期性断连,并给出一份可直接用于生产、含心跳与超时调优的配置片段。还会覆盖多实例负载均衡、WSS(TLS)转发、以及用日志和状态码定位断连的方法,读完你能把 WebSocket 稳定地代理起来,而不是靠不停加大超时碰运气。

先给一个结论:WebSocket 反向代理的完整解法 = 正确的升级头 + 合适的长连接超时 + 应用层心跳。三者缺任何一个,都会表现为"能连上,但过一会就掉"。

一、WebSocket 和普通 HTTP 的本质区别

普通 HTTP 是"一问一答":客户端发一个请求,服务端回一个响应,连接(HTTP/1.1 下)可以选择关闭或复用,但每次交互都是独立的。WebSocket 不一样,它的生命周期分两段:

客户端 ──HTTP Upgrade 请求──> Nginx ──> 后端
后端 ──101 Switching Protocols──> Nginx ──> 客户端
之后:客户端 <──── 全双工长连接 ────> 后端(双向随时收发)
  • 握手阶段:客户端发一个带 Upgrade: websocket 的普通 HTTP 请求,服务端回 101 Switching Protocols,协议就此升级。
  • 数据阶段:升级成功后,这条 TCP 连接变成全双工通道,双方可以随时互推消息,不再遵循请求-响应模式。

关键就在这个"协议升级"上。Nginx 默认对每个 location 按 HTTP 处理,如果它不认识 Upgrade/Connection 头,就不会把这条连接升级,而是当普通请求处理,结果就是连接被提前收尾或超时断开。所以配置的核心动作是:把升级相关的头透传给后端。

二、WebSocket 握手涉及的关键头

理解下面这几个头,配置就不会是靠背的。

请求头 作用 Nginx 需要做什么
Upgrade: websocket 声明要升级协议 透传给后端:proxy_set_header Upgrade $http_upgrade;
Connection: Upgrade 指示本次连接要升级 透传:proxy_set_header Connection "upgrade";
Host 后端识别虚拟主机 透传:proxy_set_header Host $host;
Sec-WebSocket-Key 握手校验随机数 透传(默认已透传)
Sec-WebSocket-Version 协议版本,通常 13 透传

其中最容易忘、也最关键的是 Upgrade 和 Connection。Nginx 用内置变量 $http_upgrade 取到客户端的 Upgrade 值,再通过 proxy_set_header 把它转给后端;Connection 则要显式设成 upgrade(HTTP/1.1 的 proxy_http_version 1.1 也必须设,否则默认按 HTTP/1.0 走,升级头不被支持)。

三、最小可用配置:三个必须项

一个能让 WebSocket 正常工作的最小配置如下。注意它同时设了 proxy_http_version 1.1,这是前提。

location /ws/ {
    proxy_pass http://127.0.0.1:8080;

    # 1) 必须用 HTTP/1.1,WebSocket 升级才被支持
    proxy_http_version 1.1;

    # 2) 透传升级头(缺一不可)
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

    # 3) 透传 Host,后端虚拟主机/校验需要
    proxy_set_header Host $host;

    # 常规真实 IP 透传
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # 长连接相关超时(见第四节)
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

改完务必校验并平滑重载:

nginx -t && nginx -s reload

如果你用的是 map 方式做通用配置(同一个 location 既服务普通 HTTP 又服务 WebSocket),可以这样写,更优雅:

# 放在 http {} 块里
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;      # 普通 HTTP 请求时,Connection 设为 close
}
location /ws/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;   # 由 map 动态决定
    proxy_set_header Host $host;
    proxy_read_timeout 3600s;
}

四、超时设置:为什么恰好 60 秒断一次

proxy_read_timeout 是 WebSocket 断连的头号元凶。它的含义是:Nginx 在两次从后端读到数据之间的最大等待时间,默认 60 秒。如果这条长连接上有 60 秒没有任何数据流动,Nginx 就会认为连接空闲、把它关掉,前端于是看到断开并重连。

有两种应对思路,最佳实践是两者结合:

  1. 调大 proxy_read_timeout:把它设成比业务最长空闲时间更长的值。例如聊天场景可能几分钟才有一条消息,就设 3600s。
  2. 应用层心跳(推荐):让客户端或服务端每隔 30~50 秒发一个 ping/心跳帧(文本帧或 WebSocket ping 帧),让链路上始终有数据,从而触发不了空闲超时。

关键超时参数对比:

参数 默认值 含义 WebSocket 建议
proxy_read_timeout 60s 两次读之间的最大间隔 3600s 或配合心跳
proxy_send_timeout 60s 两次写之间的最大间隔 与上保持一致
proxy_connect_timeout 60s 与后端建连超时 5~10s 足够
keepalive_timeout 75s 客户端连接空闲超时 WebSocket 场景可不依赖它

经验值:心跳间隔建议取 30~50 秒,既远小于常见 60 秒的中间设备(防火墙、负载均衡)空闲回收阈值,又不会产生太多空流量。移动网络下运营商 NAT 网关常在 1~5 分钟回收空闲 TCP,心跳对此尤其重要。

五、生产级完整配置示例

下面这份配置把上面的要点整合起来,包含 upstream 长连接复用、多种超时和代理缓冲的调整,可直接改地址使用。

upstream ws_backend {
    server 127.0.0.1:8080 max_fails=3 fail_timeout=10s;
    keepalive 32;                     # 与后端保持空闲长连接
}

server {
    listen 80;
    server_name chat.example.com;

    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_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 长连接超时
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_connect_timeout 10s;

        # WebSocket 场景关闭缓冲、禁用请求缓冲,避免消息被攒着不发
        proxy_buffering off;
        proxy_request_buffering off;

        # 不要在 WebSocket 上做重试,避免握手混乱
        proxy_next_upstream off;
    }
}

几个要点说明:

  • keepalive 32 让 Nginx 与后端复用连接,减少握手开销;注意它要求 proxy_http_version 1.1 且 Connection 头在非升级请求时为空(用上面的 map 处理)。
  • proxy_buffering off 对实时消息很重要,否则响应可能被缓冲、延迟下发。
  • proxy_next_upstream off:升级请求不是幂等的,自动换上游可能造成异常,建议关掉。

六、多实例负载均衡下的 WebSocket

这是配置好之后最容易再踩的坑。WebSocket 是有状态长连接,一旦握手连到某个后端实例,后续这条连接的所有消息都会固定走这台实例。如果你有多个后端实例,就要考虑两件事:

方案 做法 适用场景
无状态 + 消息总线 后端不存会话,靠 Redis/MQ 广播消息 推荐,水平扩展友好
黏性会话(sticky) 同一客户端固定到同一实例 后端有本地状态,难改造时
IP Hash upstream 里用 ip_hash 按 IP 固定 简单,但 NAT 后大量用户会聚到同一台

Nginx 开源版没有内置的商业级 sticky 模块,常用替代是 ip_hash:

upstream ws_backend {
    ip_hash;                          # 同一 IP 尽量固定到同一后端
    server 127.0.0.1:8080;
    server 127.0.0.1:8081;
    keepalive 32;
}

但 ip_hash 有局限:NAT 出口下大量用户共用一个公网 IP,会全部压到同一台实例;扩容缩容时映射也会变化。更推荐把会话状态外置(Redis 存 session、发布订阅广播),让后端无状态,再配合普通轮询即可,这样扩容、重启、故障切换都不丢会话。

七、HTTPS/WSS 场景与日志排障

WebSocket 在 HTTPS 下称为 WSS,配置上不需要特殊改动——只要 location 的代理头和超时设对了,TLS 由 Nginx 的 listen 443 ssl 处理,Nginx 内部再以明文 HTTP 连后端即可。

server {
    listen 443 ssl;
    server_name chat.example.com;
    ssl_certificate     /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;

    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_read_timeout 3600s;
    }
}

排查时,先看日志里握手是否返回 101:

# 在 http{} 里自定义日志,把升级头与状态码记下来
log_format wslog '$remote_addr - $status - upgrade=$http_upgrade '
                 'conn=$connection_upgrade rt=$request_time';
access_log /var/log/nginx/ws.access.log wslog;

# 用 curl 验证握手(-i 看响应头,应看到 101 Switching Protocols)
curl -i -N \
  -H 'Connection: Upgrade' \
  -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' \
  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  http://127.0.0.1/ws/

# 看错误日志里的断连原因
tail -f /var/log/nginx/error.log | grep -i 'upstream\|timed out\|reset'

常见状态码含义:

状态码 含义 排查方向
101 协议已切换,握手成功 正常
400 握手请求非法 检查 Sec-WebSocket-* 头是否被中间层剥掉
426 需要升级协议 后端要求 Upgrade,检查 upgrade 头是否透传
502 后端不可用 后端进程、端口、协议问题
499 客户端提前断开 前端超时/主动关闭,常见于心跳缺失
504 后端超时 proxy_read_timeout 或后端慢

常见问题

Q:加了配置还是 60 秒断一次,为什么?
先确认 proxy_http_version 1.1 和两个升级头都设了,且 location 匹配到了实际的 WebSocket 路径(比如 /ws/ 的大小写、末尾斜杠)。再确认 proxy_read_timeout 是否真的生效(nginx -T 看最终配置),最后考虑是中间还有一层负载均衡或防火墙有 60 秒空闲回收,需要在更外层也调大超时或加心跳。

Q:Connection "upgrade" 写死会不会影响同 location 的普通 HTTP 请求?
会。如果这个 location 同时处理普通请求,写死 upgrade 会导致普通请求也被声明为升级,可能引发异常。用 map $http_upgrade $connection_upgrade 的方式动态取值,普通请求自动变成 close。

Q:要不要开 proxy_buffering?
WebSocket 场景建议 proxy_buffering off。缓冲会让服务端的推送消息被攒着、延迟下发,实时性变差。开启缓冲对短请求优化明显,但长连接双向推送下弊大于利。

Q:多台后端时,为什么用户会看到"消息发给了别人"或"偶尔丢消息"?
典型的黏性问题:用户握手连到了 A 实例,而消息广播只发给了 B。解决方向是会话外置(Redis 订阅广播)或用黏性策略,让同一用户的消息始终落在一处并全局广播。

Q:WSS 需要为 WebSocket 单独配证书吗?
不需要。WSS 的 TLS 由 Nginx 的 ssl_certificate 统一处理,location 内仍以明文 HTTP 转发给后端。只有当你需要端到端加密到后端时,才用 proxy_pass https:// 或 grpc_pass 加相应配置。

总结

WebSocket 反向代理的本质是"透传协议升级":proxy_http_version 1.1 加 Upgrade、Connection 两个头,是能否连上的关键。

周期性断连(常见 60 秒)来自 proxy_read_timeout 空闲超时,正解是调大超时 + 应用层心跳双管齐下,中间设备(NAT、防火墙)也常有类似的空闲回收,需要一并考虑。

生产配置建议加上 keepalive 复用、proxy_buffering off(实时性)和 proxy_next_upstream off(避免升级请求重试出错)。

多实例场景优先让后端无状态、会话外置,配合消息总线广播;确需黏性再用 ip_hash,但要清楚 NAT 聚合的副作用。

排障先看状态码:101 正常,426/400 查升级头,502/504 查后端与超时,499 多半是前端超时或心跳缺失。