SSL 证书续期成功,但网站还是挂了

一次证书失效的完整排查,以及 ACME 协议你必须知道的事

March 2, 2026·4 min read·Yimin
#SSL#acme.sh#Nginx#运维#HTTPS

证书续期了,但网站还是挂了——因为「续期成功」和「证书生效」根本是两件事。


🎯 问题现象

某天打开 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)来验证,而不是从起点(命令执行成功)来验证。