leyizuo的博客

Let's Encrypt 证书为什么会静默过期

证书签发成功不等于会自动续期。梳理三个最常见的断点:定时任务没装、验证方式与凭证不匹配、续期后没有重载服务,以及一套可以照着跑的检查清单。

运维 HTTPS nginx

免费证书的有效期只有 90 天,靠的是自动续期把它变成”配好就不用管”。但自动续期是一条链,链上任何一环断了都不会立刻报错——它只会在几十天后、证书真的过期的那一刻,把整站 HTTPS 一起带走。

更糟的是,过期前没有任何提示。浏览器不会提前警告,服务端日志一片安静,监控如果只探 HTTP 状态码,也照样是 200。

断点一:定时任务根本没装

acme.sh 在安装时会往 crontab 里写一条每日任务,但前提是安装过程正常走完。用 curl | sh 装的时候如果中途报错、或者是在容器里装完又把镜像层丢掉,cron 就不会留下。certbot 走的是 systemd timer,同样可能处于 enabled 但 inactive 的状态——enabled 只说明开机会启动,不代表现在在跑。

两条命令就能确认:

crontab -l | grep acme
systemctl list-timers --all | grep -E 'certbot|acme'

如果两条都是空的,那么无论当初证书签得多成功,都不会有人去续它。

补装很简单:

~/.acme.sh/acme.sh --install-cronjob

装完记得确认 cron 守护进程本身是活的(systemctl is-active crond)。定时任务写进了 crontab、但 crond 没在跑,是另一个同样安静的失败。

断点二:续期时用的验证方式和签发时不一样

这是最隐蔽的一种。签发证书时可以在命令行里临时传 DNS 服务商的 AccessKey,用 DNS-01 完成验证;但 acme.sh 记录续期配置时,如果凭证没有写进 account.conf,续期那一刻它只知道”要用 DNS 验证”,却拿不到任何密钥,于是直接失败。

看一下每个域名自己的配置就能发现:

grep Le_Webroot ~/.acme.sh/example.com_ecc/example.com.conf

Le_Webroot='dns' 意味着续期要走 DNS API,必须有持久化的凭证;Le_Webroot='/var/www/certbot' 则是 HTTP-01 webroot 模式,续期时只要 nginx 还能对外提供那个目录就行,不依赖任何第三方密钥。

只要 80 端口对公网可达,HTTP-01 是更省心的选择——它没有外部凭证这个额外的失效点。切换方式是重新签一次:

~/.acme.sh/acme.sh --issue --force \
  -d example.com -w /var/www/certbot --keylength ec-256

--force 用于覆盖还没到期的证书。签成功后原来的 Le_Webroot 就被改写成 webroot 路径,之后的自动续期不再需要 DNS 凭证。

断点三:证书续了,服务没重载

续期只是把新证书写到 ~/.acme.sh/ 下面。如果 nginx 的 ssl_certificate 指向的是 /etc/nginx/ssl/ 里的副本,而你没有配置安装动作,那么 nginx 会一直用内存里加载的旧证书,直到某次重启——通常也就是过期之后。

--install-cert 负责把这件事固化下来:

~/.acme.sh/acme.sh --install-cert -d example.com --ecc \
  --fullchain-file /etc/nginx/ssl/example.com.crt \
  --key-file      /etc/nginx/ssl/example.com.key \
  --reloadcmd     "nginx -t && nginx -s reload"

这条命令会把路径和 reloadcmd 一起记进该域名的配置里,以后每次自动续期都会重新执行一遍安装和重载。

reloadcmd 里前置一个 nginx -t 是值得的:万一配置在这几个月里被人改坏了,nginx -t 会失败、reload 不会执行,服务继续用旧证书跑,而不是被一个语法错误直接掀翻。

顺带说清楚 SAN

经常有人问:已经有一张 api.example.com 的证书,能不能给裸域 example.com 复用?

不能。浏览器校验的是证书扩展里的 Subject Alternative Name,不是 CN。查一下就知道这张证书到底覆盖哪些域名:

openssl x509 -in example.com.crt -noout -text | grep -A1 "Subject Alternative Name"

只列了 DNS:api.example.com,那它就只对这一个名字有效,用在裸域上会得到 NET::ERR_CERT_COMMON_NAME_INVALID,这是硬失败,配置层面绕不过去。通配符证书 *.example.com 也帮不上忙——通配符只覆盖一级子域,不包含裸域本身。

正确做法是要么单独签一张,要么签一张 SAN 里同时列出所有名字的证书。

检查清单

给一台机器做证书体检,按这个顺序过一遍就够了:

# 1. 每张证书还剩多久
for f in /etc/nginx/ssl/*.crt; do
  echo -n "$f  "; openssl x509 -in "$f" -noout -enddate
done

# 2. ACME 客户端知道哪些证书、下次续期是什么时候
~/.acme.sh/acme.sh --list

# 3. 定时任务在不在
crontab -l | grep acme

# 4. 每张证书的续期方式(dns 还是 webroot)
grep -H Le_Webroot ~/.acme.sh/*/*.conf

# 5. 续期后会不会自动重载
grep -H Le_ReloadCmd ~/.acme.sh/*/*.conf

第 4 步出现 dns 而 account.conf 里没有对应凭证,第 5 步是空的,第 3 步查不到任务——这三种情况都属于”现在看起来一切正常,但到期必挂”。

别只靠清单

清单能查出问题,但查不出”三个月后才发生的问题”。真正稳妥的做法是把证书剩余天数变成监控指标:openssl s_client 从外部探测,剩余天数低于 20 天就告警。这样即使自动续期又因为某个新原因失败了,你也会在证书过期前收到通知,而不是等用户来告诉你网站打不开。