证书明明"自动续期"了,为什么还是到期宕机:一次 reload 缺失引发的复盘
一次典型的证书到期故障:ACME 续期流程本身跑通了,新证书已经躺在磁盘上,但 Nginx 从未 reload,线上跑的还是旧证书。复盘症状、排查路径和三层预防措施。
本文目录

凌晨的告警来得很有迷惑性:监控显示站点 TLS 握手失败,但运维同事第一反应是"不可能,我们配了自动续期"。登录服务器一看,/etc/letsencrypt/live/ 下的证书确实是新的,有效期还有八十多天。Certbot 日志显示续期在三天前成功执行。一切看起来都很健康——除了用户打不开网站。
症状:磁盘上是新的,内存里是旧的
用 openssl s_client -connect example.com:443 -servername example.com 从外部验证,返回的证书 notAfter 是昨天。再用 openssl x509 -in fullchain.pem -noout -dates 看磁盘上的文件,有效期明明还有很久。

两个结果对不上,结论就基本锁定了:提供服务的进程还在用旧证书。
Nginx 只在启动和 reload 时读一次证书文件,之后证书内容就常驻在 worker 进程的内存里。文件被 ACME 客户端悄悄换掉,进程毫不知情。旧证书到期那一刻,握手开始失败——这就是典型的"续期成功但部署没完成"。
根因:renew 钩子的最后一公里
排查续期配置,问题出在最常见的地方:renewal 配置里只写了签发参数,没有 deploy hook,或者 hook 里只有 nginx -s reload 之外缺了执行权限、路径写错,再或者之前某次重构把 hook 文件挪走了而没人发现。

Certbot 官方建议用 --deploy-hook 或 /etc/letsencrypt/renewal-hooks/deploy/ 目录放重载脚本,因为它只在"证书真的换了"时执行,比无脑每次 reload 干净。这次的配置里 deploy hook 存在,但脚本里写的是 systemctl reload nginx,而 systemd unit 改名过一次,命令静默失败,exit code 没人看。
这里有个小坑值得记住:静默失败比报错更可怕。如果续期直接失败,至少 Certbot 会退出非零状态,cron 可能发邮件。但"续期成功、部署失败"这种状态,文件系统层面一切正常,只有到期那天才爆出来,留给你的反应时间是零。
排查路径
当时实际走的排查顺序,供参考:

- 外部
s_client拿到线上实际提供的证书指纹和日期——确认不是 DNS 或 CDN 层的错觉。 - 磁盘上证书文件的日期和指纹——和线上一比对,不一致,排除签发问题。
nginx -T | grep ssl_certificate确认配置指向的路径和续期输出路径是同一个(软链指向/etc/letsencrypt/live/,没走错)。- 翻 Certbot 的 renew 日志和 deploy hook 执行记录,发现 hook 命令返回了 unit 不存在的错误。
- 手动 reload,握手立刻恢复,验证新证书已生效。
整个过程从告警到恢复不到半小时,但暴露的问题比故障本身更值得修。
预防:三层,别只修一处

只修好那个 reload 命令是不够的,因为同类问题有一百种姿势复发。
第一层,把续期和部署绑成一个动作。 续期工具必须原生支持"签发成功后自动执行部署",并且部署失败要让整个流程返回非零状态,能被 cron 或 systemd 的失败通知机制接住。续期和部署拆成两个互不感知的任务,迟早对不上。
第二层,监控实际提供的证书,而不是文件。 从外部探测 TLS 握手返回的 notAfter,剩余天数低于阈值(比如 14 天)就告警。这一条能兜住几乎所有"续期链路某环断了"的故障,包括这次这种。监控磁盘文件的 mtime 只是辅助,它证明不了线上在用什么。
第三层,续期后做主动验证。 hook 里 reload 完之后,顺手用 s_client 验证一次指纹是否变成新证书的,不对就告警。多十行脚本,省一次凌晨爬起来。

在 CertbotX 中实践
CertbotX 把上面第一、三层做成了默认行为:ACME 签发成功后直接进入部署阶段,支持推送到 Agent、EdgeOne 或 CDN,部署结果有记录可查,而不是"脚本跑没跑成功靠猜"。到期前会自动重新签发并再次走完整部署链路,每次部署留下审计记录——排查"线上到底装的哪张证书"时,直接翻记录比登机器对比指纹快得多。