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,别拿正式环境试错。
查看「中间件」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

HTTPS 证书过期三天:certbot 自动续期为什么一直失败 - 起航社区