资讯详情

资讯详情

Nginx stream模块四层代理实战:TCP/UDP转发、SNI分流与避坑指南

这几年做基础架构我排查过的线上事故里至少有一半不是应用代码写错了而是流量在负载均衡这一层出了问题。业务从单机长成多机集群之后数据库、消息队列、游戏服、日志采集几乎都会要求“只暴露一个统一入口内部再分发”。Nginx 的 stream 模块也就是常说的四层 TCP/UDP 代理是我目前用过最顺手的解法。它从 Nginx 1.9.0 开始进入开源版本配置思路和 HTTP 反向代理很接近但处理的不是 URL而是 IP端口级别的连接。这篇文章我尽量把 stream 模块的适用场景、配置要点、踩坑经验一次讲清楚适合正在规划内网网关、或者打算用 Nginx 统一四层入口的运维和开发同学。1. 为什么选择 Nginx 做四层代理1.1 先分清四层代理和七层代理到底差在哪在聊配置之前必须先把概念捋直。我们平时用得最多的proxy_pass http://xxx属于七层代理工作在应用层能解析 HTTP 方法、URL、Header、Cookie所以可以做域名路由、URL 改写、鉴权、限流这些精细操作。但它的局限也很明显HTTP 代理只能处理 HTTP/HTTPS 流量对于 MySQL 协议、Redis 协议、DNS 查询这些非 HTTP 流量就无能为力了。四层代理的位置在传输层也就是 TCP/UDP 这一层。它不关心载荷里是 SQL 还是 Redis 命令只负责把从客户端进来的字节流原封不动地转发给后端再把后端的响应原封不动地传回去。换句话说四层代理是一个“透明的搬运工”而七层代理是一个“会看内容的调度员”。Nginx 的 stream 模块做的就是这个搬运工的工作但它的搬运能力比很多人想象中要强得多。1.2 同类工具对比Nginx 的优势到底在哪做四层代理不是只有 Nginx 一个选择。我最早尝试过 LVS它的性能确实强悍基于内核态转发但配置和维护成本高而且 DR 模式需要调整后端服务器的网络参数对跨机房、跨网段场景很不友好后来也试过 HAProxy它确实是个专业四层代理TCP 调度能力极其成熟不过如果团队已经熟练使用了 Nginx再引入一套新配置语法的工具学习成本和踩坑成本都要翻倍iptables 的REDIRECT倒是能实现端口转发但只能做简单的 DNAT做不到灵活的负载均衡和健康检查更谈不上按域名分流。Nginx 的优势在于它把七层和四层的能力封装在同一套配置体系里。同一个进程可以既处理 HTTP 流量又处理 MySQL 或 DNS 的流量使用的是相似的语法结构团队不用重新学一套系统。另外Nginx 的 epoll 事件模型在流式转发场景下延迟很低内存占用也小实测一个中等规模的连接转发节点内存消耗往往只有同样连接数的其他中间件的一半左右。它和 HAProxy 相比最大的差异化其实是“能做基于 SNI 的应用层路由”这一点在后面的配置里会详细展示。2. stream 模块配置体系与核心指令拆解2.1 stream 上下文的位置和边界stream 配置块和 http 配置块一样都位于 nginx.conf 的顶层两者是并列关系。很多人第一次接触时会习惯性地把 stream 块写进 http 里结果执行nginx -t直接报错。正常结构是这样的events { worker_connections 1024; } http { # 其他 HTTP 配置 } stream { # 四层代理配置 }stream 块内部有自己的server指令但没有location、if这些 HTTP 层的东西。stream 里的每一个server块就代表一个监听端口端口背后可以直连后端也可以挂到 upstream 组里做负载均衡。如果业务同时需要 HTTP 和 TCP/UDP 代理直接在两个配置块里各写各的监听就行互不干扰。2.2 listen、proxy_pass 和 upstream 的基本用法一个最简的 TCP 代理只需要三行stream { server { listen 3306; proxy_pass 192.168.1.10:3306; } }意思很直白Nginx 监听 3306 端口把进入的连接转发给192.168.1.10:3306。这种写法适用于单机转发不过生产环境里的后端往往是多个节点这时就需要引入 upstreamstream { upstream db_readonly { server 192.168.1.11:3306 weight3; server 192.168.1.12:3306 weight2; server 192.168.1.13:3306 backup; } server { listen 3306; proxy_pass db_readonly; } }weight控制权重backup表示热备节点平时不接收流量只有其他节点都不可用时才顶上。这里的语义和 HTTP upstream 完全一致学过一次就能迁移使用。2.3 TCP 和 UDP 的关键区别udp 参数与 proxy_responsesTCP 和 UDP 在 stream 配置里最大的区别就在于listen后面的udp参数和proxy_responses指令。stream { server { listen 53 udp; proxy_pass 192.168.2.53:53; proxy_responses 1; proxy_timeout 10s; } }TCP 是面向连接的Nginx 知道连接何时建立、何时关闭所以它可以依赖连接状态来做管理但 UDP 是无连接的Nginx 怎么判断一个 UDP “会话”结束了靠的是proxy_responses和proxy_timeout。proxy_responses表示后端预期返回的数据报数量DNS 查询一般是请求一个包、回复一个包所以设成 1如果不知道或不确定回复数量就省略这个参数让 Nginx 自然等待proxy_timeout超时后关闭会话。没有这两个参数撑着UDP 代理就很容易在转发一条数据报后把后续所有客户端请求都错配到同一个后端表现出的现象就是“流量歪了”。3. 实操TCP 代理、UDP 代理与灵活的分流方案3.1 编译参数确认与环境准备很多发行版自带的 Nginx 已经包含 stream 模块但保险起见还是先确认一下nginx -V 21 | grep stream输出里如果有--with-stream说明基础模块可用如果要做 UDP 代理这个就够了。不过要做 SSL 终结和 SNI 分流的话还需要额外确认是否有--with-stream_ssl_module和--with-stream_ssl_preread_module。用系统包管理器装的 nginx 普遍带了这些但用某些精简版编译包时只有--with-stream少了 SSL 相关模块做 SNI 路由时会发现ssl_preread on;直接报 unknown directive。这个坑我踩过所以建议先跑一次nginx -V再动手。3.2 TCP 场景一MySQL 读写分离的入口收敛假设后端有主从两套数据库我们希望外部只访问一个 MySQL 地址主库承担写入、从库承担只读查询。应用层已经做好了读写分离数据库前端只需要一个稳定的入口stream { upstream mysql_write { server 192.168.1.101:3306; } upstream mysql_read { server 192.168.1.102:3306 weight3; server 192.168.1.103:3306 weight3; server 192.168.1.104:3306 backup; } server { listen 3316; proxy_pass mysql_write; proxy_connect_timeout 5s; proxy_timeout 300s; } server { listen 3317; proxy_pass mysql_read; proxy_next_upstream on; } }这里把写入口放在 3316读入口放在 3317应用侧的配置指向不同端口即可。proxy_connect_timeout 5s控制与后端建立连接的超时时间超过 5 秒就让出连接避免把失败请求长时间挂起proxy_timeout是空闲超时MySQL 长连接通常保持在分钟级所以我习惯给到 300 秒。proxy_next_upstream on表示当前后端失败后尝试下一个节点对数据库这种需要高可用的后端很关键。3.3 TCP 场景二按 SNI 把 TCP 流量分流到不同后端这个场景是我觉得 stream 模块最有价值的地方。公司里有几个老旧系统复用了 443 端口有的是 HTTPS 七层流量有的是自研私有协议还有的是远程运维通道。如果按端口区分每个服务都得记一个新端口很不友好其实可以在同一端口上按 TLS 握手时的 SNI 字段做分流。stream { map $ssl_preread_server_name $backend_pool { default web_backend; ops.internal.example.com ops_backend; legacy.internal.example.com legacy_backend; } upstream web_backend { server 10.0.0.10:443; } upstream ops_backend { server 10.0.0.11:22; } upstream legacy_backend { server 10.0.0.12:8443; } server { listen 443; proxy_pass $backend_pool; ssl_preread on; } }核心是ssl_preread on它让 Nginx 在不解密 TLS 流量的情况下把客户端握手里的 SNI 域名读出来写入变量$ssl_preread_server_name然后通过map映射到对应 upstream。这样运维通道、老系统、普通业务都能共用 443 端口对外只暴露一个入口。需要注意这种模式的后续转发是完全透传的Nginx 不会做 TLS 卸载所以后端的证书是原始业务自己的逻辑上更接近一个“智能端口转发器”。3.4 UDP 场景一DNS 查询的负载均衡UDP 代理用得最多的场景之一就是 DNS。内部有多个 DNS 后端前端统一收口在 53 端口stream { upstream dns_upstreams { server 192.168.1.120:53; server 192.168.1.121:53; server 192.168.1.122:53; } server { listen 53 udp; proxy_pass dns_upstreams; proxy_responses 1; proxy_timeout 10s; } }这里的proxy_responses 1非常关键因为 DNS 通常是一问一答。如果后端链路经常 10 秒以上没有响应就要排查上游 DNS 是否真的在服务而不是怀疑 UDP 代理丢包。UDP 转发对 Nginx 来说会和 TCP 一样选择一台后端建立映射后续同一个客户端的 DNS 请求会尽量发往同一台后端这本身就是一种天然的本地化缓存优化。3.5 UDP 场景二游戏服务器或自定义 UDP 协议游戏服务器大比例使用 UDP 承载实时对战数据当游戏从单体扩展成多区服集群时客户端需要一个固定的接入 IP。Nginx stream 的 UDP 代理可以把不同玩家的数据报分散到不同区服节点stream { upstream game_region { hash $remote_addr consistent; server 10.0.0.20:9000; server 10.0.0.21:9000; } server { listen 9000 udp; proxy_pass game_region; proxy_timeout 30s; } }这里用了hash $remote_addr consistent;做一致性哈希让同一个玩家 IP 尽可能固定落到同一个区服节点避免跨区传送进度数据。proxy_timeout 30s表示如果 30 秒内该玩家没有新的数据报Nginx 就把这个 UDP 会话回收释放占用的内存和 fd。对实时对战场景来说这个值可以更短像 5 到 10 秒就够能显著降低空闲会话数量。3.6 用 PROXY Protocol 保留客户端真实 IP四层代理普遍面临一个问题后端看到的 IP 是代理节点的 IP不是真实客户端 IP。对数据库审计、风控系统来说这个信息很重要。解决方法是使用 PROXY Protocol。它会在 Nginx 和后端建立连接时附带一段明文协议头把原始客户端地址信息传给后端。stream { server { listen 443 proxy_protocol; proxy_pass 10.0.0.30:443; proxy_protocol on; } log_format stream $proxy_protocol_addr - [$time_local] $protocol $status; access_log /var/log/nginx/stream.log stream; }listen ... proxy_protocol表示允许客户端通过 PROXY Protocol 接入proxy_protocol on表示向后端转发时也带上该协议头。后端软件只要支持 PROXY Protocol比如 HAProxy、Nginx stream、某些 Redis 前端就能直接解析出真实来源 IP。日志格式里的$proxy_protocol_addr也是排障时很有用的变量配合访问日志能定位真正发起请求的客户端。4. 常见问题排查技巧与避坑实录4.1 UDP 代理“时而正常时而异常”的真相UDP 代理不像 TCP 那样有明确的三次握手Nginx 通过proxy_responses和proxy_timeout控制会话生命周期。一旦把proxy_responses设成 1而某个后端因为协议协商需要返回 2 个或更多响应包Nginx 会在这个会话上等待额外的响应结果表现为后续新请求迟迟得不到转发或者被集中转移到其他节点。排查方式是观察 stream 访问日志里$status列如果出现大量超时优先检查后端返回包的数量与proxy_responses是否匹配。多数 UDP 异常都可以先从这两个参数下手而不是怀疑网络丢包。4.2 健康检查机制开源版只有被动检查很多朋友在职级上用过云厂商的负载均衡习惯了主动健康检查于是下意识地认为 Nginx stream 也该有interval、fall、rise这些主动探测参数。实际上开源版 Nginx 的 stream upstream 默认只支持被动健康检查也就是通过max_fails和fail_timeout来判定节点是否健康upstream db_read { server 192.168.1.11:3306 max_fails3 fail_timeout10s; server 192.168.1.12:3306 max_fails3 fail_timeout10s; }max_fails3表示在fail_timeout时间内失败 3 次就标记为不可用标记后 10 秒内不会再转发新请求过去。主动健康检查需要 Nginx Plus 版本才自带开源版如果确实需要主动探测可以写一个外部脚本定时检测端口或执行简单协议请求然后通过 reload 接口动态调整 upstream不过这属于复杂玩法建议先从被动的 max_fails 入手绝大多数场景够用。4.3 大 UDP 报文被截断别忘检查 MTU 和缓冲区DNS 某些场景下响应包会比较大比如 DNSSEC 或带额外记录时UDP 报文可能超过 512 字节。Nginx 转发 UDP 时默认的缓冲区大小可能不足以承载大报文结果就是客户端收到被截断的响应甚至直接丢失。排查办法是看是否有buffer size too small之类的日志解决办法是调大缓冲区server { listen 53 udp; proxy_pass dns_upstreams; proxy_responses 1; proxy_buffer_size 16k; }proxy_buffer_size 16k能覆盖绝大多数 DNS 大包需求。同时也要注意如果外网链路的 MTU 小于 1500大 UDP 包还可能在传输中分片或丢弃需要结合网络侧配置一起排查。4.4 配置了 stream 但端口就是不监听有时候nginx -t通过了ss -lntu却看不到监听端口这大概率是编译时没带 stream 模块。之前我接手过一台老服务器系统用自带工具包精简编译了 Nginx只带 HTTP 模块。配置里写stream { ... }本来应该直接报 unknown directive但被外层配置文件 include 进来时可能因为错误提示不显眼而被忽略结果代理一直不生效。遇到这种情况第一件事是nginx -V看模块列表缺模块就重新编译或者换一个组件包完整的系统 Nginx。4.5 长连接场景下 upstream 连接池耗尽TCP 代理在连接数高、连接建立频繁的场景下比如短时间内大量客户端发起 MySQL 连接会反复占用后端的 fd 资源。优化方案是利用 upstream 的keepalive缓存空闲连接upstream db_read { server 192.168.1.11:3306; server 192.168.1.12:3306; keepalive 16; }keepalive 16表示每条后端连接保持 16 个空闲长连接。这个数字不宜设得过大因为空闲连接也会占用后端资源对于数据库这类每个连接本身占用内存较高的场景建议先从 8 或 16 开始压测看后端活跃连接数和内存变化再调整。这里要注意stream 里的 keepalive 只针对 TCP 场景UDP 没有连接池的概念。4.6 排查连通性先本机后链路再抓包四层代理排障一定要按层来。第一步在代理节点本机用nc或telnet测试后端端口是否通nc -vz 192.168.1.11 3306。第二步确认 Nginx 监听正常ss -lntup | grep 3306。第三步再测转发链路如果还是有问题用 tcpdump 同时在代理节点和后端节点抓包对比客户端请求是否到了 Nginx、Nginx 是否转发到后端、后端是否回了包、回包是否又被 Nginx 送回了客户端。UDP 场景注意抓包时加-w存储避免终端刷屏影响分析。5. 从代理节点到统一流量入口扩展实践与体会5.1 一端口多用途HTTP 和 stream 共存时的规划stream 模块和 http 模块可以在同一个 Nginx 进程里共存这给架构规划提供了很大的灵活性。我在实际项目中经常把 80 和 443 留给 HTTP 服务把 3306、3317、6379、53 这些端口规划给四层代理。重点是要提前约定端口用途避免将来为了新增一个 TCP 服务又要重新梳理整个 iptables 或安全组规则。Nginx 在这种场景下本质上充当了内网流量的统一网关所有东西都汇聚到一个配置文件里审计和管理都很方便。5.2 日志和监控是四层代理的生命线TCP 和 UDP 代理的日志比 HTTP 日志少了很多信息但也更纯粹。我在 stream 块里单独开了access_log每次转发失败都会记录状态码和上下游地址排查问题时非常有价值。建议在日志里至少包含$remote_addr、$protocol、$status、$bytes_sent、$bytes_received、$session_time、$upstream_addr这几个变量。$bytes_received对 UDP 场景特别有用能直观看到是不是大量请求在上游就被丢弃了。最后分享一个小技巧我在生产环境里跑 stream 模块跑了三年多最大的体会是四层代理看似简单真正的难点其实在于如何优雅地管理连接生命周期和节点状态。最后分享一个小技巧每次改动 stream 配置后不要只跑nginx -t还应该用curl -v或nc实际测试一次转发链路确认监听端口、upstream 节点、超时参数都符合预期。特别要注意的是proxy_timeout的取值TCP 和 UDP 的语义不同TCP 里它是空闲连接超时UDP 里它决定了会话的存活时间千万别直接复制一套配置到处套用先想清楚端口后面是数据库还是缓存、是长连接还是短连接再去调参数。这样才能让 Nginx 的四层代理真正变成内网流量入口里那枚稳定可靠的螺丝钉。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →