Let's Encrypt 证书自动续期失效排查实战:certbot deploy hook、DNS-01 通配符与「续了没生效」全流程

证书过期不是「忘了续」,而是「续了没生效」

用 Let's Encrypt 的个人站长几乎都遇到过这种场面:某天早上打开网站,浏览器红着脸告诉你 NET::ERR_CERT_DATE_INVALID,一查发现证书三天前就过期了。但奇怪的是,服务器上的 certbot 定时任务明明在跑,日志里也写着续期成功。问题到底出在哪?

本文不教你「怎么申请第一张证书」——那种教程遍地都是。本文要解决的是更折磨人的第二阶段问题:证书明明续期成功了,客户端却还在用旧的。这个坑涉及 ACME 挑战方式、Nginx 配置加载、systemd 定时器、以及通配符证书的特殊要求,环环相扣,任何一环出错都会导致「续了等于没续」。

先搞清楚 certbot 的三种运行形态

在你排查任何问题之前,必须先知道服务器上的 certbot 到底是怎么被调起来的。很多人踩坑就踩在「我不知道它是谁在跑」。

形态一:独立运行的 systemd timer。通过 apt install certbot 安装的版本,通常会自带一个 certbot.timer。查看命令:

systemctl list-timers | grep certbot
systemctl status certbot.timer

如果是 timer 驱动的,续期逻辑由 certbot renew 负责,它会扫描 /etc/letsencrypt/renewal/ 下每张证书的配置文件,检查剩余有效期是否小于 30 天,小于才真正续期。

形态二:Certbot 的 nginx 插件自己管。如果你当初用 --nginx 安装,续期时 certbot 会尝试自己修改并 reload Nginx。这种情况下,如果 Nginx 配置后来被手工改动过、或者 --nginx 插件解析配置失败,续期就会静默地跳过 reload。

形态三:acme.sh 或 Caddy 等第三方客户端。如果你用的是 acme.sh,它的续期靠的是 crontab -l 里的一条定时任务,默认每天凌晨随机分钟执行。检查:

crontab -l | grep acme
~/.acme.sh/acme.sh --list

先确认形态,再谈排查。盲目地「再手动跑一次 certbot renew」往往掩盖了真正的病根。

头号病根:续期成功,但服务没重新加载证书

这是最高频、也最隐蔽的一种失败。Let's Encrypt 的证书文件是就地覆盖的:/etc/letsencrypt/live/example.com/fullchain.pem 是软链接,指向 ../../archive/example.com/fullchain5.pem 这样的归档文件。续期成功后软链接指向新的归档文件,但——正在运行的 Nginx 进程仍然持有旧文件的内存句柄。

Nginx 只有在收到 reload(systemctl reload nginx)或重启时,才会重新打开证书文件。如果续期钩子没有触发 reload,Nginx 就会继续吐着旧证书,直到证书真正过期那天,报错才浮出水面。

验证方法——比较磁盘上的证书和实际服务返回的证书指纹:

# 磁盘上证书的到期时间
openssl x509 -enddate -noout -in /etc/letsencrypt/live/example.com/fullchain.pem

# 线上服务实际返回的证书到期时间
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -enddate -noout

如果这两个日期不一致(线上返回的更早),那就是「没 reload」实锤了。

修复方式是配置 deploy hook。certbot 支持 --deploy-hook,在证书真正更新的那次续期后执行。编辑 renewal 配置或使用命令行:

certbot renew --deploy-hook "systemctl reload nginx"

更稳妥的做法是写一个脚本,让钩子只在实际更新时运行(deploy hook 本身就满足这个条件,它只在证书变化后触发),并做好错误处理:

#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
systemctl reload nginx
if [ $? -ne 0 ]; then
  echo "nginx reload 失败" 1>&2
  exit 1
fi

注意:放在 renewal-hooks/deploy/ 目录下的脚本,certbot 会自动为每一次成功续期执行,不需要额外指定 --deploy-hook。这是比写在命令行里更稳妥的方式,因为它不会因为某次手动执行忘了带参数而失效。

第二号病根:HTTP-01 挑战被 CDN 或跳转打乱

新证书签发失败,日志通常会给一个明确线索。先看 certbot 的日志:

grep -i 'error\|failed\|challenge' /var/log/letsencrypt/letsencrypt.log | tail -30

如果看到 Timeout during connect 或 Invalid response,大概率是 HTTP-01 挑战没走通。HTTP-01 的原理是:Let's Encrypt 从一个随机的出口 IP 访问 http://example.com/.well-known/acme-challenge/一串token,读取文件内容验证你对域名的控制权。

这里有几个常见的破坏点:

  • CDN 缓存了 404。如果你套了 Cloudflare 之类的 CDN,/.well-known/ 路径的 404 结果可能被缓存,导致后续挑战全都失败。解决:在 CDN 里对 /.well-known/* 设置 bypass cache。
  • HTTP 强制跳转 HTTPS。你的 80 端口做了 301 跳到 443,而 certbot 的挑战文件放在别处,跳转后 404。certbot 通常能处理跳转,但如果跳转目标本身有 CDN 拦截就会失败。
  • Nginx 的 default_server 抢了请求。挑战请求的 Host 头如果没匹配到正确的 server 块,会落到默认站点,读到空目录。

一个诊断技巧是手动模拟挑战请求:把 webroot 目录设好,然后自己 curl 一下那个 token 文件,确认从公网能读到纯文本。读不到,就是 Nginx 路由或 CDN 的问题,与 certbot 无关。

通配符证书必须用 DNS-01,别硬套 HTTP-01

很多人想给自己的所有子域颁发一张通配符证书 *.example.com,于是写:

certbot certonly --webroot -d example.com -d '*.example.com'

这会直接报错:Wildcard domains require DNS-01 challenge。因为 HTTP-01 无法验证「某个随机子域」的归属——你不知道 Let's Encrypt 会来访问哪个子域,也就没法提前放好文件。通配符只能靠 DNS-01:在域名的 DNS 里放一条 _acme-challenge 的 TXT 记录。

DNS-01 的自动化依赖 DNS 服务商的 API。用 certbot 的 DNS 插件(如 certbot-dns-cloudflare):

# 安装插件
apt install python3-certbot-dns-cloudflare

# 准备凭证文件(务必 chmod 600)
cat > /root/.secrets/cloudflare.ini <<'EOF'
dns_cloudflare_api_token = 你的API令牌
EOF
chmod 600 /root/.secrets/cloudflare.ini

# 申请通配符证书
certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
  -d example.com -d '*.example.com'

用 API 令牌而不是全局 API Key,可以限制权限只到「编辑 DNS」范围,更安全。DNS-01 的一个隐患是传播延迟:有些 DNS 服务商(尤其是免费套餐)的 TXT 记录生效慢,certbot 查询权威服务器时读到旧记录就判定失败。可以在插件参数里加大等待时间,比如给 cloudflare 插件加 --dns-cloudflare-propagation-seconds 60。

为什么「手动跑一次 renew」验证不了自动续期

很多站长遇到证书问题时,第一反应是手动敲一遍 certbot renew,看到「证书未到期,跳过」或「续期成功」,就以为问题解决了。这是一个危险的错觉。手动执行和定时器自动执行,走的是两套完全不同的环境。

手动执行时,你是以 root 身份在交互式 shell 里运行,环境变量、PATH、工作目录都是你登录会话的。而 systemd timer 触发时,进程运行在一个干净的最小环境里,PATH 可能不含 /usr/local/bin,工作目录是根目录,你写在 shell 配置文件里的自定义变量一律不存在。如果 deploy hook 脚本依赖某个环境变量、或者用相对路径引用文件,手动跑成功、自动跑失败,就成了必然。

所以正确的验证方法不是手动跑,而是检查 timer 的真实日志和触发记录:

# 看 timer 是否按时触发过
systemctl list-timers certbot.timer
journalctl -u certbot.service --since "30 days ago"

更进一步的验证是 certbot renew --dry-run(下文详述),它能在 staging 环境完整模拟一次自动续期,包括钩子执行的环境,比手动真跑更能暴露问题。记住一个原则:能自动跑通,才算配置完成;手动跑通,只说明命令本身没写错。

模拟续期:不做这一步,你永远不知道它是否真的能自动续

证书申请成功后,最危险的心态就是「设置好了应该没问题」。ACME 的续期逻辑只有在临近过期(不足 30 天)时才会真正执行,你没法等 60 天来验证。所以必须用 --dry-run 做一次全真模拟:

certbot renew --dry-run

--dry-run 会走完整的 ACME 流程(包括挑战、签发、钩子),但用的是 staging 环境,不会消耗真实额度,也不会覆盖你的证书。如果 dry-run 失败,说明自动续期一定会挂,务必解决后再走。

需要注意:某些 deploy hook 在 dry-run 时也会执行,如果你不希望模拟时真去 reload 服务,可以加条件判断,检查环境变量 $RENEWED_LINEAGE 是否存在。

如果连 dry-run 都跑不起来,注意 staging 环境有严格的速率限制(每个域名每分钟 5 次失败验证),频繁重试会被临时封禁。等一会儿再试,别死循环猛撞。

一套完整的排查清单

把上面的内容浓缩成一张可以照着做的清单,遇到证书问题按顺序走:

  • 第一步,确认谁在续期。systemctl list-timers | grep certbot 和 crontab -l,先确定调度器存在且真的在跑。
  • 第二步,看日志不猜。/var/log/letsencrypt/letsencrypt.log 是唯一的事实来源,搜 error 关键字,别凭感觉。
  • 第三步,比对证书指纹。磁盘上的到期时间 vs 线上 openssl s_client 返回的到期时间,不一致就是没 reload。
  • 第四步,检查 deploy hook。确认 renewal-hooks/deploy/ 下有 reload 脚本,且脚本可执行、无语法错误。
  • 第五步,检查挑战路径。HTTP-01 的 /.well-known/acme-challenge/ 不能被 CDN 缓存,不能被跳转吃掉,不能被 default_server 抢走。
  • 第六步,wildcard 走 DNS-01。需要 API 令牌,注意传播延迟,凭证文件权限 600。
  • 第七步,跑 dry-run。模拟成功才算真正配置完成,否则一切都是侥幸。

最后提醒一点:给证书过期加个监控。哪怕自动续期配置得再完美,DNS 服务商改版 API、CDN 换了策略、磁盘写满导致 certbot 无法写文件——这些都会让续期在某一天突然失灵。一个最简单的方案是写个脚本每天检查证书剩余天数,少于 14 天就给自己发一封邮件告警。把「证书过期」从一个惊吓事件,降级成一个可预期的提醒,这才是一个成熟站长该有的运维习惯。

Last modification:October 15th, 2026 at 09:24 pm

Leave a Comment