用 SNI 复用 443:一次 nginx stream 分流实践
手头有一台很普通的 KVM 小机器:1 核,960MB 内存,跑着几个自己的服务。最近多了一个需求——对外提供一个基于 HTTP/2 的隧道入口,同时又要让同一个域名下的普通网页正常访问。麻烦在于,两件事都想要 443。
可选方案大概有三种:换成支持 ALPN 分流的服务前端、加一层四层负载均衡、或者在 nginx 里做 SNI 预读。前两个都要么引入新组件,要么要额外机器;第三条路最省资源,正好 nginx 的 ngx_stream_ssl_preread 模块就是干这个的。
为什么是 ssl_preread
TLS 握手的第一步是 ClientHello,其中 server_name 扩展是明文的。四层代理不需要解密,只要等到 ClientHello 到达,读出 SNI,就能决定把这条 TCP 连接交给谁。这就是 ssl_preread 做的事:它不终止 TLS,只偷看第一段字节。
和七层反代相比,这个方案有两个明显好处:
- 不参与 TLS,没有证书管理上的耦合,后端可以自己决定握手参数;
- HTTP/2、WebSocket、甚至非 HTTP 的协议都能原样透传,不会被 nginx 的 HTTP 解析器限制。
代价是:同一个连接里的所有请求只能去同一个后端,没法按路径分流。对我们这种“一个域名一个服务”的场景,刚刚好。
结构
最终的结构很简单:nginx 监听公网 443,按 SNI 做选择;命中的域名交给 127.0.0.1:8443 上的隧道服务,其它所有流量(包括直接用 IP 访问、没有 SNI 的客户端)落到 127.0.0.1:8444 的普通 HTTPS 站点。
┌─ SNI = 目标域名 ─→ 127.0.0.1:8443 隧道服务
公网 443 (nginx) ┤
ssl_preread └─ 其它 / 无 SNI ──→ 127.0.0.1:8444 HTTPS 站点
配置
stream 块和 http 块是平级的,所以要放在 nginx.conf 的主上下文里。核心只有几行:
stream {
map $ssl_preread_server_name $backend {
tunnel.example.com 127.0.0.1:8443;
default 127.0.0.1:8444;
}
server {
listen 443;
ssl_preread on;
proxy_pass $backend;
}
}
几个实践中的细节:
map的键是大小写不敏感的,但客户端发来的 SNI 一般就是小写,别在配置里写混合大小写给自己找麻烦。- 没有 SNI 的连接(老客户端、直接用 IP 访问)会命中
default,所以默认分支一定要是一个能正常响应的后端,否则会看到莫名其妙的握手失败。 - 用变量做
proxy_pass时,如果值里是主机名而不是 IP,需要额外配resolver;写成127.0.0.1:port就完全不用管。
踩到的坑
1. Ubuntu 的 nginx 包默认不带 stream
我是从 nginx-core 换到 nginx-full 之后才看到 stream 模块的。Ubuntu 把它做成了动态模块,装在 /usr/lib/nginx/modules/ngx_stream_module.so,需要在 nginx.conf 顶部 load_module,或者直接装 libnginx-mod-stream 并启用 modules-enabled 里的软链。装完先 nginx -V 确认一下,别等配好了才发现模块不在。
2. 后端必须只监听回环地址
第一版我把后端监听在 0.0.0.0:8443,结果绕过 443 直接访问 8443 也能通。虽然端口没对外放行,但这等于把“按域名分流”这件事变成了一种建议。改成 127.0.0.1 之后才真正闭环。
3. 日志要分开看
stream 的日志格式和 http 不一样,默认只有连接级别的信息。排查问题时,我临时在 stream server 里加了 access_log,至少能看到哪个 SNI 落到了哪个后端,比抓包快得多。
效果
上线后观察了几天:CPU 几乎没有变化,内存多了不到 2MB,443 上的连接建立时间没有可测量的差异。最关键的是,直接用 IP 访问 443 也能正常打开站点,不会出现“域名能用、IP 打不开”这种一眼假的特征。
如果你的需求是“同一个端口,按域名分给不同后端,且不想在中间做 TLS 终止”,ssl_preread 大概是成本最低的答案。它不优雅,但足够可靠。