Let's Encrypt 证书自动续期总失败?从 webroot 验证到 reload 钩子的四层排查实战

为什么证书总是在半夜过期:一次真实的续期失败复盘

很多站长把 certbot renew 或 acme.sh 挂上 cron 之后就再也没管过,直到某天 Chrome 弹出"您的连接不是私密连接",才发现证书已经过期三天。本文不讲怎么安装 Let's Encrypt 客户端,而是聚焦一个更实际的问题:续期脚本明明在跑,为什么证书还是过期了?我们把续期链路上的每个环节拆开,给你一套能落地的排查方法。

先搞清楚:续期成功与否,看哪里

绝大多数人只看"cron 有没有执行",这是最大的误区。cron 执行了不代表续期成功了。正确的观察点有三个:

  • 证书本身的有效期:openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -dates
  • 续期日志:certbot 是 /var/log/letsencrypt/letsencrypt.log,acme.sh 是 ~/.acme.sh/acme.sh.log
  • 续期计时器状态:systemctl list-timers | grep -E 'certbot|acme'

先跑第一条命令。如果 notAfter 已经过去,那么无论 cron 跑得多勤快,问题都出在续期环节本身。还要特别留意一个细节:cert.pem 只是域名证书,实际部署时应该用 fullchain.pem(含中间证书)。如果你只配了 cert.pem,浏览器可能报"证书链不完整",这在移动端 App 或 curl 里表现为 unable to get local issuer certificate,很容易和"过期"混淆。

第一层排查:续期到底有没有被触发

certbot 的默认机制是"剩余有效期少于 30 天才续期"。所以当你手动执行 certbot renew 看到下面这行时,它其实什么都没做:

Cert not yet due for renewal

The following certificates are not due for renewal yet:
  /etc/letsencrypt/live/example.com/fullchain.pem expires on 2026-11-20 (skipped)
No renewals were attempted.

很多人第一次排查会误以为"续期成功了"。要强制验证整条链路,用 --dry-run:

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

--dry-run 会走完整的 ACME 流程(申请、验证、签发)但使用 staging 环境,不会消耗真实速率限制。如果 dry-run 都能通过,说明验证链路是通的,问题多半在"没到时间"或"计时器没跑";如果 dry-run 失败,错误信息就是根因。

另一个常见陷阱是计时器被覆盖。装了 certbot 的 apt 包会自动创建一个 certbot.timer,但如果你后来又手动往 crontab 里加了一条 certbot renew,两套机制可能互相干扰:cron 里那条通常不带输出重定向,失败信息直接进了邮件队列或黑洞,你根本看不到。检查一下有没有重复:

systemctl status certbot.timer
crontab -l | grep -i certbot
# 二选一即可,建议保留 systemd timer

第二层排查:webroot 验证为什么失败

续期失败里最常见的错误是这样的:

Domain: example.com
Type:   unauthorized
Detail: Invalid response from
http://example.com/.well-known/acme-challenge/xxxxxxxx:
"<html>
<head><title>404 Not Found</title></head>..."

Let's Encrypt 的 HTTP-01 验证原理很简单:CA 请求 http://你的域名/.well-known/acme-challenge/<token>,你的服务器必须返回一个指定字符串。返回 404,说明这个路径没有映射到正确目录。常见原因有四个:

  1. Nginx location 覆盖了 .well-known。如果你写了一条 location / { try_files $uri $uri/ /index.php; },而 .well-known 目录不存在,请求会被交给 PHP 处理并返回 404。
  2. webroot 路径配错。certbot 的 --webroot-path 必须指向站点真实根目录(如 /www/wwwroot/example.com),而不是上级目录。
  3. 301 跳转吃掉了请求。很多站点强制 HTTPS 跳转,但 ACME 验证走的是 HTTP,如果跳转规则把 /.well-known/ 也跳成了 HTTPS,而 HTTPS 又指向别的 server block,验证就断了。
  4. CDN 或反向代理拦截。Cloudflare 开启代理时,HTTP-01 请求由 Cloudflare 回源,若源站配置不对,同样 404。注意 Cloudflare 的"始终使用 HTTPS"和"自动 HTTPS 重写"两个开关都可能干扰 HTTP-01,排查期间可以先临时关掉。

正确做法是在 Nginx 里显式放行这个路径,并且放在跳转规则之前:

server {
    listen 80;
    server_name example.com;

    # ACME 验证必须先于 HTTPS 跳转
    location ^~ /.well-known/acme-challenge/ {
        root /www/wwwroot/example.com;
        default_type "text/plain";
        allow all;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

注意 ^~ 前缀匹配优先级高于普通前缀匹配,能确保它不被后面的正则 location 抢走。改完之后先手动验证:

mkdir -p /www/wwwroot/example.com/.well-known/acme-challenge
echo "hello-acme" > /www/wwwroot/example.com/.well-known/acme-challenge/test
curl -s http://example.com/.well-known/acme-challenge/test
# 必须原样输出 hello-acme,返回 404 或跳转都说明配置还没生效

如果是 acme.sh 的 standalone 模式,还要注意它启动的是一个临时监听 80 端口的服务,此时 Nginx 必须停掉否则端口冲突;这也是很多人抱怨 standalone 模式"第一次能签,续期就失败"的原因——因为第一次签的时候 Nginx 还没启动。

第三层排查:DNS 验证的超时与传播

如果你用的是 DNS-01 验证(泛域名证书必须用它),失败原因通常藏在"传播等待"上。acme.sh 的报错长这样:

[Fri Oct  2 03:12:44 CST 2026] example.com:Timeout
[Fri Oct  2 03:12:44 CST 2026] Please check log file for more details

根本原因是 CA 需要的 _acme-challenge.example.com TXT 记录还没传播到它查询的权威服务器。排查动作:

# 看当前权威服务器返回的 TXT
dig +short TXT _acme-challenge.example.com @8.8.8.8
dig +short TXT _acme-challenge.example.com @1.1.1.1

# 直接问你的权威 DNS
dig +short TXT _acme-challenge.example.com @ns1.yourdns.com

如果权威服务器上已经有了,但公共 DNS 还没看到,这就是 TTL 与缓存问题。acme.sh 可以通过环境变量延长等待时间:

export DNS_SLEEP=120        # 默认 20 秒,API 慢的厂商调到 120
export DNS_TIMEOUT=600
acme.sh --renew -d example.com --force

另一个 DNS 验证的隐形杀手是泛域名证书的验证记录位置。申请 *.example.com 时,TXT 记录要写在 _acme-challenge.example.com,而不是 _acme-challenge.www.example.com。有些 DNS 服务商支持的通配记录语法不一样,容易写错地方,导致 CA 查不到。用 dig 明确指定记录名去验证,比凭记忆靠谱。

第四层排查:签发成功但网站没生效

这是最隐蔽的一类故障:证书确实续期了,新文件也写到了 /etc/letsencrypt/live/example.com/,但 Nginx 还在用旧证书。原因是证书文件更新后,Nginx 必须 reload 才能加载新证书,而很多人漏配了续期钩子。

certbot 的钩子配置在 /etc/letsencrypt/renewal-hooks/deploy/ 下,新建一个可执行脚本:

#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
systemctl reload nginx
# 若证书还被其它服务使用(如 postfix、dovecot),一并 reload
systemctl reload postfix 2>/dev/null || true

记得 chmod +x。acme.sh 则在续期命令里加 --reloadcmd:

acme.sh --install-cert -d example.com \
  --key-file       /etc/nginx/ssl/example.com.key \
  --fullchain-file /etc/nginx/ssl/example.com.crt \
  --reloadcmd      "systemctl reload nginx"

这里有个关键点:acme.sh 会把你指定的 --key-file、--fullchain-file 作为安装路径记录在续期配置里,每次续期成功后自动复制过去并执行 reloadcmd。如果你的 Nginx 配置指向的是 /etc/nginx/ssl/ 而不是 /etc/letsencrypt/live/,那必须用 --install-cert 建立这个映射,否则续期只会更新 live 目录,Nginx 用的那份永远不会变。

验证是否 reload 成功,看证书的序列号而不是看日期:

# 本地文件序列号
openssl x509 -in /etc/nginx/ssl/example.com.crt -noout -serial
# 线上实际提供的序列号
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -serial

两个序列号一致,才说明新证书真正生效了。日期比对不可靠,因为旧证书在过期前日期也是"未来"的。如果序列号不一致,问题不在证书而在部署:检查 Nginx 配置里 ssl_certificate 指向的路径,以及 certbot/acme.sh 有没有真的更新那个路径下的文件(用 ls -l --time-style=full-iso 看修改时间)。

还要注意 ssl_certificate 路径如果被别的 server block 复用(例如通过 include 的公共配置段),改一处会影响所有站点,验证时要把所有相关域名都 curl 一遍,避免只修了主域而漏了子域。

第五层排查:速率限制与账户问题

如果上面四层都正常却还是失败,看看是不是撞了 Let's Encrypt 的速率限制。日志里搜索关键词:

grep -i "too many certificates" /var/log/letsencrypt/letsencrypt.log
grep -i "rateLimited" /var/log/letsencrypt/letsencrypt.log

常见的限制有:同一注册域名每周 50 张证书、同一组域名每周 5 张重复证书、每 3 小时 300 个 pending 授权。测试阶段用 --dry-run 就是避免消耗这些配额的关键。如果不幸已经撞限,除了等待没有别的办法,但要检查是不是某个自动化脚本在每次失败后疯狂重试——那会迅速把配额烧光。

一张排查顺序表

  • 证书已过期 + cron 有记录 → 跑 --dry-run 看真实报错
  • 报 404 unauthorized → 查 .well-known 的 location 与 webroot 路径
  • DNS-01 超时 → 加大 DNS_SLEEP,用 dig 确认 TXT 已在权威服务器上
  • 签发成功但浏览器仍报错 → 对比本地与线上证书序列号,补 reload 钩子
  • 以上都正常但还是失败 → 查速率限制,日志里搜索 too many certificates,等一周或改用 staging 调通
  • 移动端报链不完整 → 确认部署的是 fullchain.pem 而不是 cert.pem

给个人站长的最小可靠方案

与其每次出问题再救火,不如把可靠性前置。三条习惯:

第一,用 systemd timer 而不是裸 cron,它自带 Persistent=true,服务器重启或关机错过的时间点会在开机后补跑。第二,续期命令只跑 renew,不要带 --force,强制续期会白白消耗速率限制。第三,加一个外部监控,比如用 curl --cert-status 每天检查一次,或者在探针里加一条证书剩余天数小于 14 天就告警的规则。

还可以做一个五分钟就能加上的自检脚本,每天跑一次,把剩余天数写进日志并在低于阈值时告警:

#!/bin/bash
# /root/cert_check.sh
DOMAIN="example.com"
END=$(echo | openssl s_client -connect ${DOMAIN}:443 -servername ${DOMAIN} 2>/dev/null \
      | openssl x509 -noout -enddate | cut -d= -f2)
LEFT=$(( ( $(date -d "$END" +%s) - $(date +%s) ) / 86400 ))
echo "$(date) ${DOMAIN} expires in ${LEFT} days"
[ "$LEFT" -lt 14 ] && echo "CERT EXPIRING SOON: ${DOMAIN} ${LEFT}d"

最后提醒一句:Let's Encrypt 的证书有效期是 90 天,官方建议在剩余 30 天时续期,留出足够的重试窗口。如果你的续期脚本设置的是"提前 7 天",一旦那几天刚好遇到验证故障,几乎必然过期。把窗口开大,比事后排查划算得多。整套流程里真正高频翻车的就三处:Nginx 的 .well-known location、DNS TXT 的传播等待、以及漏配 reload 钩子。盯住这三点,你的证书基本不会再半夜偷偷过期。

Last modification:October 2nd, 2026 at 10:24 pm

Leave a Comment