证书到期才发现:certbot续期静默失效排查

证书突然过期:多半是续期静默失败了

Let's Encrypt 的证书有效期只有 90 天,靠 certbot 自动续期。理论上装好之后就不用管了,但我在自己几台服务器上都遇到过「某天早上 HTTPS 打不开」,查下来都不是证书申请失败,而是续期任务在某次系统升级后静默停止工作了——没有任何告警,直到第 90 天浏览器弹出红色警告。

这篇文章整理三类最常见的静默失效原因,以及一套能在证书过期前 21 天就发现问题的检查脚本。适用场景是 Nginx + certbot(也有少量 certbot 挂在 Apache 上),Debian/Ubuntu 系统。

先搞清楚 certbot 是怎么被调起的

不同安装方式决定了续期任务放在哪里,这是排查的第一步。三条命令分别对应三种安装:

# 1) 系统包安装(apt install certbot)→ 由 systemd timer 驱动
systemctl list-timers | grep -i certbot
systemctl status certbot.timer
cat /etc/cron.d/certbot 2>/dev/null

# 2) snap 安装(官方推荐)→ 也是 timer,但服务名不同
systemctl list-timers | grep -i snap.certbot

# 3) 自己写的 crontab
crontab -l | grep -i certbot

如果三处都查不到,说明续期任务压根不存在——很多教程让你「手动跑一次 certbot certonly」就结束了,从来没装过 timer。这种情况证书 90 天后必然过期。

如果 timer 存在,看它的状态和上次执行时间:

systemctl status certbot.timer --no-pager
systemctl list-timers certbot.timer --all
journalctl -u certbot.service --since "30 days ago" --no-pager | tail -50

systemctl list-timers 输出里的 NEXT 和 LAST 两列很关键。如果 LAST 显示的是 80 天前,那这个 timer 早就没在跑了。常见原因是 timer 被 systemctl disable 过(有些安全加固脚本会批量禁用非必要的 timer),或者 unit 文件里的 ConditionACME 之类条件不满足导致每次触发都跳过。

原因一:HTTP-01 验证的 webroot 路径被换了

这是最常见的一类。certbot 用 HTTP-01 验证时,会在网站根目录下写一个 /.well-known/acme-challenge/ 文件,然后让 Let's Encrypt 的服务器来访问。如果这个路径访问不到(404 或 301 到别处),续期就会失败。

哪些改动会破坏它:

  • 站点从 /var/www/html 迁到别的目录,但 /etc/letsencrypt/renewal/*.conf 里的 webroot_path 没改;
  • Nginx 里加了 location ~ /\. 的规则,把点开头的路径全部 403——这是很常见的安全加固写法,但它顺手封掉了 acme-challenge;
  • 站点加了强制 HTTPS 跳转,而验证请求走的是 http,301 之后 Let's Encrypt 不一定跟随;
  • 加了 CDN,源站验证文件被 CDN 缓存成了 404。

有一个几秒钟就能判断的方法:手动做一次 dry run,它会真的走验证流程但不消耗配额。

certbot renew --dry-run --cert-name example.com

输出里如果出现 Timeout during connect 或 Invalid response ... 404,就是路径问题。看到 Congratulations, all simulated renewals succeeded 才说明续期链路是通的。

如果要确认具体是哪条 nginx 规则在拦,可以在验证时手工放一个测试文件:

mkdir -p /var/www/html/.well-known/acme-challenge
echo ok > /var/www/html/.well-known/acme-challenge/test
curl -sI "http://example.com/.well-known/acme-challenge/test" | head -3
# 期望 200;返回 301/403/404 就说明有规则挡路

确认后有两种修法:一是在 nginx 里给这个路径单独开白名单,放在所有 location 之前:

location ^~ /.well-known/acme-challenge/ {
    root /var/www/html;
    default_type "text/plain";
    try_files $uri =404;
}

二是改用 DNS-01 验证,完全绕开 webroot 问题。代价是需要域名服务商支持 API,且要装对应的 certbot 插件。

原因二:renewal 配置文件里的 authenticator 变了

certbot 每次成功签发都会在 /etc/letsencrypt/renewal/域名.conf 里记录当时用的验证方式。如果你后来手动改过这个文件,或者从 standalone 模式换成了 webroot 模式,续期时会按旧配置执行,然后失败。

grep -E 'authenticator|installer|webroot_path|server' \
  /etc/letsencrypt/renewal/example.com.conf

典型症状是续期时报 Problem binding to port 80: Could not bind to IPv4 or IPv6。这说明配置里还是 authenticator = standalone,而 80 端口已经被 Nginx 占着。standalone 模式在首次签发时能成功,如果当时你恰好停了 Nginx;但续期时 Nginx 正在跑,就必然冲突。

修法是用 certbot certonly --webroot -w /var/www/html --cert-name example.com 重新签发一次,certbot 会把新的 authenticator 写进配置文件,以后续期就正常了。或者在续期时加 --pre-hook "systemctl stop nginx" 和 --post-hook "systemctl start nginx",但这样每次续期网站都会中断几十秒,不推荐。

原因三:过了速率限制或被上游拒了

Let's Encrypt 有速率限制:同一注册域名每周 50 张证书,同一组完全相同的域名集合每周 5 张。如果你在调试过程中反复运行 certbot,很容易打满「相同域名集合」这个限制,然后接下来一周的续期全部失败,错误信息是 too many certificates already issued for exact set of domains。

这个错误在 journalctl 里能查到,但如果你从来不查日志,就只会看到证书过期。查法与判断:

journalctl -u certbot.service --since "60 days ago" --no-pager \
  | grep -iE 'error|failed|too many|rate' | tail -20

另外还有一种少见的:ACME 账户的注册邮箱失效,或者账户私钥被误删(/etc/letsencrypt/accounts/),续期时报 Account not found。这种情况需要重新 certbot register,或者干脆删掉 /etc/letsencrypt/accounts 让它重新注册。

过期前 21 天就该知道的检查脚本

与其等过期,不如每天检查一次所有证书的剩余天数。certbot 自带 certificates 子命令,输出里就有到期日:

certbot certificates 2>/dev/null | grep -E 'Certificate Name|Expiry Date'

但如果 certbot 本身坏了(比如 python 依赖被系统升级破坏),这条命令也会失败。更稳的做法是直接读证书文件,用 openssl 算剩余天数——不依赖任何第三方工具:

#!/bin/bash
# /usr/local/bin/check-certs.sh
THRESHOLD=21
ALERT=""
for f in /etc/letsencrypt/live/*/fullchain.pem; do
  [ -e "$f" ] || continue
  name=$(basename "$(dirname "$f")")
  end=$(openssl x509 -enddate -noout -in "$f" | cut -d= -f2)
  end_ts=$(date -d "$end" +%s)
  now_ts=$(date +%s)
  days=$(( (end_ts - now_ts) / 86400 ))
  echo "$name: ${days}d"
  if [ "$days" -lt "$THRESHOLD" ]; then
    ALERT="$ALERT\n$name 剩余 ${days} 天"
  fi
done
if [ -n "$ALERT" ]; then
  echo -e "证书即将过期:$ALERT" | mail -s "证书告警" you@example.com
  exit 1
fi
exit 0

放到 /etc/cron.d/certcheck,每天上午九点跑一次:

0 9 * * * root /usr/local/bin/check-certs.sh >> /var/log/certcheck.log 2>&1

21 天这个阈值不是随便定的。Let's Encrypt 的证书在剩余 30 天时 certbot 才会自动续期,如果续期失败,你有三次每天跑一次的机会(timer 一般一天两次);留 21 天意味着还有足够时间人工介入,而不是等到最后一天才慌。

续期成功后别忘了重载 Nginx

还有一种很坑的情况:证书续期成功了,文件也确实更新了,但 Nginx 还在用旧证书。因为 Nginx 在启动时把证书读进内存,文件变了不会自动重新加载。

certbot 的 --deploy-hook 就是干这个的。在 renewal 配置里确认有:

grep deploy_hook /etc/letsencrypt/renewal/*.conf
# 期望看到类似:renew_hook = systemctl reload nginx

如果没有,手动加一次,或者下一次签发时带上 --deploy-hook "systemctl reload nginx"。注意这里要用 reload 而不是 restart,reload 是平滑的,不会断开现有连接。

验证方法很简单:续期后对比文件修改时间和 Nginx 里证书的序列号。用 openssl 从 443 端口直接读线上证书:

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -serial -dates
openssl x509 -noout -serial -dates -in /etc/letsencrypt/live/example.com/fullchain.pem

两个序列号一致才算真正生效。不一致就是没 reload,或者 reload 失败(比如配置有语法错误,reload 被拒绝但还是用旧配置跑着,这一点特别容易漏)。

多域名共存时的两个坑

一台服务器上挂十几个域名,是个人站长的常态。certbot 支持一张证书覆盖多个域名(-d 多次),也支持每个域名一张证书,两种方式在续期时行为完全不同,混用很容易出问题。

坑一:一张证书带多个域名,续期是全有或全无。如果证书覆盖 example.com、www.example.com、a.example.com 三个域名,续期时三个域名的验证必须全部通过,只要有一个域名的 DNS 指向删掉了或者 webroot 失效,整张证书续期失败,所有域名一起过期。这种「连带」失效非常致命,因为你在监控里看到的是原本正常的域名也挂了。

处理办法有两个:一是拆成多张小证书,按站点分组,一个站出问题不影响其他站;二是续期脚本里用 certbot renew --cert-name xxx 单独跑,把失败的单独拿出来处理,不要让一次失败阻塞其他证书。我更倾向拆开,代价是多几张证书,但故障半径小得多。

坑二:不同证书的 renewal 配置会被同名混淆。/etc/letsencrypt/live/ 下的目录名是证书名(cert-name),不是域名。如果两个证书的第一个域名相同,certbot 会自动加后缀变成 example.com-0001。这个后缀一旦生成,后续所有操作都得带上完整的 cert-name,否则 certbot renew 会同时处理两张证书,其中一张的路径你可能根本不知道指向哪里。

检查办法很简单,把所有证书名和覆盖域名列出来对一遍:

certbot certificates 2>/dev/null \
  | grep -E 'Certificate Name|Domains|Expiry'

如果看到 -0001、-0002 这种名字,先搞清楚它们各自对应哪个站点的哪个 webroot,把重复的删掉(certbot delete --cert-name xxx-0001),只保留在用的那一张。删除前务必确认 nginx 配置里引用的是哪一个路径,删错了站点立刻报证书文件不存在。

把续期从「被动等待」改成「主动演练」

证书这东西最怕的不是过期,而是「你不知道它会不会过期」。我现在的做法是每个月做一次完整的演练:找一个低峰时段,手动执行 certbot renew --dry-run --cert-name 域名,把输出存下来,跟上次的对比。如果哪个月开始出现 warning,当月就处理掉,不留到下个月。

演练时注意看三处:一是验证方式是否还是你期望的那种(webroot 还是 dns-01),二是 hook 有没有正常执行(--dry-run 默认不跑 deploy hook,需要加 --run-deploy-hooks 才会执行),三是有没有出现 Saving debug log to 之外的多余警告。这三项都干净,说明续期链路是健康的。

最后提醒一点:不要把 --dry-run 当成「什么都没变」的操作。它虽然不会真的签发证书,但仍会向 ACME 服务器发起验证请求,也会在本地留下日志。频率太高(比如做成每小时跑一次的定时任务)反而可能触碰验证失败次数限制。一个月一次,或者在大改 nginx 配置之后跑一次,是最合适的频率。

一张排查顺序表

把上面的内容压缩成一张判断表,遇到 HTTPS 挂了按顺序走,不要跳步:

  1. openssl s_client 读线上证书到期时间——确认是不是真过期,还是浏览器缓存的旧警告;
  2. systemctl list-timers | grep certbot——确认续期任务存在且在跑;
  3. journalctl -u certbot 看最近 60 天有没有 error——确认失败原因;
  4. certbot renew --dry-run——确认当前链路能否走通;
  5. 检查 webroot 路径、location ~ /\. 规则、强制跳转;
  6. 检查 renewal/*.conf 的 authenticator 是否还匹配现状;
  7. 最后才怀疑速率限制和账户问题。

按这个顺序走,绝大多数情况在第三步就能定位,不用把整台服务器的配置翻一遍。

最后补一句经验:不要用 certbot --nginx 这种自动改配置的方式在生产机上首次部署。它会往你的 nginx 配置里插一堆标记行,后续手工改动配置时极容易冲突,而冲突的表现就是「某次改完 nginx 配置后证书续期就失败了」——这条因果链很难在事后关联起来。手动 certonly --webroot 加一个独立的 acme-challenge location,可控性高得多。

Last modification:October 21st, 2026 at 10:26 pm

Leave a Comment