HTTPS 证书过期三天:certbot 自动续期为什么一直失败
续期定时任务每小时都在跑,证书还是过期了三天。根因是一条无条件的 return 301,把 ACME 校验请求也跳去了 HTTPS。webroot 放行、^~ 的优先级、reload 的时序,以及为什么告警要盯剩余天数。
早上六点四十分,值班同事发来一张截图:浏览器红色警告页,NET::ERR_CERT_DATE_INVALID。主站打不开,接口也全挂,因为前端所有请求都被拦在证书校验这一步。
我第一反应是不可能——这台机器上明明配了 certbot renew 的定时任务,systemctl list-timers 里能看到它每小时跑一次。登上去执行 certbot certificates,输出里那句 VALID: 3 days ago (INVALID: EXPIRED) 让我安静了两秒。
定时任务一直在跑,证书还是过期了。这说明「跑了」和「续上了」是两件事。
一、现象:任务在跑,证书没换
先看三个状态,它们分别代表「计划」「执行」「结果」:
| 检查项 | 命令 | 当时输出 |
|---|---|---|
| 定时器是否在跑 | systemctl list-timers | grep certbot |
每小时一次,正常 |
| 最近一次执行 | systemctl status certbot.timer |
上次触发 1 小时前 |
| 证书实际状态 | certbot certificates |
已过期 3 天 |
| 续期预演 | certbot renew --dry-run |
失败 |
定时器健康、证书失效,那问题只能在中间:每次执行都失败了,但失败没人通知。
/var/log/letsencrypt/letsencrypt.log 里连续三天都是同一段:
Cert is due for renewal, auto-renewing...
Challenge failed for domain ispt.top
http-01 challenge for ispt.top
Detail: Invalid response from http://ispt.top/.well-known/acme-challenge/xxx: 404
Attempting to renew cert (ispt.top) produced an unexpected error: ... 404.
404。校验文件放不出去,Let's Encrypt 拿不到它,就判定「你不拥有这个域名」。
二、先分开「配置错」和「环境变」
这个报错有两个完全不同的来源,先分清再动手:
- 配置错:ACME 校验路径被 nginx 的某条
location拦截,或者被强制跳转到 HTTPS,校验器跟不过去。这类问题从第一天就该失败,不会「以前好使」。 - 环境变:80 端口被防火墙/安全组关掉、CDN 回源改了、或者这台机器的证书本来就是另一台机器签的。
这一次是「以前好使」,所以我优先查环境变化。线索很快就有了:三周前站点做过一次 HTTPS 整改,把 80 端口的 server 块改成了无条件 301 到 443。
server {
listen 80;
server_name ispt.top www.ispt.top;
return 301 https://$host$request_uri;
}
return 301 是无条件命中的,/.well-known/acme-challenge/ 也不例外。Let's Encrypt 的校验器访问 http://ispt.top/.well-known/acme-challenge/<token>,收到 301,跟到 https://,然后在 TLS 握手时遇到一张过期证书,校验失败——日志里只显示第一跳的 404,反而误导了我一阵。
三、修:给 ACME 一条不打弯的路
两种做法:用 webroot 模式让校验文件走文件系统,或者保留 standalone 但临时占用 80。我选了 webroot,因为它不打断 nginx,也不需要停服务。
先建目录,让 certbot 把校验文件写进去:
mkdir -p /var/www/letsencrypt/.well-known/acme-challenge
chown -R www-data:www-data /var/www/letsencrypt
然后在 80 端口的 server 块里,把 ACME 路径单独提出来,放在 301 之前:
server {
listen 80;
server_name ispt.top www.ispt.top;
# ACME 校验:必须优先于下面的 301
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
default_type "text/plain";
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
这里有两个细节值得说。^~ 是必要的:它让这个前缀匹配直接胜出,不再去比正则 location,避免被别处的 \.php$ 之类规则抢走。try_files $uri =404 则保证文件不存在时明确返回 404,而不是落到 SPA 的 index.html 上——后者会让校验器拿到一坨 HTML,报错信息更难懂。
续期命令改成显式指定 webroot 和域名:
certbot certonly --webroot \
-w /var/www/letsencrypt \
-d ispt.top -d www.ispt.top \
--deploy-hook "systemctl reload nginx" \
--non-interactive --agree-tos -m ops@ispt.top
--deploy-hook 里的 reload 很关键:certbot 只会更新 /etc/letsencrypt/live/ 下的软链,nginx 在启动时已经把证书读进内存了,不 reload 就还是旧证书。这个坑我在另一台机器上踩过,续期成功了但线上还是过期证书,排查了很久才发现是漏了 reload。
四、验证要验到「线上握手」,不是「文件已更新」
续期成功后,certbot certificates 会显示新的到期日。但这只证明文件写好了,不证明线上生效。我固定用三步确认:
# 1. 本地文件的有效期
openssl x509 -in /etc/letsencrypt/live/ispt.top/cert.pem -noout -dates
# 2. 线上实际握手拿到的那张(关键:加 -servername 走 SNI)
echo | openssl s_client -connect ispt.top:443 -servername ispt.top 2>/dev/null \
| openssl x509 -noout -subject -dates
# 3. 剩余天数,低于 14 天就该告警
echo | openssl s_client -connect ispt.top:443 -servername ispt.top 2>/dev/null \
| openssl x509 -noout -checkend $((14*86400)) && echo "OK: 14 天内不会过期"
第二次的输出如果还是旧日期,就是没 reload;如果 s_client 报证书不匹配,多半是 nginx 的 ssl_certificate 还指向旧的绝对路径而不是 live/ 下的软链。
完整跑一遍预演,确认全链路通:
certbot renew --dry-run --webroot -w /var/www/letsencrypt
--dry-run 用的是 staging 环境,不会消耗正式环境的频率限制,也不会改动线上证书,是改完配置后必须走的一步。
五、真正该修的是「失败没人知道」
证书过期是结果,根因是三天里每一次续期失败都没有惊动任何人。certbot.timer 的退出码在 systemd 里只记进 journal,不会发通知。
我用一个简单的健康检查脚本兜住,挂在定时任务里每天跑两次:
#!/bin/bash
# /usr/local/bin/cert-check.sh
DOMAIN="ispt.top"
WARN=14
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 ))
if [ "$left" -lt "$WARN" ]; then
curl -s -X POST "$ALERT_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"${DOMAIN} 证书剩余 ${left} 天,续期可能已失败\"}"
fi
判定对象是「线上证书剩余天数」,而不是「certbot 有没有成功」。这个视角的差别很大:前者能同时覆盖续期失败、reload 失败、以及证书被别处覆盖这三种情况。
再加一条 cron,避免只有单一入口:
17 3,15 * * * /usr/local/bin/cert-check.sh >> /var/log/cert-check.log 2>&1
六、几个容易反复踩的点
| 现象 | 常见原因 | 处理 |
|---|---|---|
404 on challenge |
return 301 抢在前面,或 SPA 兜底返回 HTML |
用 ^~ 单独放行并 try_files =404 |
Connection refused |
80 端口没开、安全组未放行 | 检查 ufw/安全组/云厂商 ACL |
| 续期成功但线上仍旧证书 | 没有 reload nginx | 加 --deploy-hook |
too many failed authorizations |
反复重试触发频率限制 | 用 --dry-run 调试,等一小时再试 |
只有 www 生效 |
-d 漏了裸域 |
两个域名都写进同一张证书 |
还有一点:如果站前面有 CDN,证书可能签在 CDN 侧,源站 certbot 续的是源站证书,两边都要管。这次主站没走 CDN,少了一层,但 www 子域走的是另一条链路,我是分开验证的。
小结
- 「定时任务在跑」不等于「续期成功」,判断依据必须是线上握手的证书有效期。
return 301会把 ACME 校验一起跳走,80 端口要单独给/.well-known/acme-challenge/留一条^~规则。^~加try_files $uri =404,两个细节分别挡住正则抢占和 SPA 兜底。- 续期后必须 reload nginx,
--deploy-hook是把它固定下来的最省事方式。 - 告警要盯「证书剩余天数」而不是「脚本退出码」,才能覆盖 reload 失败这类静默故障。
- 改完配置先跑
certbot renew --dry-run,别拿正式环境试错。
正在加载评论…