Nginx stream 模块四层代理实战:TCP/UDP 转发、proxy_protocol 真实 IP 与 TLS 透传取舍

Nginx 不只是 HTTP 服务器

很多人对 Nginx 的印象停留在「网站服务器 + 反向代理」,其实它内置的 stream 模块是一个完整的四层(TCP/UDP)代理。这个模块的能力经常被忽略:它能把 MySQL 的 3306、Redis 的 6379、SSH 的 22 端口收口到一台入口机后面,做统一转发、健康检查和访问控制,而不需要额外部署 HAProxy 或 frp。本文讲清楚 stream 模块怎么开、怎么写、怎么和 proxy_protocol 配合拿真实来源 IP,以及 TLS 透传和终结两种形态的取舍。

先确认 stream 模块在不在

stream 模块不是所有 Nginx 编译版本都带。先查:

nginx -V 2>&1 | tr ' ' '\n' | grep -E 'stream|with-stream'
# 或者看已加载模块
nginx -V 2>&1 | grep -o 'with-stream[^ ]*'

如果输出里没有 --with-stream,说明这个版本没编译进去。发行版自带的 nginx 包通常默认开启,源码编译需要显式加 --with-stream --with-stream_ssl_module --with-stream_ssl_preread_module。缺了 stream_ssl_preread,就没法做后面讲的按 SNI 分流。

基本结构:stream 与 http 平级

stream 块写在最外层,和 http 平级,不能嵌套进 http 里:

# /etc/nginx/nginx.conf 结构示意
user  www-data;
worker_processes auto;

events { worker_connections 10240; }

http  { ... 网站配置 ... }

stream {
    upstream mysql_backend {
        server 10.0.0.11:3306 max_fails=3 fail_timeout=10s;
        server 10.0.0.12:3306 backup;      # 备用节点
    }
    server {
        listen 33060;                      # 对外暴露的端口
        proxy_pass mysql_backend;
        proxy_connect_timeout 5s;
        proxy_timeout 600s;                # 数据库长连接,超时要给大
    }
}

这样外部应用连入口机的 33060,就被透明转发到内网真实的 MySQL 上。proxy_timeout 对数据库、Redis 这类长连接服务必须给大,默认 10 分钟,小于业务空闲连接存活时间会被莫名切断。

TLS 透传 vs TLS 终结:两种形态别搞混

四层代理处理加密流量有两种方式,选错了会出大问题:

  • TLS 透传(passthrough):Nginx 只转发 TCP 字节流,不解密。证书和加密全在后端服务器上,Nginx 看不到明文。配置直接 proxy_pass 即可,用 ssl_preread 可以在不解密的情况下读取 SNI 做分流。
  • TLS 终结(termination):Nginx 自己持有证书、解密后再以明文或重新加密转给后端。适用于你想在入口统一管理证书的场景。

按 SNI 分流的透传配置(一个 IP 一个端口承载多个 TLS 服务):

stream {
    map $ssl_preread_server_name $backend {
        db.example.com     mysql_tls;      # 各 upstream 自行定义
        cache.example.com  redis_tls;
        default            reject_backend;
    }

    upstream mysql_tls { server 10.0.0.11:3306; }
    upstream redis_tls { server 10.0.0.12:6379; }

    server {
        listen 8443;
        proxy_pass $backend;
        ssl_preread on;                    # 关键:不开读不到 SNI
    }
}

ssl_preread 只在透传模式下有意义;终结模式下 Nginx 已经解密,直接看 $ssl_server_name 即可。

proxy_protocol:把真实来源 IP 带过去

四层转发后,后端服务器看到的所有连接来源都变成了 Nginx 入口机的 IP,日志和 IP 白名单全失效。proxy_protocol 就是为解决这个问题的:Nginx 在转发的数据流前面插入一小段「原始来源 IP 和端口」的信息。

# 入口机:开启了 proxy_protocol 的转发
stream {
    server {
        listen 33060 proxy_protocol;       # 声明本端口接收 proxy_protocol
        proxy_pass mysql_backend;
        proxy_protocol on;                 # 转发时也带上
    }
}

注意发送和接收两端必须一致:入口 proxy_protocol on 表示「转出时附带」,listen ... proxy_protocol 表示「接收时解析」。后端如果是 Nginx,还要在 listen 上开 proxy_protocol 并用 set_real_ip_from 取真实 IP;如果是 MySQL,它原生不支持 proxy_protocol,就得靠后端自己解析或改用别的方式。⚠️ 这个特性两端必须都开,只开一端会导致连接直接失败——这是最常见的翻车点。

健康检查与访问控制

stream 模块只有被动健康检查(max_fails + fail_timeout),没有 http 那种主动探测。配合 allow/deny 做来源限制:

stream {
    server {
        listen 33060;
        allow 10.0.0.0/24;      # 只允许内网段
        allow 203.0.113.5;      # 允许某个固定办公 IP
        deny all;
        proxy_pass mysql_backend;
    }
}

把数据库收口到入口机、只对内网开放,是个人站安全加固里很划算的一步:既能隐藏真实 IP,又能在入口做统一的白名单,后端数据库甚至不用暴露任何公网端口。

常见坑速查

  • stream 写进 http 块报错:stream 必须与 http 平级,不能嵌套。
  • 模块没编译:nginx -V 确认 --with-stream,缺了要重编译(用 nginx -V 原始参数)。
  • 长连接被切断:proxy_timeout 太小,数据库/Redis 调到几百秒。
  • 后端看不到真实 IP:没开 proxy_protocol,或只开了一端。
  • SNI 分流不生效:忘了 ssl_preread on,或客户端用 IP 而非域名发起连接(IP 连接没有 SNI)。
  • 改完不生效:stream 的改动需要 nginx -t 后 reload,reload 对 stream 连接是新建连接才生效。

UDP 转发:DNS 与游戏加速场景

stream 模块同样支持 UDP,这对自建 DNS、游戏服务这类基于 UDP 的应用很有用。UDP 的配置和 TCP 类似,但要注意 UDP 是无连接的,健康检查只能靠超时判断:

stream {
    # 自建递归 DNS 对外转发
    upstream dns_backend {
        server 10.0.0.20:53;
        server 10.0.0.21:53;
    }
    server {
        listen 53 udp;
        listen 53;                 # 同时监听 TCP(DNS 大响应会走 TCP)
        proxy_pass dns_backend;
        proxy_timeout 3s;          # UDP 超时要短,否则客户端等太久
        proxy_responses 1;         # 期望 1 个响应,收到即结束会话
    }
}

proxy_responses 1 是 UDP 转发里容易被忽略的参数:它告诉 Nginx 这个会话期望收到几个响应包,收到就可以释放会话资源。DNS 查询通常是「一来一回」,设成 1 能避免会话表被无谓占用。proxy_timeout 3s 也要比 TCP 场景小很多,因为 UDP 客户端不会等待太久。

把 stream 当 TLS 终结器统一管理证书

另一种常见用法是用 stream 做 TLS 终结,把所有证书集中在一台入口机管理,后端内网用明文传输,省去每台机器都配证书的麻烦:

stream {
    server {
        listen 443 ssl;
        ssl_certificate     /etc/nginx/certs/example.com.crt;
        ssl_certificate_key /etc/nginx/certs/example.com.key;
        ssl_protocols       TLSv1.2 TLSv1.3;
        proxy_pass 10.0.0.30:8080;     # 后端明文
    }
}

⚠️ 这样做的前提是入口机到后端的内网链路可信。如果后端在后端机房、跨公网,明文就不可接受了,必须让后端自己持有证书并改用透传模式。选择终结还是透传,本质是在「证书管理便利」和「链路加密」之间做权衡:内网可信就终结,跨不可信网络就透传。

性能与选型:什么时候该用 stream,什么时候不该

stream 模块的性能在中低流量下完全够用,但它是七层服务器顺带做的四层代理,在极端高并发、超低延迟的场景里不如专门的 LVS 或 HAProxy。判断标准很简单:

  • 转发量在每秒几千连接以内、个人站或中小业务:stream 足够,且省去额外组件。
  • 需要主动健康检查、连接队列精细控制、极高 QPS:用 HAProxy 或 LVS。
  • 只是想把内网端口收口、加白名单:stream 是最轻的选择。

另外 stream 的配置不能复用 http 里的 map、upstream 定义——它们是两套独立的命名空间,同名变量和 upstream 互不相干。写配置时别把 http 的 upstream 名字拿到 stream 里用,会报 no such upstream。

用 stream 做端口收口的安全收益

把数据库、Redis、管理面板这些「本不该暴露公网」的服务全部收到入口机的 stream 后面,是安全加固里立竿见影的一步。收益有三层:一是隐藏真实拓扑,扫描者只能看到入口机开放的少数端口,看不到后端内网的真实分布;二是集中访问控制,白名单只在入口配一次,后端无需各自维护;三是便于审计,所有连接都要经过入口,日志集中、时序清晰。

# 入口机:只放行固定来源,其余拒绝,并把连接日志记下来
stream {
    log_format stream_log '$remote_addr [$time_local] $server_port -> $upstream_addr $status';
    access_log /var/log/nginx/stream.log stream_log;

    server {
        listen 33060;
        allow 10.0.0.0/24;
        deny all;
        proxy_pass mysql_backend;
        # 连接建立失败时记录原因
        proxy_next_upstream on;
    }
}

配合 fail2ban 或简单的 limit_conn 思路,可以在入口挡住针对数据库端口的暴力破解扫描。⚠️ 但要注意:stream 层的 access_log 记录的是 TCP 连接级信息,不是应用层的 SQL 或命令,所以「谁连上了」能查清,「连上后干了什么」还得靠后端应用自己的日志。做完收口后,务必再确认后端服务已经不再监听公网地址(ss -lntp 看是 0.0.0.0 还是 127.0.0.1),否则等于门关了、窗户还开着。

改配置时的保命习惯

stream 常常承载着数据库、SSH 这类「断了就进不去」的关键服务,改配置不慎会把管理通道也切断。养成两个习惯:一是改前先 nginx -t 测语法,reload 而非 restart;二是永远保留一个不经过 stream 的直连通道,比如保留一个内网 IP 直连 22 端口,或者用云厂商的 VNC/控制台兜底。历史上不少事故就是「把 SSH 也收进了 stream,结果配置一错整台机器失联」。先测试、再 reload、留后路,这三步能让你在改四层代理时心里有底。

结语:一台 Nginx 收住所有入口

stream 模块让个人站长不必为了「转发一个端口」就额外引入一个代理软件。数据库、缓存、内网服务的入口统一收到一台 Nginx 上,用白名单和 proxy_protocol 兼顾安全与可追溯,配置风格还和 http 保持一致,学习成本几乎为零。下次遇到「想让内网服务不暴露公网、又想有个统一入口」的需求,先想想是不是 stream 块就能搞定,而不是又多装一个组件。

Last modification:October 3rd, 2026 at 10:28 pm

Leave a Comment