我最早处理"续期失败"这件事,是被一封来自浏览器的警告逼出来的:早上打开自己的站,红叉,证书昨天刚过期。certbot 的日志看起来一切正常,每天凌晨两点都有一行 "Certificate not yet due for renewal"。它确实在跑,也确实没续上。
这种情况在个人站长里非常普遍。Let's Encrypt 的证书只有 90 天,靠自动续期活着,而自动续期的失败方式几乎都是"静默"的:不报错、不告警、不出现在任何你看得见的地方,直到证书过期那天用户打不开页面。这篇文章把我踩过的坑按排查顺序整理一遍,重点是那些"看起来成功了"的假象。
第一件事:区分"计划任务跑了"和"证书更新了"
很多人确认续期正常的依据是"crontab -l 里有 certbot renew"和"日志里没报错"。这两条都不构成证据。真正能说明问题的是证书本身的到期时间:
# 看证书文件里真实的到期时间
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -dates -subject
# 看 certbot 自己记录的状态
certbot certificates
# 看这个文件多久没更新了(超过 60 天就是有问题)
stat -c '%y %n' /etc/letsencrypt/live/example.com/fullchain.pem如果 certbot certificates 显示的到期日一直停在两个月前,那说明续期一次都没成功过,只是每次都被判定为"未到期"。这里有个常见误解:certbot renew 对未到期的证书是直接跳过的,所以 cron 日志每天都干净,问题要到第 61 天才开始暴露。
我的做法是给这个判断加一条硬性告警:每天检查剩余天数,低于 20 天就发通知。别指望"还有 30 天,慢慢来"——你会忘。
为什么 renew 明明跑了却没续上
按我遇到的频率排序:
1. 验证方式与当前架构不匹配
最初签证书时用的是 --nginx 或 --webroot -w /var/www/html,后来网站上了 CDN 或者改了站点根目录,验证请求根本打不到源站。certbot 的报错通常藏在 /var/log/letsencrypt/letsencrypt.log 里,而 stdout 只留一句 "Attempting to renew cert... failed"。
判断方法很直接:手动跑一次强制演练,把失败原因露出来。
certbot renew --dry-run --cert-name example.com 2>&1 | tail -30如果看到 Invalid response from http://example.com/.well-known/acme-challenge/xxx: 404,说明 HTTP-01 的验证文件没被正确提供。可能是 Nginx 里有个 location 把 /.well-known/ 拦了,也可能是站点根目录改了而 webroot 参数还是老路径。
对于挂了 CDN 的站,HTTP-01 天生不可靠——验证请求可能被 CDN 缓存、被 WAF 拦截、或者被回源到一台没有该文件的机器。这种情况应该换 DNS-01。
2. 前置校验失败,但错误被日志吞掉
renew 在真正申请之前会做一些检查:域名是否还指向本机、配置文件是否可读、ACME 账户是否有效。任何一条不过,整个 renew 就跳过这个证书,而且退出码可能仍然是 0。
所以要养成看退出码的习惯。正确写法不是简单地写进 crontab,而是让失败有声音:
# /etc/cron.d/certbot-renew
17 3 * * * root /usr/bin/certbot renew --quiet --deploy-hook "/usr/local/bin/reload-services.sh" || logger -t certbot "RENEW FAILED"同时确认 --quiet 不会把关键错误也一起静音。我一般会保留 --quiet 但把 --deploy-hook 的输出去掉,并且额外用 stat 检查文件时间做第二道保险。
3. 权限与文件锁
certbot 的账户目录在 /etc/letsencrypt/accounts,私钥在 /etc/letsencrypt/archive。如果用 root 跑过一次、后来换成别的用户跑,或者备份恢复时把权限搞乱了,就会在写文件时失败。典型报错是 Permission denied 或 Unable to lock /etc/letsencrypt/.certbot.lock。
第一条排查命令永远是查锁文件:
ls -la /etc/letsencrypt/.certbot.lock
fuser -v /etc/letsencrypt/.certbot.lock 2>/dev/null如果进程早就死了但锁文件还在,删掉即可。如果是两个 cron 同时在跑(比如系统自带的 certbot.timer 和你自己加的 cron 各一份),那是真的会互相卡住——这一点下面单独说。
最大的坑:两套定时任务在打架
Debian/Ubuntu 装 certbot 时,如果同时装了 certbot.timer(systemd),你又手动往 crontab 里加了一条,就会有两个实例在凌晨三点同时申请。表现是:偶尔成功,偶尔失败,日志里出现一堆 Another instance of Certbot is already running。
systemctl list-timers | grep -i certbot
systemctl status certbot.timer --no-pager
crontab -l | grep -i certbot
ls /etc/cron.d/ | grep -i certbot保留一套就够了。我倾向保留 systemd timer,因为它的 OnCalendar 支持随机延迟,能避免"所有人在同一秒请求 Let's Encrypt"造成的限流;而且 journalctl 里的日志比 cron 邮件好查。把 crontab 那条删干净,然后 systemctl enable --now certbot.timer。
附带一个容器环境的问题:如果 certbot 跑在 Docker 里,而 Nginx 跑在宿主机,那么 certbot 写进容器卷里的证书,宿主机的 Nginx 读不到。要么让 Nginx 也进容器并挂同一个卷,要么用 webroot 方式让宿主机 Nginx 直接提供验证文件。
DNS-01 通配符:最容易"看起来成功"的一条路
通配符证书 *.example.com 只能用 DNS-01 验证,因为 ACME 服务器需要查一条 TXT 记录。这条路径的坑不在 certbot,而在 DNS 服务商的 API 与 TTL。
最典型的现象:certbot 一直卡在 "Waiting for verification",最后超时。原因通常是三种之一:
- API 凭据权限不足,只加了记录但没生效(部分服务商需要额外的 zone 权限)。
- TTL 设得太长,验证服务器查到了旧的 NXDOMAIN 并缓存了。
- 用了多台权威 NS,其中一台的更新有延迟,验证正好打到那台上。
排查方式是手动验证 TXT 是否可见,以及从多个公共解析器分别查:
dig +short TXT _acme-challenge.example.com @1.1.1.1
dig +short TXT _acme-challenge.example.com @8.8.8.8
dig +short TXT _acme-challenge.example.com @223.5.5.5三个结果不一致,就是权威同步问题,跟 certbot 无关。这时候加 --dns-cloudflare-propagation-seconds 120 之类的等待参数比改任何配置都有效。
续上了却没生效:deploy hook 才是最后一公里
除了前面三类,还有一种"续期成功但证书没换"的隐蔽情形:certbot 走的是 relative path 之外的符号链接,而你的 Nginx 配置里写的是 archive/example.com/cert3.pem 这种带编号的具体文件。certbot 每次续期会新建 cert4.pem,然后把 live/ 下的软链指过去——如果你绕过了 live/,Nginx 就永远读那张已经过期三天的旧文件。我在一台跑了两年的老机器上就中过这一枪,日志里 renew 全绿,浏览器天天报过期。判断方法只有一个:openssl s_client 拿到的指纹,跟 live/fullchain.pem 的指纹比对,不一致就是路径写错了。
这是我最想强调的一段。证书文件在磁盘上更新了,但 Nginx 不会自己重载——它还握着旧证书在内存里。所以你会看到"certbot 说 Success,浏览器还说证书过期"。
# 手动确认 Nginx 实际提供的是哪张证书
echo | openssl s_client -servername www.example.com -connect 127.0.0.1:443 2>/dev/null \
| openssl x509 -noout -dates
# 正确做法:deploy hook 里 reload
certbot renew --deploy-hook "systemctl reload nginx"注意是 reload 不是 restart。reload 只让 worker 重新加载配置,不断开现有连接;restart 会有一瞬间丢连接,对高并发站就是可观测的 502。
还有一种"renew 成功但这个域名没更新"的情况:cert 名称与域名不一致。一台机器上签了多个证书时,certbot renew 会遍历所有 lineage,某一个失败不影响其他,但输出很啰嗦,容易被忽略。用 certbot certificates 逐个核对 Expiry 最稳妥。
如果证书是通过 --cert-name 管理多个域名的,reload 之后还要确认 Nginx 配置里 ssl_certificate 指向的确实是 live/ 下的路径(而不是某个 archive/ 下的具体编号文件——那是最危险的做法,编号一变就指向旧证书)。
一套每天自动跑的巡检脚本
我最终的方案不复杂,核心是"不信任任何一次 success,只信任日期":
#!/bin/bash
# /usr/local/bin/cert-check.sh —— 剩余天数低于 20 天报警
set -u
WARN=20
ALERT=""
for f in /etc/letsencrypt/live/*/fullchain.pem; do
[ -f "$f" ] || continue
name=$(basename "$(dirname "$f")")
end=$(date -d "$(openssl x509 -in "$f" -noout -enddate | cut -d= -f2)" +%s)
days=$(( (end - $(date +%s)) / 86400 ))
echo "$name: ${days} days"
[ "$days" -lt "$WARN" ] && ALERT="$ALERT $name(${days}d)"
done
[ -n "$ALERT" ] && echo "证书即将过期:$ALERT" | mail -s "cert alert" me@example.com还有一类容易漏掉的失败:机器上装了防火墙或安全组,只放行了 80/443 的入站,而 certbot 做 HTTP-01 时需要能被外部访问到 /.well-known/acme-challenge/。看起来是同一个 80 端口,但如果前面套了 CDN、或者 Nginx 有一层强制跳转 HTTPS 的规则,验证请求在跳转过程中就丢掉了 challenge 文件,返回一个 301 到 https 页面,ACME 服务器照样判定失败。修法是在跳转规则里为这个路径留一个例外,或者直接换 DNS-01 省掉整条链路。
再加一条:脚本每天检查 fullchain.pem 的 mtime,如果超过 75 天没变,也报警。两条独立判据,一条看证书内容,一条看文件时间,比任何单点检测都可靠。与此同时,把 certbot 自身的日志级别调到 --debug 只在排查时使用,日常保持默认,避免日志本身把磁盘写满——一个日志写满磁盘导致 certbot 无法写临时文件的案例,我见过不止一次。
小结
续期这件事,真正的风险在于"你以为什么都没发生",失败几乎不会以报错的形式出现。我现在的检查清单是四句:DNS-01 优先于 HTTP-01(尤其挂了 CDN 之后);只保留一套定时任务,别让 timer 和 cron 打架;deploy hook 一定要 reload,且配置只引 live/ 路径;每天看剩余天数,不看日志里有没有报错。
还有一个习惯值得养成:每季度挑一台机器,故意把系统时间往前拨(或在测试机上新签一张 3 天的短期证书),完整走一遍"快过期→自动续→reload→浏览器验证"的链路。演练过一次,真出事时就不会手忙脚乱。