
先说我遇到的一个场景公司核心出口只有一个公网IP防火墙上只放开了443端口可背后等着接进来的业务有十几个有Web站点、有API网关还有跟七层HTTP完全沾不上边的消息中间件。最初我用七层Nginx反向代理把所有域名的证书集中放到入口网关统一管理结果每次业务换证书都要拉上一堆人开会约窗口期。后来我把入口改成了Nginx四层代理配合SNI按域名做分流同一个443端口不同域名直接转发到各自后端的443证书由各业务团队自己维护。落地之后我再也没为“该谁更新证书”这种事开过会。这篇文章我会从原理开始讲把ngx_stream_ssl_preread_module模块的编译、配置、验证、排错完整走一遍。适合正在规划统一入口或者被“多域名共用一个443端口”折磨过的人。尤其是那些非HTTP协议也想按域名走不同后端集群的场景这个方案几乎是唯一优雅的解法。1. 为什么要在四层做域名分发一个真实的入口困境1.1 只有一个443端口却要面对十几个业务域名做过对外服务的同学都会遇到这个现实问题公网入口资源是稀缺的尤其是防火墙策略、云平台安全组、专线带宽都是按端口申请的。你很难为每一个业务单独划出一个公网IP加一个443端口。但业务的域名是无限的www.example.com、api.example.com、blog.example.net、mq.example.org……全都指向同一个入口IP。如果走传统七层反代那Nginx就得同时持有所有这些域名的证书并且在http块里写一堆server块。这会产生三个连锁问题证书集中管理谁更新证书都要经过入口网关负责人证书一旦更新不及时用户侧直接看到安全告警某些服务根本不是HTTP协议七层反代直接无能为力。我当时遇到的分歧点就在这里业务方说“我的域名证书我自己管”安全团队说“入口不该接触业务私钥”而消息中间件团队说“我们走的是自研TCP协议加TLS你们Nginx七层压根转发不了”。所有需求交叉起来传统的七层方案被否掉了。1.2 七层反向代理的三大痛点必须终结TLS、证书集中、协议受限七层反代的工作方式是客户端把TLS握手交给NginxNginx拿自己的证书完成握手解密出HTTP请求再根据Host或URL路径把请求转发给后端。这一套在Web场景下非常成熟但它有三个绕不开的边界。第一是必须在网关终结TLS。这意味着Nginx要持有所有域名的私钥。私钥集中在一个地方从合规角度看暴露面很大一旦入口被攻破所有业务的证书私钥一起沦陷。第二是证书生命周期管理。Lets Encrypt这类工具能解决自动续期但在大企业里很多证书是商业证书采购、分发、轮换都走流程。集中管理等于把十几套流程的复杂度都压在入口负责人头上。第三是HTTP协议限制。七层只认HTTP遇到数据库、Redis、MQ、自研长连接这些场景就算后端支持TLSNginx的http模块也拿它没办法只能退回四层TCP转发。而四层转发又分不清域名只能按端口区分端口资源又不够。1.3 四层SNI分流的本质优势SSL透传、按域名路由、协议不受限四层SNI分流的思路完全不一样。Nginx在stream模块里开启ssl_preread后会在TLS握手阶段“偷看”一下ClientHello明文里的SNI字段拿到域名然后决定把这条TCP连接原样转给哪个上游。上游自己完成剩下的TLS握手证书由后端自己出。这样带来的直接好处就是入口Nginx不需要保存任何证书私钥谁的域名谁负责证书协议不再受限只要是带TLS的TCP流量哪怕是自研二进制协议只要能解析出SNI都能按域名路由。这本质上是一条“SSL透传加按域名路由”的链路也是HAProxy里用的同款思路。2. ssl_preread的偷看原理TLS握手明文阶段到底发生了什么2.1 TLS ClientHello的结构SNI字段放在明文部分可能有人会问TLS不是加密的吗怎么还能在握手阶段看到域名这里的关键在于TLS的第一次握手消息ClientHello本身就是明文传输的。客户端发起连接时会先发一条ClientHello里面带上自己支持的TLS版本、加密套件列表以及它想访问的域名——这个域名就是SNIServer Name Indication为了帮助服务端在同一个IP上选择正确的证书。这条消息发生在任何加密动作之前所以它必须是明文。服务端读完ClientHello才知道要选哪张证书、用哪个加密套件然后回复ServerHello之后才开始协商密钥、进入加密通道。ssl_preread模块利用的就是这个时间差它只读ClientHello里那几KB数据解析出SNI然后立刻把整条连接原样转给上游自己既不解密也不参与后面的握手。理解这一点非常重要它决定了整个方案的架构定位入口Nginx不是一个TLS终点而是一个“按域名指路的路由器”。它看到域名后就把客户端这条TCP管子直接接到正确的后端门上后续所有TLS握手细节都不再经过Nginx处理。2.2 ssl_preread模块如何工作解析ClientHello并填充变量ngx_stream_ssl_preread_module的工作原理可以简化成三步。第一步在stream的server块里开启ssl_preread on让Nginx在每个连接刚建立时先缓冲一下ClientHello的字节第二步模块解析出SNI字段写入变量$ssl_preread_server_name同时解析出TLS版本写入$ssl_preread_protocol第三步后续的map规则和proxy_pass指令读取这些变量决定这条连接转给谁。整个过程发生在Nginx的事件循环里不需要额外进程也不依赖任何外部组件。因为只解析ClientHello所以它对客户端和后端的协议兼容性没有任何影响——客户端完全感知不到中间还有一层路由后端也不会察觉流量被“转手”过它只知道自己收到了一个正常的TLS连接。一个容易被忽略的细节是$ssl_preread_server_name只有在客户端主动发送SNI时才非空。现代浏览器和curl、openssl s_client默认都会发但如果客户端直接用IP访问或者某些老旧的库没有开启SNI扩展这个变量就是空的。后面排错章节我会专门讲这个坑。2.3 性能开销只解析字节流前几百字节不碰加密内容既然只读ClientHello性能开销自然非常小。一个连接在Nginx层需要做的额外工作最多是缓冲并解析几百字节的明文握手报文这个耗时通常在微秒级别相比后面的TLS握手和业务传输可以忽略不计。在Nginx接受新连接的流程里ssl_preread不会阻塞事件循环它只是注册一个读事件等ClientHello到齐后一次性解析。我自己的压测经验是在万兆网卡和普通虚拟机配置下Nginx四层SNI分流的单机吞吐能做到接近网卡上限连接速率瓶颈往往在并发数而不是CPU。相比七层反代每个连接都要做完整TLS握手加解密四层SNI的CPU消耗几乎可以忽略。这也是为什么很多大流量入口都在用“四层SNI先行、七层按需介入”的架构。2.4 与七层proxy_pass的本质区别一张表看懂两种模式维度七层HTTP反代四层SNI分流Nginx角色反向代理、TLS终结者路由器、TCP透传证书归属Nginx持有并管理各后端自己持有能否看见HTTP内容能不能适用的协议HTTP/HTTPS任意TLS承载的TCP协议路由依据Host、URI、HeaderSNI域名、TLS版本是否支持缓存、改写支持不支持后端看到的源IPNginx IP或透传Nginx IP或PROXY protocol这个对比很直观。四层SNI分流适用的场景就是“路由”两个字要的是把正确的流量送到正确的门口至于门口之后的事情一概不插手。如果你需要在入口做缓存、URL改写、内容过滤还是得走七层。3. 动手前的准备确认模块、编译参数与验证手段3.1 用nginx -V检查stream与ssl_preread模块在写配置之前先确认你手里的Nginx到底带不带这个模块。大多数发行版自带的Nginx包在最近几年都已经默认开启了stream和ssl_preread但不幸的是也有一些精简编译的包只带了http模块。检查方法很简单nginx -V 21重点看configure arguments那一行里有没有--with-stream。如果发行版打包时没有disabled这个特性通常--with-stream会同时引入ngx_stream_module和ngx_stream_ssl_preread_module。更准确的办法是直接看configure参数里有没有--with-stream_ssl_preread_module或者在nginx -V的输出里搜索prereadnginx -V 21 | tr \n | grep preread如果输出里有ssl_preread相关字样说明模块可用。如果nginx -V完全没有任何stream和preread的输出那就要考虑换一个完整版Nginx包或者走编译安装了。这一步不做后面配置写完nginx -t也会直接报unknown directive ssl_preread白折腾。3.2 没有该模块时的编译安装参数如果手上是源码编译的Nginx编译时加上stream相关选项即可。推荐的最小参数组合是这样的./configure \ --prefix/usr/local/nginx \ --with-stream \ --with-stream_ssl_preread_module \ --with-stream_ssl_module \ --with-http_ssl_module \ --with-http_v2_module make -j$(nproc) make install这里说明两个参数的区别--with-stream_ssl_preread_module提供的是解析ClientHello和SNI变量的能力这是本文方案的核心--with-stream_ssl_module提供的是在stream层终结TLS的能力即让四层代理也能自己持有证书做TLS握手。本方案走透传路径其实用不到后者但加上它不会破坏现状而且未来如果想把某些流量改成“Nginx终结TLS再转发”就不用重新编译了。编译前记得把PCRE、zlib这些基础依赖装好否则configure阶段会报错。如果你用的是AlmaLinux或CentOS直接用dnf install gcc pcre-devel zlib-devel openssl-devel就能满足绝大多数版本的需求。3.3 最小验证法先跑一个只含ssl_preread的配置光看编译参数还不保险我习惯用最小配置快速确认模块能被加载。新建一个临时配置文件里面只放stream块和一条ssl_preread指令events {} stream { server { listen 14443; ssl_preread on; proxy_pass 127.0.0.1:8443; } }然后用nginx -t -c指定这个配置检查。如果报错说unknown directive ssl_preread那就是模块没编进去如果顺利通过说明模块可用。这个验证方法比翻文档找版本号可靠得多毕竟发行版的打包习惯千奇百怪。需要注意stream模块的事件配置有时会跟http块冲突。在完整nginx.conf里events块仍然放在顶级stream块和http块平级互不干扰。3.4 用openssl s_client和tcpdump确认SNI可见配置验证通过后再做一个更底层的验证确认客户端发来的SNI确实能在网络层被看到。用一个带SNI的openssl请求和一个不带SNI的请求做对比# 带SNI echo | openssl s_client -connect 10.0.0.1:443 -servername www.example.com 2/dev/null | openssl x509 -noout -subject # 不带SNI echo | openssl s_client -connect 10.0.0.1:443 2/dev/null | openssl x509 -noout -subject如果需要看更底层的数据可以抓包tcpdump -i eth0 -nn -A port 443 | grep -i server_nameTLS的ClientHello是明文所以SNI字段可以直接被grep到。看到类似server_namewww.example.com的输出就说明链路里确实能拿到这个关键信息。这个验证过程既测试了Nginx前的网络路径也测试了客户端行为后面的排查会经常用到。4. 核心配置落地map映射加stream透传4.1 一个能直接跑的完整配置示例先把完整配置贴出来再逐行解释。下面这段是一个典型的多域名分流配置同一个443端口按SNI把流量分给三个后端集群。stream { log_format snilog $remote_addr [$time_local] $protocol $ssl_preread_protocol $ssl_preread_server_name - $backend_pool $status $bytes_sent; access_log /var/log/nginx/sni_access.log snilog; map $ssl_preread_server_name $backend_pool { default default_web_backend; www.example.com web_https_backend; *.example.com app_https_backend; ~^api\.(example|test)\.com$ api_https_backend; mq.example.org mq_tls_backend; } upstream web_https_backend { least_conn; server 7.7.7.101:443 max_fails3 fail_timeout10s; server 7.7.7.102:443 max_fails3 fail_timeout10s; } upstream app_https_backend { server 7.7.7.201:443; server 7.7.7.202:443; } upstream api_https_backend { server 7.7.7.210:443; } upstream mq_tls_backend { server 8.8.8.10:443; } upstream default_web_backend { server 7.7.7.100:443; } server { listen 443; listen [::]:443; ssl_preread on; proxy_pass $backend_pool; } }这段配置的核心逻辑是map根据$ssl_preread_server_name计算出一个$backend_pool变量proxy_pass把这个变量当作上游组名进行转发。不同域名命中不同的upstreamupstream背后各挂一组后端服务器这就是“不同域名请求到不同上游服务器”的完整实现。4.2 map匹配规则详解精确、通配、正则与优先级map的匹配规则值得单独讲一下因为它直接决定你的域名能不能被正确路由。nginx的map匹配按照以下顺序精确匹配优先于通配符匹配通配符优先于正则正则按出现顺序逐个尝试最后才是default兜底。精确匹配就是原样写域名比如www.example.com。通配符匹配分两种前缀通配如*.example.com匹配example.com下的任意子域名但不匹配裸域example.com后缀通配如example.*匹配以example.开头的任意域名。正则用~开头例如~^api.(example|test).com$可以使用更灵活的规则但要注意正则的匹配顺序是写在map里的先后顺序配置多时要留意顺序。我在实际配置中使用通配符时踩过一个细节.example.com和example.com是两条独立规则前者匹配blog.example.com后者匹配example.com本身。如果你希望两种都走同一个上游就得写两行或者用正则统一匹配比如~^(..)?example.com$。map还有一个容易被忽略的特性它是大小写敏感的。域名本身按规范是小写大部分客户端也会转小写但如果你在配置里手滑写了WWW.Example.COM这种精确匹配会失效。保险起见所有域名都写成小写并且要求业务方接入文档明确“SNI请使用小写域名”。4.3 upstream后端的前提条件后端必须自己终结TLS到这里要强调一个容易被新手误解的点四层SNI分流模式下Nginx不负责TLS握手所以在proxy_pass后面的上游服务器必须是一个能独立完成TLS握手的完整服务。也就是说后端至少要在443端口上监听并且持有与访问域名匹配的证书。如果后端只监听80端口而你把443的流量转过去后端收到的是一堆以\x16\x03\x01开头的TLS ClientHello二进制数据直接当作HTTP明文处理就会报400或直接断开。这个错误在我接触的人里发生率极高大多是因为他们把四层SNI分流理解成了“Nginx替后端收TLS请求然后再转发明文HTTP”。不是的四层SNI是透传不是卸载。4.4 配置检查与连接级验证curl --resolve与证书视角配置写完先做语法检查nginx -t没有报错后reload配置nginx -s reload然后最关键的一步验证路由是否生效。因为四层SNI是透传最直接的验证方式是看后端返回的证书是不是对应域名的证书。用curl按域名访问入口IP强制把域名解析到入口curl -k https://www.example.com --resolve www.example.com:443:10.0.0.1 -v curl -k https://api.example.com --resolve api.example.com:443:10.0.0.1 -v curl -k https://mq.example.org --resolve mq.example.org:443:10.0.0.1 -v观察每个请求返回的证书Subject和Issuer。如果www.example.com返回的证书主体是www.example.comapi.example.com返回的是api.example.com说明SNI分流正常。因为透传模式下证书一定是后端自己的Nginx不可能也没能力替换成别的证书。也可以用openssl直接看echo | openssl s_client -connect 10.0.0.1:443 -servername api.example.com 2/dev/null | openssl x509 -noout -subject -issuer输出里能看到证书Subject字段。这个方法比浏览器更直观因为它避开了浏览器缓存和证书信任链干扰。4.5 从访问日志看清每一条路由结果配置好log_format后sni_access.log里面每一行都能看出这条连接的来源、目标域名和转发到的后端组tail -n 20 /var/log/nginx/sni_access.log示例输出类似下面这样203.0.113.5 [13/Jul/2024:14:22:11 0800] TLS TLSv1.3 www.example.com - web_https_backend 200 3456 203.0.113.8 [13/Jul/2024:14:22:15 0800] TLS TLSv1.3 api.example.com - api_https_backend 200 5120我习惯把这个日志作为日常巡检的关键指标尤其是新业务接入时先发起几个测试请求再确认日志里的$ssl_preread_server_name和$backend_pool确实符合预期。有了日志做依据排错效率能提升一大截而不是靠猜。这就是完整的核心配置链路map取值、upstream组、转发、验证、日志。一个四层SNI入口就可以稳定运行了。5. 进阶玩法协议识别、动静分离与多层架构5.1 用$ssl_preread_protocol拦截非TLS流量SNI分流有一个很有意思的衍生能力通过$ssl_preread_protocol变量判断这条流量是不是TLS流量。如果客户端发的不是TLS这个变量就是空如果是TLS会是TLSv1.2、TLSv1.3这样的值。利用这一点可以在入口直接过滤掉非TLS的探测流量或错误流量。stream { map $ssl_preread_protocol $request_type { default unknown; TLSv1 tls; TLSv1.1 tls; TLSv1.2 tls; TLSv1.3 tls; } server { listen 443; ssl_preread on; if ($request_type ! tls) { return non-TLS connection rejected; } proxy_pass $backend_pool; } }这里的return指令会直接向客户端发送一段文本并关闭连接。对于端口扫描、明文探测这类流量这个过滤非常有用避免了它们被送到后端造成无意义的TLS握手错误。需要注意的是stream里的return不同于http里的返回状态码它是直接输出一个字符串所以一般不用来返回HTTP语义只作为拒绝信号。5.2 同一端口区分HTTP/2与普通长连接TLS握手报文里除了SNI还包含ALPN扩展用于告知服务端自己支持的HTTP协议版本比如h2代表HTTP/2、http/1.1代表HTTP/1.1。如果Nginx版本足够新ssl_preread模块也能提取到ALPN相关内容。这意味着我们可以做得更细同一个域名、同一个端口根据ALPN把HTTP/2的流量和普通HTTPS流量分到不同的后端路径。这个能力在我维护过一个“老系统只能跑HTTP/1.1新系统已经上HTTP/2”的过渡期时非常有用。边界入口先按SNI路由到某个后端集群再按ALPN细分是否走新的处理链路。当然这个玩法依赖的变量名会跟Nginx版本有关使用时先确认你的版本是否支持。我个人的建议是除非有明确的过渡期需求否则不要一上来就细化到ALPN先把SNI路由跑稳。5.3 入口四层分流加业务七层网关一种稳妥的多层架构在实际生产环境里我推荐的一种落地架构是“入口四层SNI分流业务侧各自负责七层”。也就是把所有公网443连接先交给入口Nginx入口只按域名把流量发给各个业务子域的七层网关由业务自己的七层Nginx负责证书终结、URL路由、缓存和WAF。客户端 | TLS ClientHello(携带SNI) v 入口Nginxstream ssl_preread ├─ www.example.com ── 业务A七层Nginx(:443) ├─ api.example.com ── 业务B API网关(:443) ├─ mq.example.org ── 消息中间件TLS端口(:443) └─ 其他 ── 默认后端(:443)这个架构的好处是职责非常清楚入口只管“把正确流量送到正确的门”业务方自己管理证书、路由、防护策略。新业务接入时入口只需要加一条map规则和一个upstream操作不再影响其他业务也不需要动证书。更进一步如果上游也要拿到客户端的真实源IP可以在四层配置里开启proxy_protocol让后端配合解析PROXY协议头。比如上游是另一台Nginx就在http块的server里加listen 443 ssl proxy_protocol然后在日志里启用$proxy_protocol_addr获取真实IP。这个配置比较经典适用于对安全审计、访问控制有强要求的场景。6. 踩坑实录SNI路由最常见的四个问题与排查方法6.1 坑一所有流量都进default$ssl_preread_server_name为空现象配置完reload后无论访问哪个域名流量都落在default上游。打开sni_access.log一看$ssl_preread_server_name这一列全是空$backend_pool全是default_web_backend。根因通常是客户端没有发送SNI。最容易复现的情况是测试时用curl访问IP本身或者用curl --resolve却没有带域名有些工具会省略SNI。像curl这种当你用https://IP访问时它默认不会自己填SNI除非显式指定--resolve或-servername。所以在测试阶段所有不带SNI的请求都会命中map的default。排查链路是这样走的先看访问日志确认变量确实为空然后用带SNI的openssl请求重试确认Nginx配置本身没问题最后确认客户端行为。一个关键结论是SNI路由的质量直接取决于客户端发不发SNI。对浏览器和正常域名访问来说SNI是标配但对脚本、监控系统、内部运维工具你掌控不了它们的习惯只能在default里放一个合理的兜底后端或者在入口直接阻挡空SNI请求。6.2 坑二域名匹配到错误后端证书对不上现象浏览器访问api.example.com安全告警提示证书是www.example.com的。这个现象在排障里非常典型第一反应往往是“证书配错了”但在四层SNI架构里更可能是SNI路由命中了错误的上游。排查步骤是先用openssl查看后端返回的证书确定流量实际打到了哪个上游再检查map规则的匹配顺序。我遇到过的一个典型错误是map里写了*.example.com和api.example.com但api.example.com那行写在通配符后面。虽然nginx的map规则里精确匹配优先级高于通配符但如果你误写成了正则顺序就变得很关键。另一个常见错误是upstream里写错了地址导致api的流量被转到了www的后端集群。一旦确认了$backend_pool的实际值对照upstream定义检查IP和端口通常很快就能定位。排查这类问题时sni_access.log里的- $backend_pool字段是最高效的线索比抓包快得多。6.3 坑三后端收到TLS握手后连接被重置现象客户端访问时连接直接断开curl报“connection reset”浏览器报“无法建立安全连接”。这个问题的根因八成在后端不在Nginx。最常见的情况是map把某个域名分到了一个只监听80端口的后端四层Nginx把TLS的ClientHello字节原样转发过去后端的HTTP服务看到的是非法的HTTP请求直接断开连接。另一种情况是后端端口写错比如上游写了443但服务实际监听8443连接直接拒绝。排查链路是先telnet上游的IP和端口确认端口能通然后用openssl直接连上游确认上游本身能完成TLS握手最后检查map和upstream里的端口是不是一致。总结成一句话Nginx的四层转发只是搬运工它不校验内容后端能不能处理TLS是后端自己的责任。这个问题在切换到SNI分流架构时特别容易冒出来因为以前七层反代时后端看到的都是明文HTTP现在突然变成了TLS原始字节流。6.4 坑四proxy_pass $backend_pool配置错误导致无法启动或转发失败现象nginx -t报错提示proxy_pass无法解析变量或者启动正常但所有连接都失败error log里出现no resolver defined之类或上游找不到的报错。root cause通常是map变量名和proxy_pass引用的变量名不一致或者map里某个值写成了未定义的upstream名称。四层Nginx里proxy_pass后的变量值必须能被解析成一个已定义的upstream组名如果map里default指向了一个不存在的upstream配置检查时可能不会报但运行时会连接失败。排查时要重点检查三处map的变量名是否和proxy_pass完全一致map里每个分支的值是否都是上面已定义的upstream名称map是否定义在stream块内。我见过有人把map放在http块里然后在stream块里引用某些版本下会出现变量取不到值的情况。最稳妥的做法是跟配置示例一样map和server都放在同一个stream块顶层确保变量作用域正确。6.5 通用排查工具组合tcpdump加error log加access log最后分享一套通用的排查组合。第一层是tcpdump抓包看ClientHello里的SNI是否出现确认链路和客户端行为第二层是error log确认Nginx有没有连接上游失败的具体报错第三层是sni_access.log直接看每个连接的SNI和转发目标。这三个配合起来能覆盖绝大多数SNI路由问题。举个例子如果tcpdump里能看到server_name字段但sni_access.log里该连接对应的SNI还是空说明问题出在Nginx解析ClientHello的环节如果access log里SNI正常但后端连接超时说明问题在上游网络或后端服务。这种一层一层排除的思路比对着配置反复猜要高效得多。四层SNI分流这个方案核心价值在于把“路由”和“内容处理”彻底解耦。入口只做分拣业务自己掌握证书和协议细节。我实际用下来的体会是它特别适合多业务共享入口、证书分散管理的场景但如果你希望边界网关承担SSL卸载、内容缓存或WAF职责那它并不合适四层和七层各管一段才是更合理的架构。新接入业务时先跑通一个小流量域名做试点确认map规则和日志都符合预期再逐步扩大范围这套方案就能成为你入口架构里一个稳定又省心的积木。