1. 一个HTTP服务器,为什么还要管TCP和UDP流量
很多人认识Nginx,都是从“反向代理”这个标签开始的,而且默认把它当成HTTP服务器用。我自己在这上面也走过一段弯路:早期项目里只要涉及非HTTP协议的转发,第一反应就是上LVS或者HAProxy,完全没想到Nginx其实在1.9.0版本之后就已经引入了stream模块,原生支持TCP和UDP四层代理。而且这功能不是实验性的,1.9.13版本里正式转为稳定,经过这几个大版本的迭代,无论是并发能力还是功能完整度,都已经可以作为线上主力方案来用了。
先说清楚一个核心问题:为什么需要四层代理?HTTP反向代理解决的是应用层路由——根据域名、URI、Header这些东西做转发决策。但现实里的流量远不止HTTP一类。SSH远程连接、MySQL读写、Redis缓存、DNS解析、Syslog日志收集、RDP远程桌面,这些协议要么不能改,要么没必要改,它们工作在TCP或者UDP层,需要的是基于IP和端口的透明转发。Nginx的stream模块就是干这个的。
打个比方:HTTP反向代理像一个快递分拣员,会根据包裹上的标签(域名、路径)决定送到哪个货架;而四层代理更像一根管道,两头接好,中间只做流量搬运,不关心管道里流的是水还是油。也正因为不关心内容,它的转发效率比七层代理更高,资源消耗也更低。
这篇内容会覆盖stream模块的启用方式、TCP代理和UDP代理的完整配置思路、健康检查与会话保持的细节,以及我在实际部署过程中踩过的几个坑。适合两类人看:一类是Nginx用得比较熟、但还没碰过stream模块的;另一类是正在做技术选型,想搞清楚Nginx的四层代理能力到底能抗多大压力的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与模块检查——先确认你手里的Nginx带不带stream
2.1 怎么判断当前二进制是否支持stream
讲配置之前,必须先解决一个前提问题:你手里的Nginx到底编译了stream模块没有。很多发行版的默认软件包是不带这个模块的,尤其是一些被裁剪过的二进制包,不少人在配置阶段直接写stream,结果nginx -t报错unknown directive "stream",第一反应还以为是配置语法写错了,其实是模块压根不存在。
判断方法很简单,执行这条命令:
bash复制nginx -V 2>&1 | grep -- '--with-stream'
如果终端有输出,说明当前二进制支持四层代理。如果没有任何输出,一般有两个解决办法:一是用包管理器装完整的Nginx发行包(比如Nginx官方源里的nginx-full),二是自己重新编译,在configure阶段加上--with-stream。生产环境我一般建议直接编译,因为可以顺带把后面可能要用的--with-stream_ssl_module和--with-stream_ssl_preread_module一起编进去。其中ssl_preread模块在做SNI路由时非常有用,后面会详细讲。
还有一个细节需要注意:nginx -V查看的是当前正在使用的二进制文件。如果你用/usr/local/nginx/sbin/nginx启动了服务,但系统里还有另一个路径的Nginx,检查的时候要看清楚到底查的是哪个二进制。这个坑我见过不止一次,有人明明编译了stream,reload之后还是报错,最后发现PATH环境变量里指向的是另一个版本的Nginx。
2.2 配置文件的组织结构
stream模块的顶层配置块是stream { },和http { }是平级关系,互相之间不嵌套。一个常见的误解是把server { listen 3306; }这样的四层配置写进http块里,然后疑惑为什么报错。因为stream里的server和http里的server虽然写法上很像,但属于完全不同的上下文,字段含义也不一样。
一份完整的nginx.conf基本结构是长这样的:
nginx复制user nginx;
worker_processes auto;
events {
worker_connections 10240;
}
http {
include /etc/nginx/conf.d/*.conf;
}
stream {
include /etc/nginx/stream.d/*.conf;
}
我是建议把HTTP和stream的配置分开放在不同目录的,一个是conf.d,一个是stream.d。这样维护的时候思路清晰,不会出现改一个文件动到两边的情况。尤其是团队协作的项目里,负责HTTP配置的人和负责四层代理的人可以各自维护自己的目录,减少冲突。
3. TCP代理实战——从单端口转发到负载均衡与健康检查
3.1 最基础的单机转发:一条proxy_pass就够
先看一个最朴素的需求:内网里有一台MySQL跑在192.168.1.10:3306,希望外部客户端通过跳板机的3336端口来访问它。配置写出来就是这样的:
nginx复制stream {
server {
listen 3336;
proxy_pass 192.168.1.10:3306;
}
}
就这么简单。listen指定Nginx监听的端口,proxy_pass指定转发目标。这里要注意,proxy_pass后面既可以写IP加冒号端口,也可以写upstream组名,还可以写域名。域名方式会做DNS解析,解析结果会被Nginx缓存,直到重启或者reload才会重新解析。如果你希望域名解析结果能动态更新,就需要配合商业版Nginx或者使用第三方resolver模块,开源版在这方面是比较简单的。
3.2 多台后端做负载均衡
单机转发只是入门,stream模块真正常用的场景是负载均衡。比如后端有三台Redis,你希望把客户端请求分散到这三台机器上:
nginx复制stream {
upstream redis_backends {
server 192.168.1.11:6379 weight=3;
server 192.168.1.12:6379 weight=1;
server 192.168.1.13:6379 backup;
}
server {
listen 6379;
proxy_pass redis_backends;
}
}
默认的负载均衡算法是轮询(round-robin),weight参数用来调节权重。上面这个配置里,11号机器每处理3个连接,12号机器处理1个,13号机器作为备份节点,只有前两台都不可用时才会接管流量。stream模块的upstream和http模块的upstream写法几乎一样,学会一次两边通用。
3.3 健康检查:TCP层的health_check和HTTP层不一样
这里有个必须提前说清楚的认知差异。HTTP层的健康检查可以通过访问/healthz接口来判断应用是否真的健康,但TCP层的health_check默认只是做TCP连接探测——也就是说,只要后端端口能建立TCP连接,Nginx就认为节点是健康的。对MySQL这类服务来说也许够用,但有些进程即使业务层已经异常、端口依然能连通,这时候健康检查就会误判。
配置健康检查的完整写法:
nginx复制upstream mysql_backends {
server 192.168.1.11:3306;
server 192.168.1.12:3306;
}
server {
listen 3306;
proxy_pass mysql_backends;
proxy_next_upstream on;
health_check interval=5s timeout=3s passes=2 fails=3;
}
health_check后面可以带几个参数:interval是健康检查的间隔时间,timeout是探测超时时间,passes表示连续成功多少次标记为健康,fails表示连续失败多少次标记为不健康。生产环境里一般会把fails设得大一点,避免网络抖动导致节点被误摘除。
还有一个和故障转移强相关的配置是proxy_next_upstream on。这个开关的含义是:当Nginx与后端建立连接失败,或者读取响应超时的时候,自动把请求转给upstream组里的下一个节点。做故障转移时一定要开着,否则某个后端挂了,连接会一直挂在那个节点上直到客户端超时断开,完全没有自动切换的效果。
3.4 TCP会话保持:别让用户连上A节点,重连后落到B节点
TCP和HTTP有一个重要的区别:HTTP请求以短连接居多,请求结束连接就关闭,切后端影响很小;但TCP长连接一旦建立,用户可能几个小时都占用着这条连接。如果负载均衡算法把新建连接轮询到不同后端,前一个连接和后一个连接落在不同节点上,问题就来了——比如通过代理连接SSH,第一次连接落在A节点,敲了一阵命令后网络波动断开,重连时却落到B节点上。如果A和B的会话状态没有做同步,用户就得重新登录一遍,体验非常糟糕。
stream模块的会话保持有多种实现方式,最简单的就是基于源IP做哈希:
nginx复制upstream ssh_backends {
hash $remote_addr consistent;
server 192.168.1.11:22;
server 192.168.1.12:22;
}
consistent参数表示使用一致性哈希,后端节点增删时,只有极少数的会话会被重新映射,这个特性在做动态扩缩容的时候非常有用。如果你的客户端IP变化频繁,也可以换用proxy_protocol或者自定义变量作为hash key,但源IP方案在大多数常规场景下已经够用了。
3.5 TLS终止与SNI路由
stream模块还支持在四层做TLS终止,这在不少场景里能省掉一台独立的负载均衡器。配置方式如下:
nginx复制server {
listen 443 ssl;
proxy_pass 192.168.1.20:8443;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
}
更进阶的玩法是通过ssl_preread模块,在不解密TLS流量的情况下,根据客户端Hello里的SNI字段路由到不同后端。常见的使用场景是:同一个IP上的443端口,需要把不同域名的流量分发到不同的后端集群。配置思路大概是:
nginx复制stream {
map $ssl_preread_server_name $backend_pool {
api.example.com api_backends;
web.example.com web_backends;
default default_backends;
}
upstream api_backends {
server 10.0.0.11:443;
}
upstream web_backends {
server 10.0.0.12:443;
}
upstream default_backends {
server 10.0.0.13:443;
}
server {
listen 443;
proxy_pass $backend_pool;
ssl_preread on;
}
}
这个方案的好处是Nginx不需要持有后端的私钥,流量仍然由后端自己解密,只做基于SNI的转发决策,非常灵活。
4. UDP代理实战——无连接协议照样能转
4.1 UDP代理和TCP代理的本质区别
UDP和TCP最大的区别在于:UDP没有连接状态,每一个数据报都是独立的。TCP代理只需要在建立连接时决定一次转发目标,之后整条连接的所有数据都走同一个后端;而UDP代理必须为每一个数据报都做出转发决策。
Nginx的stream模块对UDP的处理逻辑是:维护一张会话表,用四元组(源IP、源端口、目标IP、目标端口)来标识一个客户端会话,同一个四元组的数据报会被转发到同一个后端,直到空闲超时才释放这个会话。所以从外部看,Nginx相当于在UDP这个无连接协议之上,模拟出了“虚拟连接”的状态。
4.2 一个完整的UDP转发配置
假设要代理内网的DNS服务器192.168.1.53:53,让外部客户端可以用Nginx所在机器的53端口做域名解析:
nginx复制stream {
server {
listen 53 udp;
proxy_pass 192.168.1.53:53;
proxy_timeout 30s;
proxy_responses 1;
}
}
注意listen后面多了udp关键字,这是TCP和UDP配置最大的区别。proxy_timeout控制的是这个“虚拟会话”的空闲保留时间,超过这个时间没有数据报往来,Nginx就会丢弃会话。proxy_responses告诉Nginx期待从后端收到几个响应数据报——对DNS请求来说通常是一个,这个参数直接影响Nginx判断后端是否正常响应,不设的话容易让UDP会话一直悬挂着不释放。
4.3 UDP负载均衡与会话保持
UDP做负载均衡时,会话保持的逻辑和TCP不太一样。由于UDP没有连接概念,你不能指望客户端的一条TCP连接来锚定会话,只能基于四元组。配置示例:
nginx复制upstream dns_backends {
server 192.168.1.53:53;
server 192.168.1.54:53;
}
server {
listen 53 udp;
proxy_pass dns_backends;
proxy_timeout 5s;
health_check interval=10s udp;
}
health_check后面加了udp关键字,表示用发送UDP探测包的方式来检查后端健康状态。如果不加这个关键字,健康检查逻辑会默认走TCP模式,对UDP后端是完全不适用的。
hash指令在UDP的upstream里同样可用,原理和TCP一样,让同一客户端的请求始终落在同一个后端上:
nginx复制upstream dns_backends {
hash $remote_addr consistent;
server 192.168.1.53:53;
server 192.168.1.54:53;
}
4.4 UDP代理的现实应用场景
- DNS负载均衡:多台DNS服务器,对外统一暴露一个IP,按客户端源IP哈希做会话保持。
- Syslog日志收集:多台日志接收端,用Nginx把网络设备的日志流量分发到不同接收节点。
- 游戏服务器:很多游戏客户端用UDP传输实时战斗数据,Nginx可以按玩家IP做会话保持,保证同一玩家始终连到同一台游戏服。
- 网络时间同步(NTP):企业内网如果只有一台NTP服务器能访问外部时间源,可以用Nginx做UDP转发,让内网所有设备都指向Nginx这台机器。
4.5 大流量UDP场景的buffer调整
UDP数据报如果超过MTU(通常以太网是1500字节),会被IP层分片。Nginx做UDP代理时,默认的接收缓冲区是4k,传输大包时(比如包含大量TXT记录的DNS响应,或者某些游戏服务器的状态同步包),4k很容易不够用,导致数据被截断或者分片重组失败。这种情况下的典型现象是:小包转发正常,大包就丢数据,客户端表现为解析超时或者游戏画面卡顿。
解决办法是把proxy_buffer_size调大:
nginx复制server {
listen 53 udp;
proxy_pass 192.168.1.53:53;
proxy_buffer_size 16k;
}
16k足够覆盖绝大多数UDP业务场景。如果传输的UDP报文更大,可以继续往上调,但要注意内存占用和性能的平衡,毕竟buffer是每个UDP会话都分配的。
5. 源地址传递——四层代理里绕不开的问题
四层代理有一个天然的问题:后端服务器看到的客户端IP是Nginx的IP,不是真实客户端的IP。HTTP层可以用X-Forwarded-For这样的请求头来解决,但TCP和UDP没有地方塞这样的头信息。那怎么办?答案是PROXY protocol。
PROXY protocol是一个简单的协议,在建立TCP连接之后、发送真正的业务数据之前,先发一段纯文本头,里面包含源IP、源端口、目标IP、目标端口。Nginx在上游与后端之间如果开启了proxy_protocol,就会在连接前面加上这一段文本,后端解析之后就能拿到真实客户端IP。
配置方法:
nginx复制stream {
server {
listen 12345;
proxy_pass backend;
proxy_protocol on;
}
}
后端需要支持PROXY protocol才能解析这段头,像HAProxy、Nginx(作为后端时)、部分负载均衡器和数据库中间件都支持。如果后端不支持,开启这个选项反而会让后端无法理解收到的数据,表现为连接建立后握手失败。所以用之前一定要先确认后端的协议支持情况。
如果后端是Nginx自身,接收PROXY protocol的配置是这样的:
nginx复制server {
listen 12345 proxy_protocol;
set_real_ip_from 192.168.1.0/24;
real_ip_header proxy_protocol;
}
set_real_ip_from表示信任哪些来源的PROXY protocol头,这是防止伪造的关键。如果不限制信任范围,任何客户端都可以在连接前伪造一段PROXY protocol头,把真实IP改成任意值,这在安全上是不能接受的。
6. 实际部署中踩过的几个坑
6.1 连接数暴涨导致worker_connections不足
stream代理维持每个客户端连接状态和buffer,内存占用比HTTP转发更高。如果并发连接数很高,但events块里的worker_connections没调大,会出现大量Connection refused。需要特别注意的是,worker_connections限制的是每个worker进程的连接上限,不是整个Nginx的总连接数。要估算总上限,用worker_processes * worker_connections来计算。举个例子,4个worker进程,每个10240连接,总上限就是40960。如果业务量估算是有5万并发连接,而你只配了4乘8192,那一定会有连接被拒绝。
还有一个调优点是worker_processes的数量。stream模块是纯事件驱动的,多worker确实能利用多核,但worker数量也不是越多越好,因为每个worker都要维护自己的事件循环和连接表,太多反而增加上下文切换开销。我的经验是,物理核心数或者稍低于核心数,比如16核机器配8到16个worker,效果比较好,具体还要通过压测来验证。
6.2 UDP大包分片问题
前面在buffer部分提过,大包被分片导致后端收到残缺数据,是UDP代理里非常隐蔽的一个问题。现象往往是业务方报“偶尔丢包”,但网络抓包看又找不到规律。实际上是因为Nginx默认的4k buffer装不下超过这个大小的UDP包,部分分片被丢弃了。做UDP代理线上运维的第一件事,就是把proxy_buffer_size调到16k甚至更高,先排除这个变量再说。
6.3 防火墙导致健康检查误判
health_check的探测流量和真实业务流量可能走不同的网络路径。如果Nginx和后端之间有防火墙,但防火墙只放行了业务端口,没有放行Nginx健康检查的源端口,就会出现后端明明活着、Nginx却判定它挂了的现象。排查这类问题最有效的手段是打开stream模块的日志,看看健康检查探测包到底有没有到达后端。
stream模块的日志配置方法:
nginx复制stream {
log_format basic '$remote_addr [$time_local] $protocol $status $upstream_addr';
access_log /var/log/nginx/stream_access.log basic;
}
和HTTP模块一样,stream也有独立的access_log和error_log,配置方式和HTTP类似,但语法上不完全一样,需要单独确认。
6.4 reload不生效
stream配置和http配置虽然都用nginx -s reload来生效,但有些情况下reload并不能成功重载stream。如果你发现改了stream配置reload后没反应,先执行nginx -t确认语法没问题,再执行nginx -s reload。如果还是不生效,用nginx -s reopen重新打开日志文件,或者干脆systemctl restart nginx。我自己遇到过一次因为旧worker进程僵死导致reload不生效的情况,最终只能重启Nginx进程解决。
排查reload问题的另外一个思路是看error.log里有没有[warn]或者[alert]级别的报错,很多reload失败的真正原因就写在日志里,只是没被注意到。
6.5 不要把HTTP模块的proxy_pass逻辑套到stream上
一个常见的思维惯性是把HTTP配置里的upstream和proxy_pass中的URL路径、header处理方式等逻辑直接搬到stream里。HTTP的proxy_pass后面可以带路径,比如proxy_pass http://backend/api/,但stream的proxy_pass只接受host:port这样的格式或者upstream组名,不支持路径。如果写了路径,nginx -t会直接报错。这点对于刚从HTTP配置转型过来的用户尤其容易踩。
7. 选型思考——什么场景用Nginx,什么场景换别的方案
Nginx的stream模块能做的事很多,但它不是万能的。部署之前,我习惯先做一轮方案对比,把不同四层代理方案的优缺点理清楚。
7.1 主流四层代理方案对比
| 方案 | 性能特点 | 配置复杂度 | 生态与可维护性 | 适合场景 |
|---|---|---|---|---|
| Nginx stream | 事件驱动,单机并发能力出色,但不及内核态方案 | 低,配置风格与HTTP模块一致 | 与现有Nginx生态无缝集成 | 已有的Nginx体系内扩展四层能力 |
| HAProxy | 四层和七层都很强,单机性能优于Nginx | 较低,自带统计页面 | 老牌负载均衡器,文档丰富 | 纯负载均衡场景,独立部署 |
| LVS | 工作在内核态,性能天花板最高 | 高,依赖ipvsadm和网络配置 | 配置灵活但运维门槛高 | 超大流量场景,作为外层入口 |
7.2 我的选型经验
如果只是简单的端口转发和负载均衡,流量规模在几万到十几万并发这个量级,Nginx stream完全够用,而且最大优势是能和现有Nginx体系统一管理。你不需要额外部署一套新的负载均衡器,也不需要学习一套新的配置语法,直接在原有Nginx上加一个stream块就能干活。
但如果流量规模到了几十万并发以上,或者对转发延迟有极致的要求,我会选择LVS做入口层,后面再用Nginx或者HAProxy做业务层路由。LVS工作在内核态,数据包转发路径短,性能上限高,但它的配置和运维复杂度也高,对网络环境有比较严格的要求,不适合作为默认选项。
另外还有一种情况需要谨慎使用Nginx stream:协议本身就非常依赖连接状态的场景,比如某些数据库主从复制链路。这类长连接如果经过负载均衡器,故障转移时连接会断开重连,如果后端没有处理好断线续传,就会引发数据同步问题。这种场景下,如果后端本身支持多点写入,直接让客户端连接多个后端地址,可能比在中间加一层代理更稳妥。
7.3 最后一个小建议
如果你现在还在用旧版Nginx,比如1.8以下,stream模块是没有的。升级到1.18或者1.20以上的稳定版再用,不然遇到一些bug可能都没法通过社区快速找到解决方案。实际生产环境里,四层代理一旦出问题,影响面往往比HTTP代理大得多,因为TCP和UDP承载的是底层连接,上游连不上就是连不上,没有应用层重试的机会。所以配置完成之后,至少做一轮压测验证,把连接数、转发延迟、健康检查、故障转移这几个关键路径都测一遍,再放到线上。
