新用户新用户即享 DNS 与 CDN 免费套餐。立即注册

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 上。

修复:持久化、轮换、有界

我们把票据密钥当作一种需要妥善管理的状态,而不是进程的临时副产品:

  1. 持久化到磁盘。进程启动时从节点的证书目录加载密钥环;文件不存在时,用 CSPRNG 生成新密钥。写入采用"写临时文件再原子重命名"的方式,文件在创建时就是 0600 权限,不存在可读窗口。
  2. 接入真正的回调。我们在 vendored 的 TLS 绑定里加入了票据密钥回调(对应 BoringSSL 的 SSL_CTX_set_tlsext_ticket_key_cb):加密新票据时使用当前密钥,解密时按票据里的密钥名在密钥环中查找。
  3. 定期轮换。默认每 6 小时轮换一次。密钥环保留当前密钥和少量旧密钥,旧密钥只用于解密,不再用于签发。这样既能让最近签发的票据继续有效,又限制了单把密钥的暴露时间,减小一旦泄漏对前向安全性的影响。
  4. 失败时放行。如果密钥文件读取失败,边缘会生成临时密钥继续服务,只是这一轮票据不会跨重启有效。会话恢复是优化,不应该成为不能服务的理由。

同时,服务端会话缓存(用于 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 免费套餐。

免费注册