为什么证书总是在半夜过期:一次真实的续期失败复盘
很多站长把 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,说明这个路径没有映射到正确目录。常见原因有四个:
- Nginx location 覆盖了
.well-known。如果你写了一条location / { try_files $uri $uri/ /index.php; },而.well-known目录不存在,请求会被交给 PHP 处理并返回 404。 - webroot 路径配错。certbot 的
--webroot-path必须指向站点真实根目录(如/www/wwwroot/example.com),而不是上级目录。 - 301 跳转吃掉了请求。很多站点强制 HTTPS 跳转,但 ACME 验证走的是 HTTP,如果跳转规则把
/.well-known/也跳成了 HTTPS,而 HTTPS 又指向别的 server block,验证就断了。 - 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 钩子。盯住这三点,你的证书基本不会再半夜偷偷过期。