跳转至

一次证书到期未被监控的完整复盘

这一篇复盘「站点证书到期,浏览器红牌,续期失败却没人发现」的典型故障。你的证书可能是别的 CA、可能是单域名,但诊断链路和坑位通用:先确认到底过期没有,再复现续期为什么失败,最后把「到期」和「续期失败」都变成监控对象。

故障概述

周一早高峰,用户反馈站点浏览器直接红牌:NET::ERR_CERT_DATE_INVALID,证书已过期。奇怪的是昨天还好好的——Let's Encrypt 本来 90 天自动续期,理论上不该过期。用 openssl s_client 一看,notAfter 显示证书 3 天前就已到期,也就是说续期失败发生了至少 3 天,期间没有任何监控发现。

影响

  • 所有 HTTPS 访问被浏览器拦截,用户直接看到「连接不安全」红牌。
  • HTTP 仍能访问(如果服务端保留 80 端口),但业务几乎全走 HTTPS,等于不可用。
  • 持续约 2 小时,从发现到重新签发证书并刷新 CDN。
  • 因为站点本身「可达」(首页能返回),外部可达性监控显示正常,没有任何告警。
  • 附带损失:这 2 小时里的搜索/收录访问被浏览器拦截,搜索引擎爬虫同样遇到证书错误,对排名有隐性影响。

时间线

时间 事件
D-90 上次续期成功,证书到期日设为 D 日
D-45 正常续期窗口(Let's Encrypt 30 天续期期),应已触发续期
D-3 证书实际到期(续期静默失败,无告警)
D+0 08:30 用户反馈浏览器红牌,开始排查
D+0 09:00 openssl s_client 确认 notAfter 已过期
D+0 09:20 certbot renew --dry-run 复现失败,定位到 HTTP-01 challenge 被拦
D+0 10:00 修 challenge 路径,certbot renew --force-renewal 重新签发
D+0 10:30 刷新 CDN 缓存,HTTPS 恢复,补到期告警

根因

真正的触发点是续期任务在「校验成功」前一步静默失败:该域名的 ACME HTTP-01 challenge 路径 /.well-known/acme-challenge/ 被 CDN/WAF 规则拦截,Let's Encrypt 校验请求拿不到 token,续期失败。叠加两个放大器:

  1. 续期失败被静默吞掉:续期任务(certbot 的 systemd timer)每次失败只是记日志,没有退出码监控、没有失败告警。失败持续了 45 天都没人知道。
  2. 无证书到期监控:只监控「站点可达性」,没监控「证书有效期」。站点照样 200,所以到期前完全没信号。

关键判据(也是复盘里的分水岭):openssl s_client 显示 notAfter 已过期,而 certbot renew --dry-run 能复现同样的 challenge 失败——问题在续期链路的验证环节,不是证书本身。

诊断里最容易踩的几个坑

这次拖慢恢复的不是「查不到」,而是把「站点可达」当成了「站点健康」。几个真实教训:

  1. 先确认到底过期没有。浏览器红牌也可能来自证书链不完整、域名不匹配、中间证书缺失,未必是「过期」。用 openssl s_client -servername 解析 notAfter 对比当前时间,别拿浏览器红牌当结论。
  2. 不带 -servername 会看错证书。在 CDN/共享 IP 上,不带 SNI 会拉到默认站点的证书,得出「没到期」的错误结论。排查域名证书必带 -servername。
  3. certbot renew 不是续期动作,是检查动作。renew 只在到期前 30 天内才真正续期,平时跑它不做事。排查要用 renew --dry-run 复现完整校验流程,才知道失败在哪一步。
  4. 可达性 200 ≠ 证书有效。站点能访问不代表 HTTPS 健康。监控要分「服务可达」和「证书有效」两层,不能混为一个探针。
  5. 续期失败要看退出码。certbot 失败可能只是打日志、退出码非 0,系统不报错。把续期任务的退出码变成告警信号,失败当天就该知道。

修复

一步步定位,从确认到期开始:

# 1) 确认到底过期没有(必须带 -servername,否则在 CDN 上会拉错证书)
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -dates
# notAfter 与当前时间比对;已过期 → 确认到期

# 2) 看所有证书的续期状态
certbot certificates
# 每条证书的 Expiry Date / Renew 状态一目了然

# 3) 复现续期失败,定位在哪一步
certbot renew --dry-run
# 看日志:HTTP-01 还是 DNS-01 失败

# 4) 如果是 HTTP-01,探一下 challenge 路径是否可达
curl -I http://example.com/.well-known/acme-challenge/test
# 被 CDN/WAF 拦截 → 放行该路径;404 且没被 CDN 拦 → 检查 Web 服务器配置

临时止血:先手动续期,修好 challenge 路径后立刻重签:

# 放行 HTTP-01 路径(CDN/WAF 规则 + Web 服务器 location)
# 以 nginx 为例:
# location ^~ /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
# 放行后手动续期
certbot renew --force-renewal
# 验证新证书已签发
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -dates

彻底解决:补两层监控 + 盯续期退出码。一是到期告警,二是续期失败告警:

# 到期前 30/14/7 天告警(黑盒 SSL 监控思路)
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -enddate \
  | cut -d= -f2 | while read -r d; do
      let days=($(date -d "$d" +%s)-$(date +%s))/86400
      echo "CERT EXPIRES IN $days DAYS (example.com)"
      [ "$days" -le 30 ] && echo "ALERT: cert expiring" | mail -s "Cert alert" [email protected]
    done

防再发

这次事故暴露的三件事,逐条上护栏:

  1. 证书到期监控(第一护栏):不只监控站点可达,要监控证书有效期。用 openssl s_client 或黑盒 SSL 探针(如 blackbox_exporter 的 tls 探针)定期解析 notAfter,到期前 30/14/7 天分级告警。
  2. 续期失败监控:加定时任务跑 certbot renew --dry-run 并检查退出码,非 0 即告警;这样在「到期」之前几周就能发现续期链断了。
  3. challenge 路径放行与回归:给 HTTP-01 路径做放行规则后,纳入自动化回归(定期 curl 该路径验证 200),防 CDN/WAF 规则变更再次拦截。
  4. 证书看板:certbot certificates 定期汇总成看板,列出每张证书的到期日/剩余天数,一眼看出哪张快到期。
  5. 故障演练:定期模拟「续期失败」,验证到期告警和续期失败告警都能在预期时间内触发。

复盘结论

「浏览器红牌」先确认到底过期没有——用 openssl s_client -servername 解析 notAfter,别拿浏览器提示当结论。诊断顺序永远是:确认到期 → certbot certificates 看状态 → renew --dry-run 复现失败 → 修 challenge 路径。最容易被忽略的是 「续期失败被静默吞掉」:certbot 失败只打日志不报错,没有退出码监控就没人知道。

复盘到最后,最值钱的一句话是:监控的对象错了,反应就永远慢半拍。 我们以前「监控站点可达」,它只在浏览器红牌时才有信号;这次之后我们「监控证书有效期 + 续期退出码」,它能在到期前几周就把问题暴露出来。把监控前移一层,往往比多写几段告警规则更能缩短 MTTR——这也是这次事故给我最大的收获:不是技术多难,而是把「该看的地方」看对。


关联阅读:[SSL/TLS 握手排障../../network/ssl-tls-handshake-troubleshoot.md) · [DNS 故障排查../../network/dns-troubleshoot.md) · [CDN 原理../../network/cdn-principles.md)