长话短说。干了这么多年运维,一定遇到过这种需求:一台服务器只有一个公网IP,但手上好几个HTTPS域名,每个域名对应不同的后端应用,证书也各不相同。常规做法是七层反向代理统一接证书再转发,但如果后端是异构环境——比如一部分是Java、一部分是PHP、还有一部分是专用的云网关——你大概率不想把所有TLS终止都集中在一层。这时候,Nginx的ngx_stream_ssl_preread_module就派上用场了。
这个模块做的事情一句话总结:在四层TCP转发之前,先偷看一次ClientHello里的SNI(服务器名称指示),然后根据域名把流量原样转发给不同的上游服务器,全程不解密、不碰证书、不落地数据。
我在生产环境里用它解决过一个典型的固有问题:多个域名共用443端口、证书各自管理、后端必须直接看到原始TLS握手。这篇就把我的完整配置思路、踩坑记录和排查手法都摊开讲清楚。
1. 内容整体设计与思路拆解
1.1 选型对比:为什么用四层SNI分流,而不是七层反代
很多朋友的第一反应是用nginx的http模块做反向代理,把所有域名的证书全塞到入口,然后按server_name转发。这种方式在站点数量少、团队可以统一管理证书时确实没问题,但有三个场景会出问题:
- 证书分散在各个业务团队手里,每个团队对自己的证书生命周期负责,你没法集中托管。
- 某些设备或协议(比如RDP、自定义游戏协议、某些国产加密通道)压根不是标准HTTPS,七层没法处理,但它们在TLS握手阶段也会发SNI。
- 你想保留后端的源IP、客户端端口等原始连接特征,不做代理改写。
stream模块下的SNI分流,本质上就是一个基于域名规则的TCP路由器。它只识别域名,不关心上层跑的是HTTP还是别的什么协议,连握手的加密字节都不解密,直接流式转发。这让它天然适合做入口网关,也就是所有TCP流量先进来,根据域名去往不同的后端处理。
打个比方:七层反代像小区门口的高速收费站,每辆车都得停下来看行驶证、交费才能进。四层SNI分流则像是ETC,只看一个车牌标记就放行分道,后面的路况、乘客是谁完全不关心。对于跑着多业务的机房来说,效率和隔离性都重要,ETC路线明显更科学。
1.2 SNI工作原理与ngx_stream_ssl_preread_module的介入时机
SNI全称Server Name Indication,是TLS协议的扩展字段。客户端在发ClientHello时,会把这个字段带上,告诉服务端"我要访问哪个域名"——这样一台服务器才能在同一个IP的443端口上挂多个不同证书。正常情况下,这个字段要在完成TCP握手、开始TLS握手之后才出现在网络字节流里。
ngx_stream_ssl_preread_module的巧妙之处在于:它注册在stream处理阶段的ssl_preread钩子点,能通过零拷贝的方式在Nginx内部预读缓冲区里的数据,解析出SNI字段,又不用等待整个TLS握手完成。解析完成之后,你可以在stream配置块里用map指令建立一个域名到上游服务器组的映射关系,再通过proxy_pass把连接发往对应的后端。
这里我必须强调一个容易误解的点:这个模块不参与证书校验、不强行走TLS握手。它只是"预读"了TLS明文握手包里的域名信息,然后就用这个信息做路由。数据到达后端后,后端真正地完成TLS握手。整个转发过程中,Nginx保持的是纯TCP透传角色。
1.3 部署形态:单一大入口,多业务复用
我实际用的是一个很典型的部署拓扑:
code复制客户端 → 防火墙/负载均衡(Nginx stream) → 后端A(域A.example.com集群)
→ 后端B(域B.example.com单机)
→ 后端C(域C.example.com多副本)
入口只有一个公网IP,443端口只监听一次,由Nginx四层层来做域名的第一次仲裁。后端各自维护自己的证书、会话和业务逻辑,互不干扰。升级某个后端时,不需要动入口配置。
这种形态最大的优势不在于“省一个证书”,而在于职责分离。业务团队可以自己申请证书、自己更新重启,只要IP和端口不变,入口层完全不需要跟着折腾。对于多团队合作的运维环境,这种解耦带来的幸福感是难以言喻的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心配置细节与实操要点
2.1 确认模块是否安装:最常见的起步坑
Nginx很多默认发行的版本并不包含ngx_stream_ssl_preread_module,因为stream模块在早期是作为可选项存在的。你要先确认自己手上这个Nginx到底支不支持。
我习惯用一条命令验证:
bash复制nginx -V 2>&1 | grep -o 'with-stream[^ ]*'
如果输出里有--with-stream,代表基础流模块有;再补一条:
bash复制nginx -V 2>&1 | grep -o 'with-stream_ssl_preread_module'
如果没输出,就得编译加入这个模块。我个人推荐基于官方源码编译,改动最小,方便未来平滑升级。
编译参考:
bash复制./configure --with-stream --with-stream_ssl_preread_module --with-stream_ssl_module
make && make install
提示:
--with-stream_ssl_module不是必须的,但强烈建议一起加上,因为你可能会用ssl_preread配合TLS客户端握手来获取更多信息;而且有些模块比如stream_ssl_preread依赖它的预读框架。实测中两者组合最稳。
2.2 stream配置块内的定向参数拆解
ngx_stream_ssl_preread_module的工作环境是stream块,不是http块。它的核心指令只有那么几个,但每个都作用很大:
ssl_preread on: 打开预读能力,让Nginx在TCP包到达后解析SNI字段。ssl_preread_server_name: 变量,得到解析后的SNI域名。map指令配合使用:把ssl_preread_server_name映射到实际的上游组名。
我见过很多新手直接照抄网上的配置,结果发现$ssl_preread_server_name是空的。原因往往是把模块编译进去了,但忘了在stream块写上ssl_preread on;。这个开关默认是关闭的,不开就永远拿不到SNI变量。
另一个细节是:使用ssl_preread时,一开始的server监听块最好用ssl_preread协议,不要用ssl协议。我以前踩过坑,把listen 443 ssl写在stream里,结果Nginx会试图在四层模块中启用TLS终止,行为完全变了。正确的写法是用listen 443,加上ssl_preread on,让它处于"旁路观察"状态。
2.3 map变量映射的几个隐藏技巧
map指令通常写在http块,但stream块也支持它。我习惯于把域名到上游组的映射集中放在stream块顶部,方便维护:
nginx复制stream {
map $ssl_preread_server_name $backend_pool {
default backend_default;
api.example.com backend_api;
svc.example.com backend_svc;
*.ext.example.com backend_ext;
}
upstream backend_default {
server 127.0.0.1:8080;
}
upstream backend_api {
server 192.168.1.10:443;
server 192.168.1.11:443;
}
upstream backend_svc {
server 192.168.1.30:8443;
}
upstream backend_ext {
server 192.168.1.40:443;
server 192.168.1.41:443;
}
server {
listen 443;
proxy_pass $backend_pool;
ssl_preread on;
}
}
几个要点:
map的default必须写,而且建议指向一个有兜底服务的upstream或错误页服务,不要写空。因为总会有人用IP直连或者拿乱七八糟的域名来扫描,兜不住会直接断开,日志里全是错误记录,干扰排障。- 支持通配符匹配。
*.ext.example.com这类写法能用,但要注意它的优先级是把*当成前缀匹配处理,不会做多级域名的精确匹配。实测更合理的做法是列出所有二级域名,或者用正则。 - 映射关系维护在文件头部最显眼。如果公司域名多,你也可以单独拆一个
map映射文件,用include引进来。我现在的生产环境就把映射表单独放一个文件,运维同事改起来不用动主配置。
2.4 多协议复用:同一个入口能不能同时转发HTTP和HTTPS
这里有个经常被问到的问题:客户端的443端口上既有HTTP又有HTTPS(有人配置了自动跳转或健康检查),只开SNI预读会不会误判失败?
实测不会。SNI预读模块会在缓冲区中识别出TLS ClientHello,解析出SNI;对于非TLS流量(比如HTTP明文请求),[$ssl_preread_server_name]变量会是空字符串,走default分支。我建议把default指向一个简单的HTTP回源服务,或者一个能返回400的TCP收尾服务,防止非法流量在入口悬空占用连接。
另外,如果你还要在同一个入口做TCP端口的其它协议转发(如某些专线路由),可以多开server块,每个块不同listen端口,互不影响。ssl_preread只作用于当前server块内的流量解析。
3. 实操过程与核心环节实现
3.1 场景设定:单IP、公网443、三组不同证书业务
我用一个具体的例子完整演示,这样你可以直接照着抄。假设:
- 服务器公网IP:203.0.113.10
- 域名A:portal.example.com,后端两台的IP为192.168.10.11和192.168.10.12,均监听443。
- 域名B:data.example.com,后端一台192.168.10.21,监听8443。
- 域名C:legacy.example.com,后端特殊端口9000(非标准的HTTPS端口,但TLS握手一致)。
目标:客户端访问各自域名时,Nginx在四层直接转发到对应后端,后端完成TLS握手和证书校验,客户端不感知中间经过了一台跳板。
3.2 完整配置流程:从编译到生效的一步步记录
第一步,确认Nginx模块支持。如果没编译好,按2.1节的方法补编译。其实很多发行版的nginx包在近两年已经默认加入了--with-stream和--with-stream_ssl_preread_module。我用的就是这个默认带模块的版本,省了编译麻烦。
第二步,编写核心配置。我习惯把stream配置独立进nginx.conf的stream块里,不跟http混在一起,结构更清晰。
nginx复制user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log info;
events {
worker_connections 1024;
}
stream {
log_format stream_main '$remote_addr [$time_local] $protocol $status '
'$ssl_preread_server_name -> $backend_pool '
'$upstream_addr';
access_log /var/log/nginx/stream_access.log stream_main;
map $ssl_preread_server_name $backend_pool {
default backend_default;
portal.example.com backend_portal;
data.example.com backend_data;
legacy.example.com backend_legacy;
}
upstream backend_default {
server 127.0.0.1:8080;
}
upstream backend_portal {
server 192.168.10.11:443;
server 192.168.10.12:443;
}
upstream backend_data {
server 192.168.10.21:8443;
}
upstream backend_legacy {
server 192.168.10.31:9000;
}
server {
listen 443;
proxy_pass $backend_pool;
ssl_preread on;
proxy_timeout 60s;
proxy_connect_timeout 5s;
}
}
第三步,验证语法并重载:
bash复制nginx -t
systemctl reload nginx
如果一切正常,stream_access.log会开始输出带有SNI域名的记录。
3.3 关键参数计算与选择逻辑:为什么这样设超时
在真实场景,流式代理最怕的是连接悬挂。后端服务升级时,旧连接会被RST断开,但在断开前,Nginx默认可能会等很久。我给的配置里两个超时时间都是有讲究的:
proxy_connect_timeout 5s:Nginx向后端建立TCP连接的超时。内网情况下后端一般都在同网段,5秒绰绰有余。如果后端网络健康但连接偶发抖动,拉长到10秒会更稳,但我不建议超过10秒,否则故障时前端堆积请求数会指数级增长。proxy_timeout 60s:这是个双阻超时,既管读也管写。如果客户端和后端都断开空闲连接超过60秒,Nginx就主动回收。对普通HTTPS业务来说60秒合理——TLS会话如果真有60秒没动静,要么链路断了,要么用户在挂机,占着连接没意义。我之前用默认值(300秒),白天看不出问题,晚上端口被大量空闲连接占满,新连接排不上队,调整到60秒后立刻缓解。
有人问我为什么不调proxy_buffer_size。这里说明一下:对于ssl_preread模块,Nginx使用的是预读缓冲,归proxy_buffer_size管。测试发现,默认16k已经足够装下绝大多数ClientHello(几百字节到几KB)。如果你的客户端发大量TLS扩展或证书链特别长,可能要调大到32k或64k。但调大buffer会显著增加每个连接占用的内存,一般没必要,真遇到极端情况就先查是不是MTU或分片问题。
3.4 证书后置的安全验证:确保后端真的能完成握手
配置重载之后,我用openssl s_client做了一轮模拟验证:
bash复制openssl s_client -connect 203.0.113.10:443 -servername portal.example.com
返回的证书确实是192.168.10.11上的那份证书,CN和SAN都是portal.example.com,说明Nginx真的做到了“纯转发,不解密”。换另一个域名data.example.com再试,得到的是8443端口后端那份证书,域名匹配。
这里有个现象我提醒一下:因为Nginx不解密,所以你看到握手证书的颁发者是后端自己,不是Nginx。对整个传输链路来说,证书校验是由后端完成的,符合设计预期。
3.5 验证转发正确性的另一个实用姿势:tcpdump
有时候业务诡异,openssl s_client部分连接会卡住,或者协议不是标准TLS,你需要确认字节流到底去往哪里。tcpdump是终极手段:
bash复制tcpdump -ni eth0 'tcp port 443 and (tcp[((tcp[12] & 0xf0) >> 2)] = 0x16)'
手把手解释一下:tcp[12] & 0xf0拿到TCP头长度,>> 2是取字节偏移(因为TCP头的单位是4字节字长),组合起来指向TCP负载开始处的TLS记录层。0x16是TLS握手协议类型的标识。如果看到这样的数据包出现在后端网卡上,且源IP是Nginx,就能确认四层透传链路没问题。
4. 常见问题与排查技巧实录
4.1 stream_access.log里SNI字段为空
排查顺序建议如下:
- 确认
ssl_preread on;确实写在了对应server块。 - 确认
$ssl_preread_server_name变量在map里正确引用。 - 检查是否是TLS 1.3。
ssl_preread模块对TLS 1.3也是支持的,ClientHello在TLS 1.3中同样包含SNI字段,位置和TLS 1.2一样。实测没有问题。 - 如果客户端用的是IP直连而不是域名,SNI必然为空。这不是故障,是预期行为。
我遇到过一次很奇怪的情况:客户端抓包明明发了SNI,但日志里就是空。后来发现是那个客户端用了esni(加密SNI)扩展,导致标准的SNI字段被隐藏了。这个模块只解析经典SNI,esni相对少见,遇到时只能建议客户端关闭加密SNI扩展。
4.2 某些域名跳到了default组,但配置明明写对了
这种问题大多数出在map匹配和域名大小写上。Nginx的变量比较是区分大小写的,不管域名还是字段值,都会严格区分大小写。如果你的域名配置写成Example.com,而客户端发的是example.com,就会匹配失败走到default。建议map里key全部用小写,客户端访问统一用小写。
另一个隐蔽点:map中顺序影响匹配优先级。如果同时写了*.example.com和portal.example.com,Nginx会依次匹配,通配符的优先级低,但有人认为后写的会覆盖先写的,就搞错了。实际规则是默认值总是兜底,精确匹配优先于通配符匹配,同一优先级下按配置文件出现顺序匹配。有歧义时写明确的正则或全名。
4.3 后端证书域名不匹配警告,会影响转发吗
有段时间后端证书里的域名和SNI不一致,但转发是成功的,只是私域流量在客户端那边报证书警告。这种情况Nginx本身感知不到,因为它不校验证书。模块只负责路由,证书校验职责在后端或客户端。
所以如果你看到业务同学说“从网关过去之后证书不对”,大概率不是Nginx的问题,而是后端证书配错了。这种割裂感正是四层透传的特点——每一层只做自己的事,不要指望Nginx帮后端做证书校验。
4.4 后端多个upstream轮询时,SNI会冲突吗
不会。proxy_pass $backend_pool里的每个upstream配置可以指向完全不同的IP和端口。Nginx的负载均衡算法默认是轮询,连接分配完后,SNI信息已经不再被使用。后续数据包原样直达。也就是说,Nginx在决策后不关心中间字节是什么,只关心字节从哪进、从哪出。
我实际还做过一个变体:zone配置加在upstream里做共享内存,实现跨worker进程的连接配额管理。这个对高并发还是很实用的,建议一旦upstream组内有超过2台机器,就加上zone backend_portal 64k;。
4.5 入口高并发下的性能调优心得
ssl_preread解析不消耗多少CPU,真正吃性能的还是TCP转发本身。以下几个配置搭配下来,生产环境跑满万级并发时依然稳:
nginx复制worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 20480;
use epoll;
multi_accept on;
}
stream {
proxy_buffer_size 16k;
proxy_socket_keepalive on;
}
worker_connections与文件描述符协同调整,worker_rlimit_nofile必须同步提高,否则大量连接会因FD耗尽被拒。multi_accept on配合epoll,每个worker能一次性从事件队列取多个新连接,减少进程空转。proxy_socket_keepalive on会让下游连接的TCP保持长连接。实测开启后,短连接场景的握手次数明显下降。
5. 写在最后:除了分流,SNI预读还能做点什么
模块本身很小,但它的衍生玩法值得一说。我用ssl_preread不只是做域名分流,还可以把解析出的SNI域名嵌入日志模板,做基于域名的流量审计;在map里针对特殊域名指向127.0.0.1:1达到快速拒绝的效果,达到黑名单路由。
另外一个实用技巧:把$ssl_preread_protocol也加入日志。这个变量能拿到TLS版本号,对排障非常有用。有一次线上客户端老报底层连接错误,日志里$ssl_preread_protocol显示一堆TLS 1.0请求,后来才知道是对端旧系统只支持老协议。这个信息在没有预读模块时很难在四层直接拿到。
最后说一个我的心法:四层SNI分流和七层反代不是非此即彼的替代关系。我现在的环境里,入口四层分流做第一道大门,后面有的业务走七层反代终结TLS,有的业务保持四层透传,各取所需。你的架构里该怎么选,核心就看一个问题——你需要Nginx帮你处理TLS,还是只想让流量按名称快速去到正确的人手里。前者选七层,后者选ssl_preread,就这么简单。
所以我个人在实际操作中的体会是:ngx_stream_ssl_preread_module适合做“安静的高速路闸口”,它不吵不闹,只是在意每一辆车的车牌,然后准确指向正确的匝道。如果你也在为“单IP多域名多后端异构证书”的转发头疼,这套方案就是最匹配的那个工具,照着上面的配置改一改,基本一次就能跑通。
