SSL 证书续期成功,但网站还是挂了
一次证书失效的完整排查,以及 ACME 协议你必须知道的事
证书续期了,但网站还是挂了——因为「续期成功」和「证书生效」根本是两件事。
🎯 问题现象
某天打开 https://example.com,浏览器报红:
您的连接不是私密连接
NET::ERR_CERT_DATE_INVALID
第一反应:证书过期了,但应该有自动续期啊?
检查服务器,acme.sh 记录显示:
Main_Domain CA Created Renew
example.com ZeroSSL.com 2026-01-29T00:42:13Z 2026-03-29T00:42:13Z
1 月 29 日续期成功,下次续期在 3 月 29 日,一切正常。
但 nginx 实际用的证书:
notBefore=Nov 30 00:00:00 2025 GMT
notAfter=Feb 28 23:59:59 2026 GMT ← 已过期
今天是 3 月 2 日。acme.sh 拿到了新证书,nginx 却用了 3 个月前的旧证书。
🔍 排查:acme.sh 的新证书去哪了?
acme.sh 续期后,新证书文件在 ~/.acme.sh/example.com_ecc/ 下:
fullchain.cer ← 更新时间:Jan 29 ✅ 新证书
example.com.key ← 更新时间:Nov 30 (私钥复用,不变)
nginx 的证书目录:
/path/to/nginx/ssl/cert.pem ← 更新时间:Nov 30 ❌ 旧证书,从没被更新过
问题很清楚了:acme.sh 把新证书放在自己的目录里,nginx 根本不知道。
🧠 根本原因:续期 ≠ 生效
acme.sh 的工作分两步,很多人(包括我)只知道第一步:
┌─────────────────────────────────────────────────────┐
│ 步骤一:--issue / --renew(续期) │
│ │
│ acme.sh ←→ ZeroSSL CA │
│ 完成 DNS 验证,拿到新证书 │
│ 存放在:~/.acme.sh/example.com_ecc/ │
│ │
│ ✅ 完成后:acme.sh 里有新证书 │
│ ❌ 但是:nginx 不知道,还在用旧的 │
└─────────────────────────────────────────────────────┘
↓ 需要手动或配置触发
┌─────────────────────────────────────────────────────┐
│ 步骤二:--install-cert(安装) │
│ │
│ 把证书从 ~/.acme.sh/ 复制到 nginx 的 ssl 目录 │
│ 执行 reloadcmd:nginx -s reload │
│ │
│ ✅ 完成后:nginx 加载新证书,HTTPS 恢复正常 │
└─────────────────────────────────────────────────────┘
--install-cert 执行一次后,会把目标路径和 reload 命令保存到 acme.sh 的配置文件里,之后每次续期会自动触发。但如果初始配置时没有正确执行这一步,路径就是空的,续期永远不会安装。
查看 acme.sh 的域名配置文件,确认了这一点:
# 正常应该有值:
Le_RealFullChainPath='/path/to/nginx/ssl/cert.pem'
Le_ReloadCmd='docker exec nginx-proxy nginx -s reload'
# 出问题时是:
Le_RealFullChainPath='' ← 空的!
Le_ReloadCmd=''
⚠️ 为什么没人发现?cron 在静默失败
服务器上 acme.sh 的续期 cron:
41 8 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null
> /dev/null 把所有输出丢掉了。
续期成功了,装证书失败了(因为路径是空的),失败日志丢进了黑洞。没有任何告警,什么都没发生——直到 3 个月后证书过期,用户打开网站才发现。
⚙️ 修复:三步
第一步:执行 install-cert,把路径写入 acme.sh 配置
~/.acme.sh/acme.sh --install-cert -d example.com --ecc \
--fullchain-file /path/to/nginx/ssl/cert.pem \
--key-file /path/to/nginx/ssl/key.pem \
--reloadcmd "docker exec nginx-proxy nginx -s reload || true" \
--renew-hook "/path/to/ssl-monitor/notify-renew.sh"
执行完后,conf 文件里的路径就有了,下次续期会自动安装。
第二步:修复 cron,不再丢弃日志
# 之前(危险)
41 8 * * * acme.sh --cron > /dev/null
# 修复后
41 8 * * * acme.sh --cron >> /var/log/acme-renew.log 2>&1
第三步:加每日验证,从外部探测证书是否真的生效
0 9 * * * /path/to/check-ssl-expiry.sh >> /var/log/ssl-check.log 2>&1
脚本做一件事:用 openssl s_client 从外部连接真实的 443 端口,读取线上实际提供的证书过期时间。如果剩余天数 < 14 天,发邮件告警。
这和检查 acme.sh 的 list 不同——它不信任任何本地文件,直接从用户视角验证。
🧠 顺便搞清楚:ACME 协议和证书信任链
ACME 协议是什么?
ACME(Automatic Certificate Management Environment,RFC 8555)定义了「服务器」和「证书颁发机构(CA)」之间自动化申请证书的标准流程。
没有 ACME 之前,申请 SSL 证书要手动填表、审核、下载、安装,几天到几周。有了 ACME,整个过程几秒钟自动完成。acme.sh 就是实现这个协议的客户端。
常见支持 ACME 的 CA:Let's Encrypt、ZeroSSL(我用的这个)、Buypass。
DNS-01 验证:怎么证明你拥有这个域名?
CA 颁发证书前,需要验证你真的控制那个域名。验证方式有几种,我用的是 DNS-01:
acme.sh 调用 DNSPod API
→ 在 DNS 里加一条 TXT 记录:
_acme-challenge.example.com = "随机验证码xxx"
→ ZeroSSL 去查这条 DNS 记录
→ 查到了 = 证明你控制这个域名
→ 颁发证书
→ acme.sh 自动删掉那条 TXT 记录
DNS-01 的优势:支持通配符证书(*.example.com)。通配符证书一张可以覆盖所有子域名,不需要每个子域单独申请。
另一种常见方式 HTTP-01 不支持通配符,因为你没法在 *.example.com 这个地址上放验证文件。
证书信任链:为什么浏览器会信任你的证书?
浏览器不认识你,但认识 ZeroSSL,通过一条信任链建立信任:
┌──────────────────────────────────┐
│ 根证书(Root CA) │ ← 内置在操作系统和浏览器里,无条件信任
│ "ZeroSSL 根,我说了算" │
└────────────────┬─────────────────┘
│ 签发并背书
┌────────────────▼─────────────────┐
│ 中间 CA 证书(Intermediate CA) │ ← ZeroSSL 颁发,证明"我被根 CA 认可"
└────────────────┬─────────────────┘
│ 签发并背书
┌────────────────▼─────────────────┐
│ 域名证书(End-entity cert) │ ← 你的证书,证明"我是 example.com"
└──────────────────────────────────┘
私钥(.key)← 只有你有,用来解密,绝不公开
浏览器拿到你的域名证书,沿着链条一路往上验证,最终到达它信任的根证书,连接建立。
所以 nginx 需要配置 fullchain.pem(域名证书 + 中间 CA),而不是单独的域名证书——缺少中间 CA 这一层,链条断了,浏览器无法验证,报错。
🚀 事后防御:让"生效"可观测
这次的教训是「续期」和「生效」之间有一个不可见的空隙。把它变成可观测的:
| 监控点 | 手段 | 告警条件 |
|---|---|---|
| 续期结果 | acme.sh --renew-hook 发邮件 | 续期完成后立即通知 |
| cron 日志 | >> /var/log/acme-renew.log | 不再静默失败 |
| 线上证书有效期 | 每日 openssl s_client 探测 | 剩余 < 14 天告警 |
三个监控点覆盖了完整链路,任何一环断掉都能在证书过期前收到通知。
📝 总结
| 认知误区 | 实际情况 |
|---|---|
| acme.sh 续期成功 = 证书生效 | 续期只更新 ~/.acme.sh/ 下的文件,nginx 不感知 |
| cron 跑了就没问题 | > /dev/null 让失败静默,永远不知道出了问题 |
| 验证本地文件就够了 | 必须从外部探测 443 端口,才是用户真实看到的 |
一句话:自动化流程要从终态(用户能正常访问 HTTPS)来验证,而不是从起点(命令执行成功)来验证。