TLS 会话恢复与 HTTP/3:边缘上的握手优化
会话恢复能让回访用户省掉一次完整的 TLS 握手。我们讲讲票据密钥为什么必须跨重启持久化、如何安全地轮换,以及边缘上的 HTTP/3 是怎样复用同一套业务逻辑的。
对于一个 CDN 来说,最贵的往往不是传输数据,而是建立连接。一次完整的 TLS 握手需要非对称密码运算,还要额外的网络往返;对于距离较远的用户,这几十到几百毫秒会直接体现在首字节时间上。这篇文章介绍我们在 Rust 边缘上做的两件与握手相关的事:TLS 会话恢复,以及 HTTP/3。
会话恢复是什么
当浏览器第一次连接某个站点时,它与服务器完成一次完整握手,协商出会话密钥。如果它很快又要建立新连接(新标签页、连接被回收、移动网络切换),完全没有必要再做一遍昂贵的密钥交换。TLS 提供了会话恢复机制:
- TLS 1.2 有两种方式:会话 ID(服务器在内存里保存会话状态)和会话票据(服务器把会话状态加密后交给客户端保存,下次由客户端带回来)。
- TLS 1.3 统一为 PSK(预共享密钥)恢复:服务器在握手完成后发送
NewSessionTicket,客户端下次连接时带上它。
无论哪种方式,票据都是用服务器端的票据密钥加密的。只要服务器还能解密客户端带来的票据,就能跳过完整握手。对用户来说,这意味着更低的延迟;对边缘来说,这意味着更少的 CPU 花在密码运算上。
一个不显眼的问题:重启之后票据全部失效
在 Rust 边缘早期的版本里,我们注意到一个奇怪的现象:会话恢复的成功率明显低于预期,而且每次部署之后都会掉下去。排查下来原因很简单,但很容易被忽视——我们从来没有为 TLS 层设置票据密钥。
BoringSSL 和 OpenSSL 在没有显式配置时,会在进程启动时随机生成一个票据密钥。这在单进程长期运行的场景下没什么问题,但边缘进程会因为部署、升级而重启。每次重启,新进程都拿着一把新钥匙,客户端手里的所有旧票据瞬间作废,只能退回完整握手。代码里其实有一个密钥环的实现,但它从未接入到 TLS acceptor 上。
修复:持久化、轮换、有界
我们把票据密钥当作一种需要妥善管理的状态,而不是进程的临时副产品:
- 持久化到磁盘。进程启动时从节点的证书目录加载密钥环;文件不存在时,用 CSPRNG 生成新密钥。写入采用"写临时文件再原子重命名"的方式,文件在创建时就是
0600权限,不存在可读窗口。 - 接入真正的回调。我们在 vendored 的 TLS 绑定里加入了票据密钥回调(对应 BoringSSL 的
SSL_CTX_set_tlsext_ticket_key_cb):加密新票据时使用当前密钥,解密时按票据里的密钥名在密钥环中查找。 - 定期轮换。默认每 6 小时轮换一次。密钥环保留当前密钥和少量旧密钥,旧密钥只用于解密,不再用于签发。这样既能让最近签发的票据继续有效,又限制了单把密钥的暴露时间,减小一旦泄漏对前向安全性的影响。
- 失败时放行。如果密钥文件读取失败,边缘会生成临时密钥继续服务,只是这一轮票据不会跨重启有效。会话恢复是优化,不应该成为不能服务的理由。
同时,服务端会话缓存(用于 TLS 1.2 会话 ID)是一个有界的 LRU,默认上限 20480 条,满了就淘汰最老的会话,内存占用不会无限增长。
验证方法也值得一提:我们在测试里模拟"重启",即用同一份密钥文件创建一个全新的 SSL_CTX,再用旧进程签发的票据去连接,断言 session_reused == true。在真实节点上,我们抓取一张票据、重启边缘服务,再用旧票据重连,确认仍然被恢复。注意测试时要发一个真实请求并稍等片刻,TLS 1.3 的 NewSessionTicket 是在握手之后才发送的,过早断开会拿不到票据,结果看起来全是新会话。
需要说明的是:票据密钥目前是节点级的。如果用户的下一个连接落到另一个节点,仍然会做一次完整握手。这是我们有意保留的取舍——在节点间分发密钥会扩大密钥的暴露面。
顺带修好的:干净的重启
在排查过程中,我们还发现边缘服务的重启并不干净。Pingora 默认在收到 SIGTERM 后有一个很长的优雅退出期,而 systemd 的停止超时比它短得多,结果每次重启都是被 SIGKILL 强制结束的。我们把优雅退出期和关闭超时调整到几秒钟,现在重启几秒内就能干净完成。结合持久化的票据密钥,部署对用户几乎是无感的:连接平滑排空,回访用户的票据依然有效。
HTTP/3:同一套业务逻辑,不同的传输层
HTTP/3 运行在 QUIC 之上,而 QUIC 跑在 UDP 上,内置 TLS 1.3。它的好处主要体现在不稳定的网络上:没有 TCP 层的队头阻塞,连接建立的往返更少,网络切换时连接可以迁移。
Pingora 本身的 HTTP/3 支持还没有合入上游。我们不想把数据面的路线图绑定在上游的发布周期上,于是选择了 Cloudflare 的 tokio-quiche(基于 quiche)来实现 QUIC 和 HTTP/3 终结。
关键的设计决定是:HTTP/3 只负责传输层,所有业务逻辑复用现有管线。具体来说:
- tokio-quiche 在 UDP 443 上终结 QUIC 和 HTTP/3,在握手时根据 SNI 选择证书。证书来自与 TCP 443 完全相同的证书存储,没有第二套证书体系。
- 解码出的 HTTP/3 请求通过本机回环转交给同一进程中的 Pingora 处理,路由、缓存、WAF、规则、回源全部复用,不需要为 HTTP/3 重写任何一个功能模块。
- 回环请求携带一个只存在于进程内存中的随机密钥,并且只在来源确实是本机回环时才被信任,用来还原真实客户端 IP 和协议。外部伪造的同名请求头一律被剥离。
- H1/H2 与 H3 在同一进程里统一使用 BoringSSL,这也让我们可以在 QUIC 握手阶段计算客户端指纹。
不承诺做不到的事:Alt-Svc 门控
浏览器通过响应头 Alt-Svc: h3=":443"; ma=86400 得知某个站点支持 HTTP/3。如果服务器发了这个头,却没有在 UDP 上监听,浏览器会白白尝试一次再回落到 HTTP/2。因此 Alt-Svc 广告是按节点和域名双重门控的:只有节点确实运行着 QUIC 监听、并且域名开启了 HTTP/3 时才会发送。我们还会先剥掉源站自己返回的 Alt-Svc 头,避免源站的广告误导客户端。
目前我们没有开启 0-RTT。0-RTT 能再省一个往返,但早期数据可以被重放,需要逐条确认哪些请求是幂等的,我们会在更有把握之后再考虑。
小结
会话恢复和 HTTP/3 都不是"打开开关就行"的功能。会话恢复依赖票据密钥的生命周期管理;HTTP/3 需要另一套传输栈,同时又不能让业务逻辑分叉。我们的做法是:把密钥当作需要持久化和轮换的状态来对待,把 HTTP/3 当作一个只负责传输的前端,所有功能只实现一次。
在 Yeap Cloud 上构建
新用户即享 DNS 与 CDN 免费套餐。
继续阅读
Yeap CDN 新一代边缘:我们为什么选择 Rust
我们把 CDN 边缘节点换成了基于 Rust 和 Pingora 自研的新版本。这篇文章讲讲它给你的网站带来了什么:更快的连接、秒级生效的配置、更强的隔离与安全。
10 分钟把网站接入 Yeap CDN 并开启免费 HTTPS
一步一步把现有网站放到 Yeap CDN 后面:添加域名、配置源站、用 CNAME 或 NS 接入、申请免费 Let's Encrypt 证书、选择 SSL 模式并强制 HTTPS,最后用 curl 验证。
缓存清除实用指南:从缓存键到变体级精确清除
清除了缓存却还是命中?大多数时候问题出在缓存键上。本文解释 Yeap CDN 的缓存键如何构成、查询参数的几种模式,以及带与不带查询参数的 URL 清除分别会清掉什么。