这几年做基础架构,我排查过的线上事故里,至少有一半不是应用代码写错了,而是流量在负载均衡这一层出了问题。业务从单机长成多机集群之后,数据库、消息队列、游戏服、日志采集几乎都会要求“只暴露一个统一入口,内部再分发”。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。这种写法适用于单机转发,不过生产环境里的后端往往是多个节点,这时就需要引入 upstream:
stream { upstream db_readonly { server 192.168.1.11:3306 weight=3; server 192.168.1.12:3306 weight=2; server 192.168.1.13:3306 backup; } server { listen 3306; proxy_pass db_readonly; } }weight控制权重,backup表示热备节点,平时不接收流量,只有其他节点都不可用时才顶上。这里的语义和 HTTP upstream 完全一致,学过一次就能迁移使用。
2.3 TCP 和 UDP 的关键区别:udp 参数与 proxy_responses
TCP 和 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 2>&1 | 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 weight=3; server 192.168.1.103:3306 weight=3; 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_fails=3 fail_timeout=10s; server 192.168.1.12:3306 max_fails=3 fail_timeout=10s; }max_fails=3表示在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 的四层代理真正变成内网流量入口里那枚稳定可靠的螺丝钉。