🦀 Rustls 热重载:证书秒换零停机,连接不断流
Rustls 服务器证书热重载(Hot Reloading)实践指南
在生产环境中,使用 Let’s Encrypt 等证书时,证书通常每 60–90 天需要更新一次。如果直接重启服务来加载新证书,会导致短暂的服务不可用和连接中断,这在高可用场景下是不可接受的。
Rustls 提供了非常优雅的解决方案:通过实现 ResolvesServerCert trait 来实现服务器证书的热重载(零停机更新)。
核心思想是:
- 不把证书静态写入
ServerConfig(如with_single_cert) - 而是让 Rustls 在每次新连接建立时动态调用你的 resolver 来获取当前应该使用的证书
- 当证书更新时,只需原子地替换 resolver 内部持有的证书引用(通常用
Arc),现有连接继续使用旧证书,新连接自动使用新证书
下面详细讲解实现方式、推荐方案和生产实践。
核心机制:ResolvesServerCert Trait
Rustls 的关键接口:
pub trait ResolvesServerCert: Send + Sync {
fn resolve(&self, client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>>;
}
- 每次新 TLS 连接建立时都会调用
- 可以根据
ClientHello中的 SNI(server_name)决定返回哪个证书 - 返回
Arc<CertifiedKey>(证书链 + 私钥签名对象)
要实现热重载,需要:
- 证书存储在
Arc中 - 当证书更新时,原子替换这个
Arc resolve方法要尽量快(无锁或读锁)
推荐方案:使用现成 crate(最简单)
目前最受欢迎且维护良好的热重载方案是:
- tls-hot-reload(强烈推荐)
- rustls-hot-reload(功能类似)
使用 tls-hot-reload 示例
[dependencies]
tls-hot-reload = "0.1" # 请检查 crates.io 最新版本
tokio = { version = "1", features = ["full"] }
axum = "0.7"
axum-server = { version = "0.6", features = ["tls-rustls"] }
anyhow = "1"
use axum::Router;
use tls_hot_reload::load_server_config;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 证书文件路径(fullchain.pem 包含证书链,privkey.pem 是私钥)
let config = load_server_config(
"certs/fullchain.pem".to_string(),
"certs/privkey.pem".to_string(),
).await?;
// config 会自动监控文件变化并热重载
let app = Router::new(); // 你的路由
axum_server::bind_rustls(([0, 0, 0, 0], 443), config)
.serve(app.into_make_service())
.await?;
Ok(())
}
特点:
- 自动监控文件修改(使用 notify crate)
- 文件变更时自动重新加载 PEM 并更新内部 resolver
- 零配置,几乎开箱即用
- 支持多证书(SNI)场景(需查看最新文档是否已支持)
手动实现(完全掌控,适合多域名 / DB / Vault 场景)
如果你需要从数据库、Vault 或其他地方加载证书,或者支持多域名 SNI,可以手动实现。
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::sign::{any_supported_type, CertifiedKey};
use rustls::server::{ClientHello, ResolvesServerCert};
use rustls_pemfile::{certs, pkcs8_private_keys};
use tokio::sync::watch;
#[derive(Clone)]
struct DynamicServerCertResolver {
// 域名 -> 证书(支持 SNI 多域名)
certs: Arc<RwLock<HashMap<String, Arc<CertifiedKey>>>>,
// 默认证书(无 SNI 或未知域名时使用)
default: Arc<RwLock<Option<Arc<CertifiedKey>>>>,
}
impl ResolvesServerCert for DynamicServerCertResolver {
fn resolve(&self, client_hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
// 优先根据 SNI 查找
if let Some(server_name) = client_hello.server_name() {
if let Some(cert) = self.certs.read().unwrap().get(server_name) {
return Some(cert.clone());
}
}
// 回退到默认证书
self.default.read().unwrap().clone()
}
}
// 加载证书的辅助函数
async fn load_cert_and_key(
cert_path: &str,
key_path: &str,
domain: &str,
) -> anyhow::Result<Arc<CertifiedKey>> {
let mut cert_file = tokio::fs::File::open(cert_path).await?;
let mut certs_bytes = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut cert_file, &mut certs_bytes).await?;
let cert_chain: Vec<CertificateDer<'static>> = certs(&mut certs_bytes.as_slice())?
.into_iter()
.map(|c| CertificateDer::from(c.to_vec()))
.collect();
let mut key_file = tokio::fs::File::open(key_path).await?;
let mut key_bytes = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut key_file, &mut key_bytes).await?;
let key = pkcs8_private_keys(&mut key_bytes.as_slice())?
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("未找到私钥"))?;
let priv_key = PrivateKeyDer::Pkcs8(key.into());
let signing_key = any_supported_type(&priv_key)?;
Ok(Arc::new(CertifiedKey::new(cert_chain, signing_key)))
}
后台监控与更新
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let resolver = Arc::new(DynamicServerCertResolver {
certs: Arc::new(RwLock::new(HashMap::new())),
default: Arc::new(RwLock::new(None)),
});
// 初始加载
let cert = load_cert_and_key("fullchain.pem", "privkey.pem", "example.com").await?;
*resolver.default.write().unwrap() = Some(cert);
// 启动文件监控(示例)
let (tx, mut rx) = watch::channel(());
tokio::spawn(async move {
let mut watcher = RecommendedWatcher::new(
move |res| {
if let Ok(event) = res {
if event.kind.is_modify() || event.kind.is_create() {
let _ = tx.send(());
}
}
},
notify::Config::default(),
).unwrap();
watcher.watch(std::path::Path::new("certs/"), RecursiveMode::Recursive).unwrap();
});
// 监听变更并重新加载
while rx.changed().await.is_ok() {
if let Ok(new_cert) = load_cert_and_key("fullchain.pem", "privkey.pem", "example.com").await {
*resolver.default.write().unwrap() = Some(new_cert);
println!("服务器证书已热重载");
}
}
// ... 启动 axum-server
Ok(())
}
生产最佳实践
- 原子更新:优先使用
Arc+std::sync::atomic::AtomicPtr或arc-swapcrate 实现完全无锁更新 - SNI 支持:生产环境通常有多个域名,建议使用
HashMap<String, Arc<CertifiedKey>> - 默认证书:始终准备一个自签名或兜底证书,防止 SNI 不匹配时连接失败
- 现有连接不中断:旧连接继续使用旧证书,新连接使用新证书(TLS 标准行为)
- 监控与告警:统计热重载成功/失败次数、延迟(Prometheus)
- 与 ACME 结合:使用 instant-acme 续订成功后,写入新证书文件 → 触发 watcher → 自动重载
- 安全性:私钥文件权限严格控制(600),生产环境建议使用 Vault / KMS 管理私钥
- 测试方法:使用
openssl s_client -connect localhost:443 -servername example.com验证 SNI 和证书切换
总结对比
| 方案 | 复杂度 | 多域名支持 | 文件监控 | 推荐场景 |
|---|---|---|---|---|
| tls-hot-reload | 极低 | 部分支持 | 内置 | 快速上手、单/少量域名 |
| 手动实现 DynamicResolver | 中等 | 完整支持 | 自己实现 | 多域名、从 DB/Vault 加载 |
| rustls-hot-reload | 低 | 视版本 | 内置 | 希望更现代的实现 |
大多数生产项目直接使用 tls-hot-reload 就能满足需求,简单可靠。如果需要更灵活的多域名支持或从数据库加载证书,再考虑手动实现。
有具体场景(Axum / Actix / 结合 instant-acme / 从 Vault 加载)需要更详细代码示例吗?随时告诉我!
版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)