🦀 Rustls 热重载:证书秒换零停机,连接不断流

Photos provided by Unsplash OR Pexels

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>(证书链 + 私钥签名对象)

要实现热重载,需要:

  1. 证书存储在 Arc
  2. 当证书更新时,原子替换这个 Arc
  3. 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(())
}

生产最佳实践

  1. 原子更新:优先使用 Arc + std::sync::atomic::AtomicPtrarc-swap crate 实现完全无锁更新
  2. SNI 支持:生产环境通常有多个域名,建议使用 HashMap<String, Arc<CertifiedKey>>
  3. 默认证书:始终准备一个自签名或兜底证书,防止 SNI 不匹配时连接失败
  4. 现有连接不中断:旧连接继续使用旧证书,新连接使用新证书(TLS 标准行为)
  5. 监控与告警:统计热重载成功/失败次数、延迟(Prometheus)
  6. 与 ACME 结合:使用 instant-acme 续订成功后,写入新证书文件 → 触发 watcher → 自动重载
  7. 安全性:私钥文件权限严格控制(600),生产环境建议使用 Vault / KMS 管理私钥
  8. 测试方法:使用 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许可证)