技术指南

TLS 证书续期:验证链、热重载并保留回滚

把证书签发、候选校验、Nginx 激活与公网验收拆开,完成可观测、可回滚的 TLS 续期。

证书客户端提示“续期成功”,只证明 ACME 流程拿到了文件;它没有证明证书覆盖目标域名、私钥匹配、链条完整、Nginx 已载入新文件,或外部用户真正拿到了新证书。可靠续期应把签发、候选校验、进程激活和外部验收拆成四个关口,并在切换前保存可立即恢复的旧版本。

先定义四个成功状态

状态最低证据常见误判
签发ACME 客户端本轮成功且文件已落盘计划任务退出 0 就认为证书已上线
候选域名、期限、链与私钥均匹配只看文件名或 notAfter
激活配置测试通过,服务完成受控 reload修改软链接就认为进程自动重读
外部真实入口完成信任链与主机名校验,指纹等于候选只从本机读取磁盘文件

盘点域名、监听与证书所有者

把 example.com 替换为目标 lineage;确认 443 的真正终止点可能是 CDN、负载均衡器、容器或本机 Nginx
date --utc --iso-8601=seconds
openssl version
nginx -V 2>&1 | head -n 2
certbot --version 2>/dev/null || true
certbot certificates 2>/dev/null || true
ss -lntp '( sport = :443 )'
systemctl list-timers --all | rg -i 'certbot|acme' || true
readlink -f /etc/letsencrypt/live/example.com/fullchain.pem

一张证书可能服务多个 SAN,一个域名也可能在 CDN、边缘负载均衡与源站分别终止 TLS。先列出每个公网主机名、终止点、证书客户端、配置引用和 reload 所有者。若外部终止在 CDN,本机证书更新不能证明边缘已更新;若多个进程共用文件,也不能让每个客户端各自覆盖。

把候选文件当作不可变版本检查

1209600 秒是 14 天;系统信任目录按发行版调整,verify 必须输出 OK 并返回 0
lineage=/etc/letsencrypt/live/example.com
openssl x509 -in "$lineage/cert.pem" -noout \
  -subject -issuer -serial -fingerprint -sha256 -dates \
  -ext subjectAltName
openssl x509 -in "$lineage/cert.pem" -noout -checkend 1209600
openssl verify -CApath /etc/ssl/certs \
  -untrusted "$lineage/chain.pem" \
  -purpose sslserver -verify_hostname example.com \
  "$lineage/cert.pem"

`x509 -checkend` 会在证书将在阈值内到期时返回非零,适合新鲜度门禁。主机名则使用 `openssl verify -verify_hostname` 作为门禁:本批在 OpenSSL 3.0.13 上观察到 `x509 -checkhost` 对错误主机名打印 mismatch 却仍返回 0,因此不能只凭它的退出状态做自动化判断。

比较公钥,不输出私钥内容

只比较从证书与私钥导出的公钥;命令失败或哈希不同都应停止发布
cert_pub=$(openssl x509 -in "$lineage/cert.pem" -pubkey -noout \
  | openssl pkey -pubin -outform DER 2>/dev/null \
  | sha256sum | cut -d' ' -f1)
key_pub=$(openssl pkey -in "$lineage/privkey.pem" -pubout -outform DER 2>/dev/null \
  | sha256sum | cut -d' ' -f1)
test "$cert_pub" = "$key_pub"

明确 leaf、chain 与 fullchain

Nginx 的服务证书文件应按当前官方配置与客户端输出使用正确顺序的完整链;只部署 leaf 可能让部分客户端因缺少中间证书失败。Certbot 的 lineage 通常分别提供 `cert.pem`、`chain.pem`、`fullchain.pem` 与 `privkey.pem`。不要把根证书随服务链发送,也不要把文件名相似当作内容正确;用 `openssl verify` 和外部握手验证实际结果。

先测试配置,再让进程重读

在目标部署方式中使用对应的受控命令;systemd 管理的实例可使用发行版规定的 reload 入口
nginx -t
nginx -s reload

Nginx 官方说明,收到 HUP 后 master 会先检查语法并尝试应用新配置;失败时继续使用旧配置,成功时启动新 worker 并让旧 worker 优雅退出。这个保护仍不能发现证书选错、域名遗漏或外部终止点未更新,因此 reload 返回成功只是激活关口,不是最终验收。

从真实入口验证主机名、链和指纹

必须看到 Verification: OK;从至少一个独立网络重复,并确认连接到预期 IPv4/IPv6 或边缘节点
host=example.com
openssl s_client \
  -connect "$host:443" \
  -servername "$host" \
  -verify_hostname "$host" \
  -verify_return_error \
  -showcerts </dev/null

显式传 `-servername` 避免多站点服务返回默认证书;`-verify_hostname` 检查名称;`-verify_return_error` 让验证错误中止握手。OpenSSL 文档提醒,缺少后者时 `s_client` 为了诊断可能在验证错误后继续,而 `-showcerts` 只展示服务器发送的列表,并不等于链已验证。

先单独完成可信握手,再比较服务端发出的 leaf 指纹;管道本身不是信任链验证
candidate=$(openssl x509 -in "$lineage/cert.pem" -noout -fingerprint -sha256)
served=$(openssl s_client -connect example.com:443 -servername example.com \
  -verify_hostname example.com -verify_return_error -showcerts </dev/null 2>/dev/null \
  | openssl x509 -noout -fingerprint -sha256)
printf 'candidate %s\nserved    %s\n' "$candidate" "$served"
test "$candidate" = "$served"

回滚证书版本,也重新走一遍验收

  1. 保留旧证书目录、私钥权限、配置版本和已记录指纹,不在新版本通过前删除。
  2. 验证失败时恢复旧的证书引用或原子软链接,运行 `nginx -t` 后 reload。
  3. 从真实入口重新完成主机名、信任链与旧指纹比对;仅看到服务进程 active 不算恢复。
  4. 记录失败发生在签发、候选、激活还是外部关口,再决定修复链、域名、权限、终止点或自动化。

让 Certbot 的 deploy hook 只在续期成功后运行

Certbot 官方文档区分 pre、post 与 deploy hook:deploy hook 只在证书成功签发或续期后运行。`certbot renew` 在没有证书到期时也可能返回 0,因此不能把 renew 的退出码当作“本轮已更新并应 reload”的信号;需要激活动作时放进可独立测试、失败即非零的 deploy hook。

dry-run 默认不运行 deploy hook;加 --run-deploy-hooks 后使用当前活动证书,而不是测试服务器签发的临时证书
certbot renew --dry-run
# 修改 deploy hook 后,另行测试 hook 脚本的候选校验与 reload 路径
certbot renew --dry-run --run-deploy-hooks

`--dry-run` 使用测试服务且不把测试证书保存到磁盘。官方文档明确说明,`--run-deploy-hooks` 只在 dry run 成功后运行适用 hook,并把当前活动证书交给 hook;因此它能检查 hook 的控制流,却不能证明临时测试证书本身已被部署。首次真实续期后仍要完成外部指纹验收。

监控外部服务的剩余期限,而不只看磁盘

外部探测应覆盖每个真实主机名和终止点,并保留连续失败、剩余天数与最后成功时间
openssl s_client -connect example.com:443 -servername example.com \
  -verify_hostname example.com -verify_return_error </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -serial -dates

# 本地文件阈值门禁:14 天内到期会返回非零
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem \
  -noout -checkend 1209600

磁盘证书、Nginx 进程内证书和公网入口可能是三个不同版本。告警至少覆盖外部握手失败、主机名不匹配、剩余期限低于阈值、served 指纹长期不等于当前候选,以及续期任务/部署 hook 的失败。一次超时应先重试和分区判断,不能自动归因于证书。

通配符与 DNS-01 另设凭据边界

通配符证书需要 DNS-01,但不要用固定 sleep 假设全球传播完成,也不要给自动化 Token 整个账号权限。选择支持查询/传播检查的 DNS 插件,限定可写区域与记录,避免 Token 进入 shell history、进程列表和日志。签发完成后仍执行同样的候选、激活和外部验收;通配符不会覆盖裸域以外的所有层级。

把 HSTS 与证书续期分开变更

HSTS 是浏览器缓存的强制 HTTPS 策略,不是证书部署是否成功的证明。只有在所有子域、重定向、证书续期与应急入口长期稳定后,才单独评估 max-age、includeSubDomains 和 preload;先用较小范围验证。证书故障时临时改回 HTTP 不能绕过已缓存的 HSTS。

本次隔离演练验证了什么

VPScope 在 Ubuntu 24.04 上使用 OpenSSL 3.0.13 与 Nginx 1.24.0,生成一套临时 CA、两张同域名证书和一张错误域名证书,并让独立 Nginx prefix 只监听 `127.0.0.1:18443`。正确链、主机名、期限阈值与公钥匹配均通过;错误信任根、错误主机名、错误私钥和三天阈值均被拒绝。

演练先让 Nginx 服务版本 1,记录公网侧 leaf 指纹;切换不可变软链接、通过 `nginx -t` 并 reload 后,服务指纹变为版本 2;恢复旧链接并再次 reload 后,指纹精确回到版本 1。进程、PID 与临时文件随后全部清理。演练没有访问真实 ACME 账户、DNS API、生产域名、CDN 或公网 IPv6,这些仍要在目标环境逐项验收。

续期上线的闭环验收

  • 每个域名、TLS 终止点、证书客户端、配置引用和 reload 所有者均已登记。
  • 候选证书的 SAN、期限、信任链和私钥公钥全部通过失败即停止的校验。
  • 旧证书版本、配置和指纹仍可用,回滚不会覆盖其他服务的证书。
  • Nginx 配置测试通过并受控 reload,失败时旧配置继续服务。
  • 真实入口以 SNI、主机名和信任链完成外部验证,served 指纹等于候选。
  • Certbot dry run 与 deploy hook 语义分别测试,没有把 renew 的 0 误当成已更新。
  • 监控观察公网证书而不只看磁盘,并覆盖期限、验证、指纹和自动化失败。
  • DNS Token、私钥与日志边界已收紧;HSTS 作为独立、可审查的变更处理。

返回知识库