ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Nginx反向代理WebSocket配置详解:从握手原理到生产级调优

2026/10/6 19:27:28 拓冰建站 浏览量
Nginx反向代理WebSocket配置详解:从握手原理到生产级调优 如果你从事后端开发或者自己搭过服务器大概率遇到过这种场景服务端WebSocket程序明明监听正常本地用测试工具连得好好的可一旦部署到线上、请求经过Nginx转发客户端要么卡在连接中要么握手成功几秒钟就断开浏览器控制台还时不时蹦出个 400 Bad Request。这个问题的根源基本都指向同一件事——Nginx的WebSocket代理配置没有写对。和普通HTTP代理不一样WebSocket连接建立后需要维持一条双向长连接而Nginx默认的HTTP代理行为是按短连接设计的若不做额外配置握手头会被吞掉连接自然就建不起来。这篇文章就把我在实际部署中摸索出来的一套配置、排障思路和生产调优经验完整拆开讲给正在踩坑的人一个可以直接照抄的参考答案。1. 为什么Nginx代理WebSocket不能照抄普通HTTP配置1.1 WebSocket握手与普通HTTP请求的关键差异在动手改配置之前先搞清楚WebSocket和传统HTTP在主从通信方式上的本质区别这部分理解了后面看配置就顺了。普通HTTP请求是“一问一答”模式客户端发起请求服务端返回响应连接使命完成可以关闭。整个过程是无状态的、短连接式的即使HTTP/1.1加入了keep-alive复用机制本质上还是“请求-响应”的循环模式。WebSocket则完全不同。它首先通过一次HTTP协议的握手请求建立连接握手成功之后这条TCP连接就“升级”为双向通信管道客户端和服务端可以随时向对方推送数据。关键是——这个连接一旦建立就长期占用直到某一方主动关闭。这就带来一个问题Nginx作为反向代理它的天然职责是接收上游请求、转发给后端、等待后端响应、返回给客户端。这是为短连接设计的流转模型。When WebSocket的长期连接经过这个模型时代理层不能像处理普通请求那样“转发完就撒手”它必须把这条TCP连接“架空”成一条隧道让客户端和后端的数据直接双向穿透Nginx。这就是为什么我们需要告诉Nginx“这个连接你不许按普通请求处理要特殊对待”。1.2 Connection和Upgrade两个请求头到底在做什么WebSocket握手之所以能从HTTP协议升级成长连接靠的是两个关键请求头Connection: Upgrade和Upgrade: websocket。它们在握手请求中扮演的角色可以用一个不太精确但很好理解的类比你在一个公司前台的访客系统里登记访客身份然后前台发你一张临时通行卡凭卡可以进入内部区域自由活动不需要每次进出都重新登记。Upgrade: websocket就是访客需求声明告诉服务端“我想把当前协议切换为WebSocket协议”Connection: Upgrade则告诉服务端“这个连接我要继续用别发完响应就掐断我”。服务端收到这两个头后如果同意切换就会返回101 Switching Protocols从这一刻起双方在同一个TCP连接上直接跑WebSocket帧数据。1.3 代理场景下“逐跳头”被吞掉的根本原因问题恰恰出在Connection这个头上。HTTP协议里有一个重要概念头字段分为两种类型一种叫“端到端头”比如Content-Type、Authorization它们从客户端出发穿过所有代理层最终抵达服务端语义保持不变另一种叫“逐跳头”比如Connection、Keep-Alive、Upgrade它们只在相邻两个节点之间的单跳链路上有意义不允许被代理透传。Nginx作为HTTP代理默认行为就是“很守规矩”地处理好逐跳头之后继续转发请求。也就是说它会消费掉Connection头再向后端发起新请求时不会自动附带上Connection: Upgrade和Upgrade: websocket。这样一来后端收到的仅仅是一个普通HTTP请求根本没有进入WebSocket握手流程。这就是为什么很多人的代理配置看起来“什么都没写错”但WebSocket就是连不上。理解到这一层接下来的配置就好办了。你只需要做两步把后端请求的HTTP版本提升到1.1然后显式地把这两个头重新传给后端。2. 一套能跑起来的最小WebSocket代理配置2.1 最简配置一个location块搞定单机服务如果你的WebSocket后端服务部署在同一台机器上端口是8080路径是/ws那么下面这组配置是最小可用版。我实际生产环境里跑过很久稳定可靠适合作为起点。map $http_upgrade $connection_upgrade { default upgrade; close; } server { listen 80; server_name example.com; 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; 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 60s; } }先说最容易忽略的一步map块。这段代码必须放在http{}上下文中也就是和server{}同级不能放进location里否则Nginx启动直接报错。它的作用稍后解释先记住这个结构。2.2 逐行拆解关键指令的用途与原理这套配置里有几个指令缺一不可我逐个说一下它们的作用以及去掉之后会看到什么幺蛾子。proxy_http_version 1.1Nginx向后端发起代理请求时默认使用HTTP/1.0。但HTTP/1.0没有标准的Upgrade机制即使你把Upgrade头手动传过去了后端也不认。所以必须显式声明1.1。这一行少了最常见的报错是后端直接返回404或者干脆不响应。proxy_set_header Upgrade $http_upgrade作用是把客户端握手请求里的Upgrade头原样透传给后端。$http_upgrade是Nginx内置变量代表“客户端请求头中Upgrade字段的值”。普通HTTP请求里这个值是空的所以不会影响常规的HTTP代理行为。proxy_set_header Connection $connection_upgrade这里就是map发挥作用的地方。看这组映射逻辑当$http_upgrade的值非空说明是WebSocket握手请求时$connection_upgrade就替换成upgrade当$http_upgrade为空也就是普通HTTP请求时$connection_upgrade就替换成close。这么做的目的是让Nginx既能把Upgrade头传给后端又不会画蛇添足地给每个普通HTTP请求都加一个Connection: upgrade。很多人照着网上的配置抄完发现普通网站访问正常WebSocket却连不上排查了半天最后发现是map写错了位置或者变量名拼错。这里我提醒一句一旦改动map必须nginx -t通过后再reload否则旧配置还在内存里运行改了半天没反应。proxy_set_header Host $host保留原始的Host头。后端如果有基于域名的虚拟主机配置这一行很重要丢了会导致路由错误。X-Forwarded-For和X-Real-IP这是为后端程序获取客户端真实IP服务的。WebSocket应用经常需要记录用户来源IP做统计或安全限制如果漏了这两个头后端看到的IP永远是Nginx所在机器的内网地址业务方排查问题时容易误判。proxy_read_timeout 60s这条线的坑最深。Nginx默认的proxy_read_timeout是60秒意思是如果60秒内代理层没有从后端读到任何数据它就会主动断开这个连接。WebSocket业务如果设计成“服务器不主动推送全靠客户端心跳维持”那客户端发一次心跳可能间隔几十秒甚至一两分钟一旦超过这个阈值连接就会被Nginx强行掐断表现就是“WebSocket莫名其妙断开报错1006”。后面我会专门讲心跳和超时搭配的经验。2.3 用wscat和浏览器验证代理是否生效配置完成后别急着写业务代码先用工具验证一下代理链路是否完整。我习惯用wscat做快速连通性测试没有的话npm install -g wscat装一下就行。# 直连后端确认服务本身没问题 wscat -c ws://127.0.0.1:8080/ws # 走Nginx代理验证转发链路 wscat -c ws://example.com/ws如果直连正常、走代理连接失败问题基本锁定在Nginx配置层如果两者都连不上先去查后端服务进程和监听端口别在代理配置上浪费时间。浏览器端验证也很快F12打开控制台切到Network面板刷新页面触发WebSocket连接找到名字为ws的请求点开看Response Headers里有没有101 Switching Protocols。看到101说明握手成功看到其他状态码直接对照下一节的内容排查。我在这个环节还见过一种情况直连和走代理都能建立连接但服务端收不到客户端发来的消息。这种一般是代理层只转发了握手请求后续数据帧没有正确转发。遇到这种问题优先检查配置里是不是少了第二组proxy_set_header或者proxy_http_version 1.1因为数据帧阶段依赖HTTP/1.1的Upgrade隧道机制。3. 多后端节点下的WebSocket会话保持问题3.1 为什么负载均衡会让WebSocket“串台”单机部署的WebSocket服务能跑通之后很多人会自然想到下一个问题如果后端服务有多台机器怎么用Nginx做负载均衡这里有一个和普通HTTP负载均衡完全不同的陷阱。普通HTTP请求是无状态的Nginx可以把每次请求轮询到不同后端业务上完全无感知。但WebSocket连接是有状态的——握手成功后客户端和后端会在一条TCP连接上持续通信这个连接绑定了服务端的一段会话上下文比如登录态、房间信息、临时数据等。如果客户端发来的数据帧被Nginx转发到另一台后端机器上那台机器根本没有对应的会话上下文消息就丢了表现出来就是“连接还在但不说话了”。千万不要小看这个问题。我在测试环境验证负载均衡配置时用两个后端节点轮流发送消息结果发现消息经常性丢失一开始还以为是后端代码有并发Bug排查了很久才发现是Nginx把同一个WebSocket连接的不同数据帧转发到了不同节点上。3.2 会话保持的三种常见方案与取舍WebSocket场景下解决跨节点会话问题的核心思路只有一个让同一个客户端的连接始终落在同一台后端节点上。业界常见的方案有三种各有适用场景。方案一Nginx的ip_hash负载均衡策略。upstream ws_backend { ip_hash; server 10.0.0.1:8080; server 10.0.0.2:8080; }ip_hash会基于客户端IP计算哈希值保证同一个IP的请求稳定分配到同一台后端节点。这个方案配置最简单不需要后端配合适合用户量不是特别巨大、客户端IP相对固定的场景。但它有一个先天缺陷当某个IP下挂了大量客户端比如公司出口NAT场景几百个用户共享一个公网IP时这个IP的流量会全部压到一台后端节点上负载严重不均。方案二基于Cookie的会话保持sticky session。Nginx商业版有sticky指令可以直接实现基于Cookie的会话保持开源版虽然不带这个指令但可以通过proxy_cookie_flags或后端的会话Cookie来间接实现。这个方案的优点是粒度更细可以精确到每个客户端会话不受IP聚合影响缺点是需要后端配合设置和读取Cookie增加了架构复杂度。方案三应用层重定向。后端在握手阶段根据业务逻辑把自己节点的标识写入一个特定响应头Nginx后续根据这个标识决定转发目标。这个方案通常需要开发自定义Nginx模块或者用Lua脚本实现灵活性最高但维护成本也最高一般业务规模没必要上这么重的方案。我的建议是中小规模部署先上ip_hash实测观察负载分布情况如果出现明显不均再加Cookie方案。别一上来就用高成本方案WebSocket的会话保持其实没有想象中那么复杂很多场景下单机部署都已经够用多节点更多是为了容灾而非性能。3.3 结合HTTP服务和WebSocket服务的混合站点配置实际项目中绝大多数WebSocket服务不是独立部署的而是和传统HTTP API服务共用一个域名。比如example.com/api走普通HTTPexample.com/ws走WebSocket。这种情况下你只需要在同一个server块里配两个location即可配置逻辑互不干扰。server { listen 80; server_name example.com; # 普通HTTP API按常规反向代理配置 location /api/ { proxy_pass http://api_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # WebSocket专用路径单独配置升级头 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_read_timeout 300s; } }有几个细节值得注意第一WebSocket路径建议单独划分不要和API共用同一路径前缀否则Nginx的location匹配规则会变得很绕而且做日志分流时也不好区分。第二location里用精确匹配前缀还是用普通前缀匹配取决于你的接口设计但/ws这种固定路径直接用前缀匹配就够用了。第三如果你的WebSocket服务还依赖HTTP接口的登录鉴权可以让握手请求先经过API层校验成功后引导客户端连接WebSocket或者直接在WebSocket握手时携带Token让后端校验。这两种方式我都在生产环境用过后者更简单直接但要注意Token的传递安全性不要把它放进URL参数里。4. 代理层最常见的故障与完整排查链路4.1 握手返回400 Bad Request的排查顺序如果前端WebSocket连接报400错误不要第一时间冲进后端代码仓库里找Bug。400发生在握手阶段意味着请求根本没有到达正常的应用逻辑层。我通常按下面这个顺序排查第一步看Nginx错误日志。默认路径一般是/var/log/nginx/error.log用tail -f实时盯着看。如果日志里出现upstream prematurely closed connection基本可以确认是后端拒绝了这个请求原因大概率是握手头没有正确传递。第二步确认proxy_http_version 1.1是否配置。这个指令缺失导致的400最隐蔽因为配置看起来几乎完美Upgrade头也传了但后端使用的是HTTP/1.0无法解析Upgrade请求。第三步抓包确认握手请求实际长什么样。这一步可以让你直接看到Nginx转发出去的头是什么状态。用tcpdump在Nginx服务器上抓后端端口的包tcpdump -i eth0 -A -s 0 tcp port 8080 | grep -A 20 GET /ws如果抓包结果显示请求头里根本没有Connection: Upgrade那就回头看配置里的proxy_set_header是不是写错变量名了如果请求头里有Connection: Upgrade但后端返回400问题就在后端服务本身检查它是否真的支持WebSocket协议。这里再分享一个我踩过的坑map块定义变量时如果值里带了多余的空格或者分号nginx -t可能不会报错但运行时变量解析会出问题。遇到Connection: upgrade, upgrade这种诡异的重复值优先检查map块的书写是否规范。4.2 连接建立后几秒内被断开的问题定位握手成功101状态码但连接很快就断开这个现象比400更让人头疼因为问题可能出在很多层面。我把这类问题的排查链路总结成下面这张思维导图式清单你可以照着自己检查一遍如果连接断开时后端日志报错说明是后端主动断开的检查业务代码有没有对连接时的异常处理不当导致崩溃如果后端日志无异常大概率是代理层或网络层断开的先查Nginx错误日志重点看upstream timed out和read timed out如果是read timed out那就是proxy_read_timeout的锅后端在超时时间内没有任何数据输出Nginx就掐断了连接如果是客户端看到的错误码是1006异常关闭这是浏览器端对“非正常关闭帧”的统一错误码需要看服务端或者代理层的具体断开原因如果是1001going away或1008policy violation一般是应用层主动断开去查业务逻辑。有一个高频场景值得特别说明很多WebSocket服务为了省电或省资源会关闭空闲连接而客户端默认认为连接依然健在。一旦服务端或代理层的某一个环节先断开了连接客户端没有及时发现双方的通信状态就错位了后续数据全部发送失败。解决这个问题没有捷径就是靠心跳机制。后面我会细讲心跳怎么配。4.3 502/504错误的常见原因与确认方法502 Bad Gateway和504 Gateway Timeout也是WebSocket代理场景里的常客。502表示Nginx作为代理访问后端失败了504表示Nginx等不到后端的响应。出现502时第一反应是检查后端的端口监听状态# 查看后端端口是否监听 ss -lntp | grep 8080 # 检查后端进程是否存活 ps aux | grep 你的服务进程名如果后端服务确实活着但Nginx仍然报502八成是Nginx与后端的网络不通比如防火墙拦了内网端口或者后端服务只监听了127.0.0.1导致跨机器访问不通。还有一种隐蔽的情况后端服务刚重启Nginx的upstream配置里写的还是旧IP或旧容器地址连不上就报502刷新一下DNS或者同步一下配置就好。504则主要归咎于超时设置。后端处理握手请求的逻辑比较重比如要查数据库、调外部接口耗时超过了proxy_connect_timeout默认60秒或者proxy_read_timeoutNginx等不及就主动放弃。排查方法比较简单把相关超时时间临时调大比如proxy_read_timeout 300s再看是否复现如果不再报504说明确实是后端处理耗时长的原因。但要注意这只是临时排查手段根本解决之道还是要优化后端的握手响应速度否则大量连接会堆积在代理层等待资源拖垮整个服务。5. 生产环境下Nginx代理WebSocket的调优与监控经验5.1 超时参数、心跳探测与可靠性配置走到这一步说明你的WebSocket代理已经能从“能跑”升级到“稳定跑”。这里我给出的是一组长期实践下来的生产参数可以直接作为起点使用。map $http_upgrade $connection_upgrade { default upgrade; close; } upstream ws_backend { ip_hash; server 10.0.0.1:8080 max_fails3 fail_timeout30s; server 10.0.0.2:8080 max_fails3 fail_timeout30s; } 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_connect_timeout 10s; proxy_read_timeout 360s; proxy_send_timeout 360s; proxy_buffer_size 4k; proxy_buffers 8 4k; proxy_busy_buffers_size 8k; } }逐项说一下改动思路proxy_connect_timeout 10s连接后端超时设短一点快速失败避免请求堆积。这个值不建议超过10秒因为同机房内网连接正常情况下都在毫秒级完成设长了只会让Nginx的worker进程被卡住的连接白白占用。proxy_read_timeout和proxy_send_timeout都调到360秒。这个值不是拍脑袋定的而是根据业务的心跳频率计算出来的。我习惯把超时时间设为心跳间隔的三倍以上。假如客户端每30秒发一次心跳那么proxy_read_timeout至少90秒但为了应对网络抖动和客户端异常我会直接调到300到360秒宁长勿短。短了会误杀正常连接长了最多浪费一点系统资源风险更低。max_fails3 fail_timeout30s这两项配合能让Nginx在后端节点宕机时快速摘除流量。真的是生产环境的血泪教训——如果不配这个参数Nginx会不断把请求转发给一个已经挂掉的后端导致大量连接卡死直到超时用户侧感知就是“页面转了十几秒然后报错”。配置了这两项之后后端连续失败3次会被标记为不可用30秒内不再转发流量体验明显好转。关于心跳机制这里多说几句。WebSocket协议本身没有强制要求客户端保持心跳但生产环境强烈建议加。原因有三维持代理层连接存活防止Nginx的读写超时杀掉空闲连接及时发现“幽灵连接”网络断开但两端没感知清理后端的无效资源占用。实现方式一般是客户端每隔固定时间比如30秒发一个ping帧或者业务自定义的heartbeat消息服务端收到后回复代理层保持静默转发即可。Nginx层面完全不需要额外配置只要超时时间大于心跳间隔连接就能稳定存活。5.2 日志格式改造与连接数监控做WebSocket代理最怕的就是线上出问题但日志里啥都看不出来。默认的Nginx access log只记录了握手请求的行连接建立之后的数据帧往来完全没有记录。这就导致排查问题时少了一条最关键的线索。我建议给WebSocket代理加一个独立的日志格式重点记录握手阶段的关键信息log_format ws_log $remote_addr [$time_local] $request $status $body_bytes_sent $http_upgrade $request_time $upstream_response_time $upstream_addr; server { # ... access_log /var/log/nginx/ws_access.log ws_log; # ... }上面$http_upgrade字段会记录客户端传来的Upgrade值如果这里是空说明客户端发的是普通HTTP请求根本没进WebSocket握手流程问题可能在客户端代码而不是代理配置。$upstream_response_time可以让你判断是哪一跳耗时如果这个值很大瓶颈在后端服务如果很小但客户端依然超时问题可能在网络链路。连接数的监控也要安排上。Nginx的stub_status模块提供了基础的连接数据开启方法是在server块加一个专用locationlocation /nginx_status { stub_status on; access_log off; allow 127.0.0.1; deny all; }开启后访问/nginx_status会返回四行关键数据Active connections是当前活跃连接数Reading是正在读请求头的连接数Writing是正在写响应的连接数Waiting是空闲连接数。WebSocket长连接场景下Waiting会长期维持在高位这是正常的说明大量连接已经建立并处于空闲等待状态。你只要把这几个指标接进监控系统设定告警阈值就能对代理层健康状况做到心里有数。5.3 我长期使用下来的一些建议最后分享几条我在不同业务背景下总结的经验。这些不属于某个具体配置项但对稳定性和可维护性的提升很显著。一条是关于worker进程调优的。Nginx默认的worker进程数是按CPU核数自动设置的但WebSocket长连接非常消耗内存和文件描述符如果你的服务端连接数达到几万级别光靠默认配置是不够的。可以在nginx.conf的events块里调整一下events { worker_connections 10240; multi_accept on; }worker_connections表示每个worker进程能同时处理的连接数上限配合系统的文件描述符限制ulimit -n调高到65535以上才能支撑大规模长连接。另一条是关于线上变更的。WebSocket连接是长期存活的当你执行nginx -s reload时老连接不会立刻断开Nginx会把新配置应用到新连接上老连接继续由旧配置管理直到自然断开。这个特性有好有坏好处是reload基本不影响在线用户坏处是如果你改了和长连接相关的配置比如proxy_read_timeout老连接不生效只有新建立的连接才用新参数。所以我做配置变更后会刻意观察一段时间确认新连接的行为符合预期再考虑是否要让客户端重新连接一次否则新旧配置混跑容易造成行为不一致。还有一条是关于定位问题的通用技巧先绕开代理直连后端。不管报错信息多诡异先用客户端直连后端服务如果直连正常再走代理两边对比基本一轮就能锁定问题层级。这个办法帮我处理过不下十个看起来“高深莫测”的WebSocket故障最后的结论无一例外都是代理配置细节的问题。WebSocket代理本身并不复杂核心就是那两行proxy_set_header和一行proxy_http_version。但生产环境的稳定性靠的是理解握手原理、设置合理的超时和心跳、提前规划好负载均衡的会话保持策略以及在日志和监控上多下一点功夫。把这些都做到位了你会发现WebSocket代理其实比普通的HTTP代理还要省心因为连接一旦建立Nginx就成了一个近乎透明的通道真正的压力全在后端业务上。