Certbot 自动续期静默失效排查实战:dry-run 实测、deploy-hook 告警与 Nginx reload 陷阱

证书到期前一天才发现续期失败,是完全可以避免的

Certbot 装上之后,绝大多数教程都告诉你「它会自动续期,不用管」。这句话对了一半:Certbot 确实会装一个 systemd timer 或 cron 任务,但它只在续期成功时静默成功——一旦失败,很多情况下你也收不到任何通知,直到第 90 天证书过期、浏览器弹出红色警告,你才知道出事了。

本文不谈怎么申请证书,只谈为什么自动续期会静默失效,以及怎么把它变成一个「失败了会主动喊你」的系统。这一篇解决的是运维里最要命的一类问题:看起来在自动运行、实际早就死了的任务。

第一步:先确认你的续期到底是怎么触发的

不同安装方式,触发机制完全不同。别猜,直接查:

# 方式一:systemd timer(apt 安装的 certbot 默认用这个)
systemctl list-timers | grep -i certbot
systemctl status certbot.timer
systemctl status snap.certbot.renew.timer 2>/dev/null   # snap 安装

# 方式二:cron(老版本或手动装的)
ls -l /etc/cron.d/certbot
cat /etc/cron.d/certbot
crontab -l | grep -i certbot

如果两个都查不到,那说明你的证书根本没有自动续期机制,一直在靠手动。这不是少数派——手动装 Certbot、或者用 Docker 跑 certbot 的站,经常漏掉这一步。

看清触发命令是什么,非常关键。systemd timer 执行的通常是:

/usr/bin/certbot -q renew

-q 是 quiet,它把成功和失败都吞掉了。这就是「静默失效」的第一个来源。

失败原因一:renew 判断逻辑根本没到你的域名

certbot renew 不是「重新申请」,而是「检查即将到期的证书并续期」。它只处理剩余有效期少于 30 天的证书(默认阈值)。这意味着:

  • 你改了配置、想立刻测试,跑 certbot renew 却什么都没发生 → 不是坏了,是还没到 30 天。
  • 强制测试要用 certbot renew --dry-run,它会真的走一遍 ACME 流程但不落盘。

更隐蔽的情况:证书的续期配置存在 /etc/letsencrypt/renewal/*.conf 里,如果这个目录下的文件被误删、或者域名和证书的对应关系乱了,renew 会直接跳过你的证书,不报错,只是「没有需要续期的证书」。检查:

certbot certificates

输出里每个证书都有 Domains 和 Expiry Date。如果有域名你明明部署了却不在这份列表里,那就是 renewal conf 丢了,renew 永远不会管它。

失败原因二:验证方式的路径已经失效(webroot 最常中招)

ACME 验证要求 Certbot 把挑战文件放到一个 HTTP 能访问到的位置。三种验证方式的失效模式完全不同:

webroot:目录权限和 Nginx 配置漂移

certbot certonly --webroot -w /var/www/html -d example.com

它会在 /var/www/html/.well-known/acme-challenge/ 下放临时文件。如果出现下面任何一种情况,验证就会 404,续期失败:

  • Nginx 里有一条 location ~ /\. 的规则把点开头的目录全 deny 了——这是很多安全加固教程推荐的写法,它会顺手把 ACME 挑战也拒掉。
  • 网站根目录迁移过(换域名、加 CDN、改 document_root),但 renewal conf 里的 webroot 还是旧路径。
  • 用了 return 301 https://$host$request_uri; 强制跳转,但验证发生在 80 端口,跳转后路径变了。
  • 目录权限是 root 700,Nginx 的 www-data 用户读不到。

Nginx 里必须显式放行挑战目录,且放在所有 deny 规则之前:

location ^~ /.well-known/acme-challenge/ {
    root /var/www/html;
    default_type "text/plain";
    allow all;
}

nginx 插件:改了 Nginx 配置后最容易崩

certbot certonly --nginx -d example.com

这个插件会临时改写 Nginx 配置来插入验证规则,成功后再还原。它的失败点在于:如果你的 Nginx 配置文件有语法错误,或使用了它无法解析的自定义结构(比如某些 map、include 的动态路径),它会放弃改写,验证失败。而且它改写失败时留下的临时配置可能让 Nginx 起不来。用之前先跑 nginx -t 确认语法干净。

dns 插件:API token 过期是唯一但致命的坑

certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/.secrets/cf.ini -d example.com

这个方式不依赖 80 端口,本来最稳,但 API token 会过期、会被人重新生成、会因权限收窄而失效。token 一失效,续期立刻挂,而且报错藏在 /var/log/letsencrypt/letsencrypt.log 里,-q 模式下你根本看不到。特点是:出错信息通常是 Error determining zone ID 或 Unable to determine zone identifier。

失败原因三:日志被 quiet 吞了,没人看

不管哪种失败,证据都在这里:

/var/log/letsencrypt/letsencrypt.log

但默认它不轮转、不清理、不告警,而且 certbot -q renew 把 stdout 也丢了。所以真正的第一步是:先把日志接出来看。

# 看最近一次续期尝试发生了什么
tail -100 /var/log/letsencrypt/letsencrypt.log

# 只看错误
grep -iE 'error|fail|challenge|timeout' /var/log/letsencrypt/letsencrypt.log | tail -30

顺手给它加个 logrotate,否则这个文件能长到几百 MB:

/etc/logrotate.d/letsencrypt
---
/var/log/letsencrypt/*.log {
    weekly
    rotate 8
    compress
    missingok
    notifempty
}

把它变成「会喊人」的系统:三层保险

第一层:dry-run 定期实测

别等真过期才验证。每周跑一次 dry-run,它走完整流程但不污染现有证书,能提前暴露 90% 的配置漂移问题:

certbot renew --dry-run --non-interactive

加进 cron:

0 4 * * 1 /usr/bin/certbot renew --dry-run --non-interactive > /var/log/certbot-dryrun.log 2>&1

第二层:续期成功/失败都跑 hook

Certbot 支持在续期前后执行脚本。失败 hook 是非交互续期的救命符:

certbot renew --deploy-hook "/usr/local/bin/after-renew.sh" --renew-hook "..."

关键点是 deploy-hook 只在证书真正更新时执行,没更新就不跑——所以不能用它判断「任务有没有失败」。要捕捉失败,得包一层自己写的 wrapper:

#!/bin/bash
# /usr/local/bin/certbot-renew.sh
LOG=/var/log/certbot-renew.log
if /usr/bin/certbot renew --non-interactive --deploy-hook \
   "systemctl reload nginx" >>"$LOG" 2>&1; then
    echo "$(date -Is) renew OK" >>"$LOG"
else
    echo "$(date -Is) renew FAILED" >>"$LOG"
    # 这里接你的告警:邮件 / Telegram / 企业微信机器人
    tail -30 "$LOG" | mail -s "Certbot renew FAILED $(hostname)" you@example.com
fi

然后把 timer/cron 改成调用这个 wrapper,而不是直接调 certbot。

第三层:独立监控证书剩余天数(最可靠)

前两层依赖 Certbot 自己的机制,第三层直接从「结果」判断,谁也骗不过它。用 openssl 读线上证书的真实剩余天数:

#!/bin/bash
# /usr/local/bin/check-cert-days.sh
DOMAIN="www.example.com"
WARN=14
end=$(echo | openssl s_client -servername "$DOMAIN" -connect "$DOMAIN:443" 2>/dev/null \
      | openssl x509 -noout -enddate | cut -d= -f2)
end_ts=$(date -d "$end" +%s)
now_ts=$(date +%s)
days=$(( (end_ts - now_ts) / 86400 ))
echo "$DOMAIN 剩余 ${days} 天"
if [ "$days" -lt "$WARN" ]; then
    echo "证书将在 ${days} 天后过期!" | mail -s "CERT ALERT $DOMAIN" you@example.com
fi

为什么必须用这种方式? 因为它检查的是「浏览器看到的那个证书」,而不是「Certbot 以为它续期了的证书」。这两者不一致正是静默失效的本质:Certbot 可能续期成功了,但 Nginx 没 reload,线上还是旧证书。deploy-hook 里加 systemctl reload nginx 就是防这个,但只有从外部实测才能确认它真的生效了。

30 8 * * * /usr/local/bin/check-cert-days.sh

最容易忽略的一环:Nginx 没 reload,续了等于没续

这是「Certbot 显示成功但浏览器报过期」的头号原因。Nginx 启动时把证书读进内存,证书文件更新后,不 reload 就仍然用旧的。所以每一个续期流程的最后一步都必须有:

systemctl reload nginx
# 或
nginx -s reload

用 reload 而不是 restart:reload 是平滑的,不会断开现有连接。验证 reload 确实生效的方法,是对比进程启动时间和证书序列号:

# Nginx master 进程启动时间(reload 后 master 仍然是老进程,看 worker 的)
ps -o pid,lstart,cmd -C nginx
# 线上证书的真实序列号
echo | openssl s_client -servername www.example.com -connect www.example.com:443 2>/dev/null \
  | openssl x509 -noout -serial -dates

自动化流水线里的陷阱:CI/CD 跑 certbot 的那些坑

越来越多站长把证书管理搬进了自动化流程——用 GitHub Actions、Jenkins 或者 Ansible 定时触发 certbot。这看起来更「工程化」,但引入了一类新的静默失效:流水线本身跑成功了,证书却没更新。

最典型的是凭据范围问题。DNS 验证用的 API token 如果只给了一个「可以改这条 DNS 记录」的最小权限,一旦有人重建了这个 token、或者调整了 Zone 的权限,流水线会在「获取 zone id」这一步失败,但很多 CI 配置里 certbot 的退出码没有被正确检查,任务照样标记为成功。

# 反例:certbot 失败了但任务仍显示绿灯
- run: certbot renew --dns-cloudflare ...

# 正例:显式检查退出码,失败就中断
- run: |
    set -euo pipefail        # 任何命令失败立即退出
    certbot renew --dns-cloudflare || { echo "RENEW FAILED"; exit 1; }

另一个坑是工作目录和存储位置。CI runner 是临时环境,/etc/letsencrypt 里的内容如果不持久化、不挂载出来,每次跑都是全新的「无证书」状态,certbot 会去申请一张新证书。Let's Encrypt 有严格的重复签发速率限制(同一组域名每周 50 张),一旦触发,接下来一周都无法再签发——这会直接把你的续期卡死到过期。所以 CI 方案必须把 /etc/letsencrypt 持久化到制品存储或私有仓库,并谨慎使用 --force-renewal(它绕过「还不到 30 天就不续」的判断,是最容易撞限速的操作)。

多域名与泛域名:SAN 证书里漏掉一个就前功尽弃

个人站常见一张证书挂多个域名(主域名 + www + 二级子域 + 泛域名)。这里有两个容易翻车的点:

  • 子域必须重新签发,不能靠泛域名「顺便覆盖」。 泛域名 *.example.com 只覆盖一层子域,a.b.example.com 不在其内;而且 www 是否在 *.example.com 覆盖范围内,取决于具体实现,稳妥做法是显式把 www.example.com 也列进去。
  • 任一域名的验证失败,整张证书都不续。 一张 SAN 证书是「全有或全无」的。假设你证书里有四个域名,其中一个的 DNS 已经不再指向这台服务器(比如解析到了 CDN 或者被删了 A 记录),那么这张证书的续期会一直被那个域名拖垮。排查时先看:
# 查看证书里到底包含哪些域名
certbot certificates | grep -A2 'Domains'
# 或者直接从线上证书读
echo | openssl s_client -servername www.example.com -connect www.example.com:443 2>/dev/null \
  | openssl x509 -noout -text | grep -A1 'Subject Alternative Name'

如果发现有域名已经不用了,用 --cert-name 明确指定并剔除它,重新签发一次干净的多域名证书:

certbot certonly --nginx --cert-name example.com \
  -d example.com -d www.example.com

为什么我推荐 --deploy-hook 而不是 --renew-hook(以及两者的区别)

这两个 hook 名字像,作用完全不同,配错了会出现「脚本从来没执行过」的问题:

  • --renew-hook:每次续期成功后执行,无论证书内容是否变化。
  • --deploy-hook:只有证书真正被更新时才执行,用于 reload 服务、部署到目标位置。

日常应该用 --deploy-hook,因为 renew 任务每天跑,但证书 60 天才真正更新一次——用 renew-hook 会导致服务每天被无意义地 reload。而判断「这次到底有没有更新」的逻辑,交给 certbot 就好。

还有一个持久化技巧:把 hook 写进 renewal conf,避免忘记在命令行里带参数:

# /etc/letsencrypt/renewal/example.com.conf 里加
[renewalparams]
deploy_hook = systemctl reload nginx

容器化部署下的特殊坑:Nginx 容器里的证书是「快照」

如果 Nginx 跑在容器里,证书是通过 volume 挂进去的,那么有一个非常隐蔽的问题:宿主机上证书更新了,容器里读到的还可能是旧内容。原因是部分文件挂载方式(尤其是把单个文件 bind mount 进去,而不是挂整个目录)在文件被替换时——certbot 更新证书通常是写新文件再改名,inode 变了——容器内看到的仍是指向旧 inode 的挂载点。

表现是:宿主机上 certbot certificates 显示已经是新证书,Nginx 也 reload 了,但浏览器抓到的还是旧证书。验证方法是在容器内部读证书:

docker exec nginx-proxy openssl x509 -in /etc/nginx/certs/fullchain.pem -noout -dates

解决办法:挂载目录而不是单个文件(-v /etc/letsencrypt/live/example.com:/etc/nginx/certs:ro),并在 deploy-hook 里除了 reload 之外,必要时重启容器让挂载点刷新。这是容器运维里「文件级 bind mount 不跟随 inode 变化」的经典表现,不止证书会遇到。

给系统加一层审计:谁改过证书配置

续期在某个时间点突然开始失败,最常见的触发事件是「有人改了配置」。如果你有多人协作或自己改动频繁,建议对关键路径加审计,让每次改动都留痕:

# 用 auditd 监控证书目录与 Nginx 配置的写入
-w /etc/letsencrypt/ -p wa -k cert_change
-w /etc/nginx/ -p wa -k nginx_conf_change

之后用 ausearch -k cert_change -ts recent 就能看到最近谁在什么时候改动了证书目录。这类「事后追责」的能力,在排查「为什么昨天开始续期就挂了」时特别有用——往往能一眼定位到那次误操作。

小结:把「自动」拆成可观测的四步

自动续期失败的根源,是这个流程里有四个环节、每个环节都可能静默挂掉:触发(timer/cron 在不在)→ 判断(renew 是否覆盖到你的域名)→ 验证(webroot/nginx/dns 路径是否还有效)→ 生效(Nginx 是否 reload)。任何一环断了,表面上一切正常,直到过期那天。

要做的不是「相信它自动」,而是给它加上三层独立的观测:dry-run 定期实测配置是否漂移、wrapper 脚本在失败时主动告警、以及从外部直读线上证书剩余天数的兜底监控。最后记住那句最容易被忽略的话——证书续了但没 reload Nginx,等于没续。

Last modification:September 27th, 2026 at 10:26 pm

Leave a Comment