ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Nginx WebSocket代理配置实战:从握手原理到踩坑排查

Nginx WebSocket代理配置实战:从握手原理到踩坑排查 先说个真实经历。上个月我给一个内部工具加了实时消息推送后端用Node写的WebSocket服务最开始直接暴露了一个8080端口让前端连。能用是能用但同事反馈说这端口怎么裸奔在外网而且后续想加鉴权、想多部署几个实例做负载均衡全都得动代码。折腾一圈下来我决定把Nginx WebSocket代理这件事彻底做对。这篇文章就从我的实际配置过程出发把Nginx里配置WebSocket代理的完整思路、配置模板、常见坑点都梳理一遍适合正在用Nginx做反向代理、又需要给业务接入WebSocket实时通信的开发和运维同学参考。1. 为什么要让WebSocket走Nginx代理直连端口不可持续很多项目一开始都是前端直接连WebSocket服务器的原生地址比如ws://192.168.1.10:8080/ws。短时间测试没问题但一旦到了多人协作、联调、上线的阶段问题会一个个冒出来。第一个问题是端口暴露。WebSocket服务通常和应用主服务不在同一个端口裸奔一个端口出来意味着防火墙、安全组都要额外开放规则扫描器一扫就能看到你这个端口在跑什么协议暴露面明显变大。走Nginx代理之后对外就只留80/443后端端口全部收敛到内网Nginx统一负责转发。第二个问题是请求头和鉴权不好统一。WebSocket握手本质上是一次HTTP请求如果直连后端所有后端服务都要自己处理跨域、Token校验、IP白名单这些逻辑。而有了Nginx这一层你可以在location里统一加proxy_set_header把客户端真实IP、原始Host、用户身份信息全部传给后端后端代码可以少写很多样板逻辑。第三个问题是扩容。单机WebSocket服务最多支撑的连接数有限一旦用户量上来要么升级机器要么多开几个实例。多实例必然要引入负载均衡而Nginx的upstream模块本身就能做这件事改动只在Nginx配置层后端代码一行不用动。我的建议是哪怕现在只有一个后端实例也先把Nginx代理配好。这属于一次性投入、长期受益的事后面加机器、加域名、加HTTPS都不用再重构。2. 握手不是普通的HTTP转发Upgrade机制和Nginx的配合原理2.1 WebSocket的升级到底升级了什么WebSocket连接从建立到通信分两个阶段握手阶段和数据传输阶段。握手阶段客户端发送一个普通的HTTP GET请求但这个请求头里带着两个特殊字段Connection: UpgradeUpgrade: websocket服务器如果同意升级协议就返回HTTP/1.1 101 Switching Protocols响应之后这个TCP连接不再按HTTP规则解析而是直接变成了WebSocket双向数据通道。你可以把这个过程理解成打客服电话你先说我要转人工客服说好的转接中接通后你和客服之间就不再是语音机器人流程了而是实时对话。这里的转人工就是Upgrade转接成功就是101状态码。Nginx在这里扮演的角色就是一个接线员。它必须原样看到客户端的升级意愿还要把升级意愿完整地转达给后端等后端同意之后后续的数据帧它就不做任何解析纯粹做双向转发。2.2 Nginx默认为什么不行Nginx作为反向代理处理普通HTTP请求时不需要特殊配置。但WebSocket握手有两个特殊点恰好踩在Nginx默认行为的盲区上。第一个盲区Nginx默认向上游服务器发的是HTTP/1.0请求而Connection: Upgrade这种HTTP升级机制是HTTP/1.1才有的东西。所以不显式指定proxy_http_version 1.1的话后端根本看不到升级头。第二个盲区Nginx默认不会自动转发Upgrade和Connection这两个请求头。普通HTTP请求里这俩头意义不大但WebSocket握手离了它们就废了。你必须在location里手动用proxy_set_header把它们传过去。我实测过最典型的错误配置只配了proxy_pass就以为万事大吉结果浏览器控制台直接报WebSocket connection failedNginx access log里显示的是200状态码而不是101后端日志里看到的也只是一次普通GET请求。这就是因为升级头没传WebSocket握手没完成。2.3 关键配置逐行解读核心配置如下我建议直接复制到你的站点配置里再逐行理解map $http_upgrade $connection_upgrade { default upgrade; close; } upstream ws_backend { server 127.0.0.1:8080; } server { listen 80; server_name 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_read_timeout 60s; proxy_send_timeout 60s; } }proxy_set_header Upgrade $http_upgrade这行的意思是如果客户端请求头里有Upgrade字段就把它原样传给后端没有就传空值。这样普通HTTP请求完全不受影响只有WebSocket握手请求才会携带升级头。Connection头就需要多一点心思了。直接把Connection设成upgrade字面量也能用但会产生一个副作用所有经过这个location的普通HTTP请求不管有没有Upgrade头都会附带Connection: upgrade。虽然大多数情况下后端不在乎但严谨的配置会用map做一个映射客户端带了Upgrade头我就把Connection设成upgrade没带就设成close。这就是$connection_upgrade的由来。proxy_http_version 1.1是必须的前面解释过了HTTP/1.0协议里没有升级机制必须让Nginx用HTTP/1.1协议与上游通信。proxy_read_timeout和proxy_send_timeout默认是60秒对WebSocket场景来说这个默认值非常坑。WebSocket连接建立后如果客户端和服务器之间一段时间没有消息往来超过这个超时时间Nginx会主动断开连接。后面踩坑章节我会详细讲这个。3. 从握手到实时通信完整配置模板与验证步骤3.1 一个可以直接用的最小配置先给一个实战验证过的最小配置适合单后端场景map $http_upgrade $connection_upgrade { default upgrade; close; } upstream ws_app { server 127.0.0.1:9501; } server { listen 80; server_name ws.example.com; location / { proxy_pass http://ws_app; 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_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; } }这里把proxy_read_timeout调到了3600秒是因为这个WebSocket服务主要靠应用层的心跳包维持连接心跳间隔30秒。超时时间必须远大于心跳间隔否则会出现心跳还没来Nginx先把连接杀了的情况。proxy_buffering off关闭了代理缓冲对于需要实时性的WebSocket和SSE场景都建议加上。3.2 前端代码对应的连接地址配置好之后前端连接地址是这样的本机测试ws://localhost/默认80端口域名访问ws://ws.example.com/注意WebSocket的地址路径要和location匹配。如果你配置的是location /ws/前端就要连ws://ws.example.com/ws/。不要以为路径无所谓Nginx的location匹配规则在这里直接决定你能不能连上。后端收到的请求路径是什么这取决于proxy_pass的写法。上面的配置里proxy_pass http://ws_app;不带路径所以后端收到的完整URI和浏览器请求的一致比如浏览器请求/ws/chat后端收到的也是/ws/chat。这种原样透传的方式最简单建议后端路由也按这个来设计。3.3 配置完之后怎么验证真的通了改完配置第一件事是检查语法nginx -t看到ok和successful字样后重载配置nginx -s reload然后是功能验证。我习惯分三层来验证第一层直接用浏览器控制台测试const ws new WebSocket(ws://ws.example.com/); ws.onopen () console.log(connected); ws.onmessage (e) console.log(message:, e.data); ws.onclose (e) console.log(closed, e.code, e.reason);如果握手成功Network面板里那条WebSocket请求的状态码应该是101而不是200。看到101就说明Upgrade机制走通了。第二层看Nginx访问日志tail -f /var/log/nginx/access.logWebSocket握手成功时access log里会记录一条101状态码的日志。如果这里显示200甚至400说明握手环节有问题。第三层用命令行工具模拟手动握手。这招在排查问题的时候特别有用因为浏览器会把很多细节吞掉。用curl发一个带升级头的请求curl -i -N \ -H Connection: Upgrade \ -H Upgrade: websocket \ -H Sec-WebSocket-Version: 13 \ -H Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw \ http://ws.example.com/正常响应会包含HTTP/1.1 101 Switching Protocols如果看到400或者别的状态码curl的响应头里往往能给出比浏览器更多线索。4. 真实场景扩展多路径、负载均衡、HTTPS和静态页面共存4.1 一个域名代理多个WebSocket服务现实项目里经常出现一个域名后面挂着不止一套WebSocket服务。比如一个前端聊天室、一个后端实时监控面板分别跑在不同端口。这种场景用路径区分最清爽upstream chat_backend { server 127.0.0.1:9501; } upstream monitor_backend { server 127.0.0.1:9502; } server { listen 80; server_name ws.example.com; location /ws/chat/ { proxy_pass http://chat_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } location /ws/monitor/ { proxy_pass http://monitor_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } }这里有个容易踩的细节location /ws/chat/的末尾斜杠和proxy_pass http://chat_backend不带路径意味着请求URI会被完整保留并传给后端。也就是说前端连的是/ws/chat/socket后端收到的也是/ws/chat/socket。如果你的后端服务实际挂载路径和这个不一致比如后端只监听/socket那就需要用proxy_pass http://chat_backend/;这种带斜杠的写法Nginx会用location匹配后的剩余部分替换掉原URI。具体规则是proxy_pass不带URI时原样透传带URI时用location匹配后的剩余路径拼接。这个规则我建议每一个配Nginx的人都亲手测一遍因为光靠文档印象很容易记反。4.2 多实例负载均衡upstream里加两台机器WebSocket连接是长连接和普通HTTP短请求的负载均衡策略不太一样。普通HTTP请求每次都是新的轮询即可WebSocket连接建立后会一直占用一台后端后续所有数据帧都走同一个连接所以选择合适的策略很重要。upstream ws_cluster { least_conn; server 192.168.1.11:9501; server 192.168.1.12:9501; server 192.168.1.13:9501; } server { listen 80; server_name ws.example.com; location /ws/ { proxy_pass http://ws_cluster; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; } }least_conn让Nginx优先把新连接交给当前活跃连接数最少的后端。WebSocket连接一旦建立就是长驻用连接数作为负载依据比轮询更合理。如果你的后端在WebSocket连接建立后还要维护会话状态比如用户登录态存在进程内存里那就用ip_hash保证同一个客户端IP永远落到同一台后端。但要提醒一句ip_hash在客户端通过代理访问时会失效因为所有请求的源IP都变成了代理的IP。这种情况更推荐在后端做会话共享或者把状态放到Redis里。我实测下来如果是纯广播类业务比如行情推送无状态后端加least_conn最舒服如果是点对点聊天且没有Redis支撑的老老实实用ip_hash。4.3 HTTPS和WSS生产环境绕不开的组合现在浏览器对权限和安全的要求越来越严ws://在HTTPS页面里会被浏览器直接拦截所以线上环境基本都是wss://。配置方式很简单在443端口加一个SSL证书WebSocket代理配置完全一样server { listen 443 ssl; server_name ws.example.com; ssl_certificate /etc/nginx/certs/example.com.pem; ssl_certificate_key /etc/nginx/certs/example.com.key; location /ws/ { proxy_pass http://ws_cluster; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; } }注意一个细节Nginx到后端之间仍然是普通的http://协议只有浏览器到Nginx这一段是HTTPS/WSS。也就是说proxy_pass不用改成ws://或wss://Nginx会把来自后端的WebSocket数据直接通过TLS隧道传回浏览器。这一点很多新手会搞混以为必须要配wss://的upstream其实不需要。前端连接地址变成wss://ws.example.com/ws/。如果前端页面本身就是HTTPS的混用ws://会被Mixed Content策略拦掉统一用wss://就对了。4.4 同一站点既要页面又要WebSocket开发环境经常遇到一个情况前端静态页面和WebSocket服务都想通过同一个域名的80端口访问省得处理跨域。解决思路是用location做路由分发server { listen 80; server_name example.com; # 静态页面 location / { root /var/www/html; index index.html; } # WebSocket服务 location /ws/ { proxy_pass http://127.0.0.1:9501; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } }这样浏览器访问http://example.com/拿到静态页面JavaScript里连接ws://example.com/ws/请求路径以/ws/开头时Nginx自动转给WebSocket后端两者互不干扰。我自己的开发环境就是这么配的省了一个域名的同时也没有跨域问题。5. 实测踩坑记录频繁掉线、502、400握手失败排查链路5.1 症状WebSocket总是大约60秒断一次这是我在配置Nginx WebSocket代理后遇到的第一个坑。前端连上WebSocket之后什么都不做大约60秒连接就会被动断开浏览器控制台报WebSocket is closed before the connection is established或直接触发onclose。先别急着怀疑后端。排查链路是这样走的第一步抓后端日志。后端日志显示连接正常建立也没有主动断开。排除后端问题。第二步看Nginx access log。发现断连时间点附近没有新的HTTP请求记录说明不是Nginx主动记录了什么但连接确实消失了。第三步查Nginx默认超时配置。proxy_read_timeout默认60秒这个超时定义的是两次上游响应之间的间隔。WebSocket连接建立后如果60秒内没有任何数据从后端发过来Nginx就会认为连接空闲超时主动断开。原因找到后解决方式有两个方向调大proxy_read_timeout和proxy_send_timeout我一般设3600s起步后端加应用层心跳每隔30秒发送一个ping/pong帧让Nginx知道连接还活着。我的最终做法是两件事都做后端心跳30秒一次Nginx超时设3600秒。这样即使心跳机制出问题也有很长的缓冲期。5.2 症状连接偶尔直接502 Bad GatewayWebSocket握手阶段直接502大概率是Nginx连不上后端。排查链路第一步确认后端进程还活着curl http://127.0.0.1:9501/能不能通。第二步确认Nginx配置里的proxy_pass地址没错。这里有个坑Nginx的proxy_pass不支持ws://和wss://协议你只能写http://写ws://会直接报配置错误。别笑这个错误我在论坛里见过不止一次。第三步检查worker_connections。每个WebSocket连接都会占用Nginx的一个worker连接如果并发连接数接近这个上限新连接就会被拒绝表现就是时好时坏的502。通过nginx -V查看编译参数默认值通常是1024生产环境建议调大。看当前连接数用nginx -s reopen后的stub_status模块或者直接看ss -s。第四步如果后端在多个实例之间负载均衡还需要确认每个实例的健康状态。没有启用健康检查模块时Nginx会把连接转发给一个已经挂掉的后端。最简单的验证方式是临时把upstream里可疑的实例注释掉看502是否消失。5.3 症状握手返回400 Bad Request浏览器报错这种错误信息很明确浏览器会直接打印Error during WebSocket handshake: Unexpected response code: 400。我遇到过的情况是在某个server块里全局写了proxy_set_header Connection close;然后location /ws/里又写了proxy_set_header Connection upgrade;。理论上location里更具体的配置会覆盖外层但如果顺序不对或者注释不干净实际生效的可能不是你预期的那条。排查方法很简单把Nginx配置里的Connection头相关设置全部理一遍确保没有残留的Connection close覆盖升级配置。另一种400是Host头设置不当。有些后端框架会校验Host头和后端监听域名是否匹配如果你在proxy_set_header Host里写死了某个域名而后端期望的是另一个握手阶段可能直接拒绝。我的建议是proxy_set_header Host $host;这个变量会保留浏览器请求里的原始Host大多数后端都能接受。如果400还排查不出来就用tcpdump抓包看握手请求的完整头tcpdump -i eth0 -A -s 0 tcp port 80 | grep -A 20 Upgrade把客户端发来的请求头和Nginx转发给后端的请求头对比缺了什么、改了什么一目了然。5.4 症状wss页面连不上报证书或Mixed Content错误前端页面已经是HTTPS但WebSocket地址写成ws://时浏览器会直接拦截控制台报Mixed Content错误。解决方式前面已经说了把连接地址改成wss://。还有一种情况是证书链不完整浏览器报ERR_CERT_INCOMPLETE_CHAIN。这跟WebSocket没关系是SSL证书配置问题。我踩过一次折腾了一上午最后用在线SSL检测工具检查才发现中间证书没配全。所以如果wss://连不上先确认同一个域名下的https://访问是否正常如果HTTPS本身有问题优先解决证书问题。6. WebSocket代理的运维心得心跳、健康检查与连接数规划6.1 心跳设计的取舍关于心跳我见过两种方案。一种是纯前端主动发ping给后端后端回pong一种是后端主动发ping。如果业务是双向实时通信我建议后端主动发心跳因为前端页面切后台时JS定时器会被浏览器限流心跳会断。心跳间隔和Nginx超时的配合原则我的经验是Nginx的proxy_read_timeout至少是心跳间隔的两倍。比如心跳30秒一次超时设60秒起步心跳60秒一次超时设120秒起步。宁可超时设大也不要因为超时过短杀掉正常连接。6.2 健康检查不能只靠Nginx默认行为Nginx自带的upstream没有主动健康检查能力商业版或nginx-plus除外后端挂掉后它依然会把请求转发过去等TCP连接超时才报错。生产环境要保障WebSocket服务的可用性我建议引入nginx-module-vts或者nginx_upstream_check_module这类第三方模块做主动健康检查让Nginx周期性探测后端的某个自定义健康检查端点。如果没有条件加模块退而求其次的做法是在接入层做一层脚本监控每30秒模拟发起一次WebSocket连接握手成功立即断开连续失败N次就告警并摘除该后端。我以前在一个小团队就用这招撑过一阵够用但不优雅。6.3 连接数规划一个长连接消耗多少资源WebSocket是长连接它不像普通HTTP请求那样处理完就释放资源。每一条WebSocket连接在Nginx worker进程里占一个连接描述符同时占内核层面的TCP缓冲区。1024的worker_connections实际能支撑的WebSocket并发连接数就是1024左右如果还要承载普通HTTP流量这个数字还要再打折。我在配置高并发场景时会盯三个指标ulimit -nLinux系统对进程可打开文件数的限制Nginx worker需要同时打开大量连接必须调大比如ulimit -n 65535worker_processes和worker_connections的乘积理论上这就是这个Nginx实例能支撑的最大并发连接数每连接内存开销实测每个空闲WebSocket连接约占用几KB到几十KB内存取决于缓冲区配置假设每连接10KB一万个连接就是100MB左右的常驻内存部署前心里要有数。6.4 我的Nginx WebSocket代理配置习惯最后分享几个我长期维护配置时的习惯都是踩过坑之后留下来的第一把map $http_upgrade $connection_upgrade这段放在http块顶层不要放在某个server里。这样后续新增的WebSocket代理location都能直接引用$connection_upgrade不用每个server块里重写一遍。第二每个WebSocket代理location里都显式写一遍proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade;不要嫌重复。曾经为了省配置我尝试把这些提到server块外层结果某次改动影响了同一server下的普通API接口排查了半天才发现是Connection头全局生效了。第三改完配置一律先nginx -t再平滑重载。我见过有人直接nginx -s stop再nginx瞬间干掉所有长连接用户全被踢下线。正确做法是nginx -s reloadworker进程会平滑替换。第四上线前一定要做一次空连接保活测试建立WebSocket连接后不做任何操作等一个超过proxy_read_timeout的时间周期观察连接是否还健在。这一步能提前暴露超时配置问题比上线后被用户投诉再排查舒服得多。我个人在实际操作中的体会是Nginx配置WebSocket代理这件事表面上看只有几行配置的事但真正把它配到生产可用的程度需要对HTTP升级机制、Nginx超时模型、连接资源管理都有基础认知。遇到握手失败不要急着改配置先抓包看头确定是浏览器的问题、Nginx的问题还是后端的问题再对症处理。这套方法论同样适用于SSE、gRPC-Web这类其他长连接代理场景一次掌握、多处复用。
返回列表