凌晨两点,监控群炸了:用户反馈打开网站直接报”您的连接不是私密连接”,证书已过期。一个本来该自动续期的 Let’s Encrypt 证书,悄悄失效了——而没人收到任何告警。这类故障最阴险的地方在于:它不报警、不报错、不影响进程,只是让 TLS 握手在客户端被浏览器拦下。本文复盘这次事故,给出从发现到根治的完整四步法,帮你把”证书过期”从定时炸弹变成可控项。
事故现场:全站变”不安全”
表现很典型:后端服务、Nginx、数据库一切正常,curl 内网也 200,但只要走公网 HTTPS,Chrome 就弹出红色拦截页,App 接口集体 5xx。最初容易误判成”服务器挂了”去重启,纯属白忙——真正坏掉的是那张 90 天有效期的证书。它和SSL 证书的工作原理绑定:证书一旦到期,服务端虽能完成 TCP 与 TLS 握手的前半段,却在证书校验环节被客户端拒绝。
为什么证书会”悄悄”过期
两类证书:CA 签发 vs 自签
理解它为什么静默,才能对症下药。证书过期不会抛异常、不写错误日志,只是让客户端校验失败——所以只盯进程健康度的监控完全抓不到它。
线上公开服务用的是 CA 签发证书(如 Let’s Encrypt,90 天有效期、可自动续期);内网常偷懒用自签证书(有效期更长但需手动信任)。无论哪种,有效期都是硬截止,不会因为你”忘了”就宽限一天。
自动续期为何失效
Let’s Encrypt 靠 certbot renew 自动续期,但它依赖两件事:① 续期脚本被定时触发(cron 或 systemd timer);② 续期时的 HTTP-01 挑战能临时写通 80 端口校验文件。只要 cron 被改挂、Nginx 把 80 端口重定向到 HTTPS、或磁盘满导致写不进校验文件,续期就会静默失败,而失败邮件常淹没在垃圾箱里。
还有一类容易踩的坑是通配符与多域名:一张 *.example.com 只覆盖一级子域,覆盖不到 a.b.example.com;多域名 SAN 证书则必须确保续期时把所有 alt_name 都带上,否则续出来的证书会缺域名,边缘站点照样报不安全。续期脚本里写死的域名列表,最该纳入变更评审。
排查四步:从浏览器到服务端
别急着重启,先确认是不是证书问题。两步命令即可定性:
# 1) 看远端证书有效期(notBefore / notAfter)
echo | openssl s_client -servername fsdata.site \
-connect fsdata.site:443 2>/dev/null | openssl x509 -noout -dates
# 2) 看握手过程与证书报错
curl -vI https://fsdata.site 2>&1 | grep -i "expire\|SSL certificate"
如果 notAfter 早于今天,基本坐实。注意要用 -servername 带上 SNI,否则多域名虚拟主机可能拿到错误的证书。这一步和Nginx 反向代理配置里的多 server_name 是同一套域名逻辑。
如果本地 openssl 查到没过期、用户却报过期,多半是 CDN 或反代边缘节点缓存了旧证书——这时要顺藤摸瓜查边缘层的证书,而不是只盯源站。证书链路里任何一跳都可能独立过期,排障要沿整条链走一遍。
根因:cron 里的续期脚本静默失败
本次事故的根因很朴素:续期原本挂在一条 cron 上,某次迁移后 cron 服务没起来,certbot renew 再没跑过。等 90 天一到,证书自然过期。定位时直接手动跑续期看报错最直接:
# 先 dry-run 看能不能续(不真正签发)
sudo certbot renew --dry-run
# 确认无误后强制续期
sudo certbot renew --force-renewal
# 看 timer / cron 的执行痕迹
journalctl -u certbot.timer -n 50
systemctl status certbot.timer
常见失败原因集中在两点:HTTP-01 挑战写不通(Nginx 把 /.well-known/acme-challenge 也重定向到了 HTTPS,而那时候证书已坏、形成死循环),以及 webroot 路径配错导致校验文件 404。修好路径、补上 80 端口的明文校验路由,续期立马通过。
更深一层,这次事故暴露的是无人盯续期:既没有续期成功与否的指标,也没有证书剩余天数的告警,全靠运气和用户的抱怨。把证书当成一种会过期的资源来运营,而不是配一次就忘的配置,是认知上的关键转变。
止血与根治
应急先恢复访问,根治再杜绝复发:顺序不能乱,先止血保业务,再从容查根因,避免边救火边改配置引发二次故障。
| 动作 | 目的 | 做法 |
|---|---|---|
| 止血 | 尽快恢复 HTTPS | certbot renew --force-renewal 后立即 reload Nginx |
| 根治-触发 | 别再靠 cron | 改用 systemd timer,失败可重试且能被 journalctl 看到 |
| 根治-监控 | 提前预警 | 监控证书剩余天数,<15 天告警 |
| 根治-校验 | 挑战可达 | Nginx 对 /.well-known/acme-challenge 放行 80 明文 |
# 用 systemd timer 替代脆弱的 cron
# /etc/systemd/system/certbot.timer
[Unit]
Description=Certbot 续期
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
把续期从”藏在 cron 里碰运气”变成”有日志、可重试、失败可见”的 systemd 服务,是这次事故最大的收获。它和服务器安全加固里”让异常可被观测”的思路一脉相承。
防复发清单
- 监控先行:用 Prometheus 或外部拨测监控证书剩余天数,别等浏览器报错才发现。
- 续期可观测:systemd timer 替代裸 cron,失败进 journalctl、进告警。
- 挑战路由放行:Nginx 对
/.well-known/acme-challenge走 80 明文,别被全局 HTTPS 重定向吞掉。 - 多域名逐一核:SNI 下每个 server_name 都要确认证书匹配,别只看默认站点。
- 演练续期:把
certbot renew --dry-run纳入上线检查,确保挑战链路一直通。
小结
证书过期故障的共性,是”静默“二字:不报警、不报错,只在某个清晨让全站 HTTPS 集体失灵。根因往往不是证书机制,而是续期链路脆弱(裸 cron、挑战路由被重定向、无监控)。把续期改成 systemd 定时、给挑战路由放行、对剩余天数做监控,三件套就能把这类事故彻底关进笼子。和Nginx 限流防刷一样,边界层的可靠性,靠的是可观测、可重试、可预警,而不是”应该会自动好”。证书到期从不是小概率事件,而是确定性会发生的时间点——把它排进运维日历,比任何应急预案都管用。




