免费证书自动续期的静默失败排查

免费证书的坑不在于申请,而在于续期。Let's Encrypt 的证书 90 天到期,官方推荐每 60 天续一次。几乎所有「网站突然显示不安全」的事故,最后查下来都是同一句话:续期任务跑了,但没成功,而没人知道。这篇讲清自动续期链路上每一个会静默失败的环节,以及怎么用三个数字保证它不出事。

一、先确认现在到底是谁在续期

在动手改之前,先搞清楚现有的续期是怎么跑的。常见的四种:Certbot 的 systemd timer、acme.sh 的 cron、宝塔面板的内置任务、以及手工 crontab。它们可能同时存在 —— 两套程序抢同一个证书目录是最容易出的故障。

# 1. systemd 定时器(Certbot 官方包默认走这条)
systemctl list-timers --all | grep -iE 'certbot|acme'
systemctl cat certbot.timer 2>/dev/null | grep -E 'OnCalendar|Persistent'

# 2. cron 里的 acme.sh
crontab -l | grep -i acme
ls -l /etc/cron.d/ | grep -iE 'acme|cert'

# 3. 证书到底存在哪、谁在管
ls -l /etc/letsencrypt/live/
ls -l ~/.acme.sh/ 2>/dev/null | head

# 4. 证书实际的到期时间(不要相信面板显示)
openssl x509 -in /etc/letsencrypt/live/www.example.com/fullchain.pem -noout -dates -subject

第 4 条一定要跑。notAfter 才是事实,面板上的「有效期剩余」经常是缓存的旧值。subject 里的 CN 也要看一眼 —— 证书放对了目录但 CN 是别的域名,Nginx 照常加载,浏览器照常报错。

二、续期失败的六种真实原因

1. 验证路径被前面的规则挡住了

HTTP-01 验证要求 http://域名/.well-known/acme-challenge/随机串 能被外网访问到。最容易出问题的地方是:站点里有一条「全站跳 https」的规则,而 ACME 验证走的是 80 端口明文 —— 一跳转,验证就失败。

server {
    listen 80;
    server_name www.example.com example.com;

    # 必须放在所有 return/rewrite 之前,且 allow all
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/letsencrypt;
        default_type "text/plain";
        allow all;
        try_files $uri =404;
    }

    location / {
        return 301 https://www.example.com$request_uri;
    }
}

注意 ^~ 的作用:前缀匹配且跳过正则 location,这样后面无论有什么 \.php$ 的正则都不会抢走这个路径。不加 ^~ 时,如果站点有 location ~ \. 这类兜底规则,验证文件可能被交给 PHP 处理,返回 404。

2. CDN 缓存了验证路径

如果域名前面有 CDN,验证请求打到的可能是 CDN 缓存的 404 页面。要么在 CDN 上把 /.well-known/* 设为不缓存并回源,要么改用 DNS-01 验证(不需要 80 端口可访问,只要求你能改 DNS 记录)。

DNS-01 对个人站其实更省心:不依赖任何 HTTP 路径,也不受 CDN 影响。acme.sh 对常见 DNS 服务商都支持自动添加 TXT 记录:

# 以 Cloudflare 为例,凭证写到账号配置里,后续自动续期无需人工
export CF_Token="你的API令牌"
export CF_Account_ID="你的账号ID"

~/.acme.sh/acme.sh --issue --dns dns_cf \
  -d example.com -d '*.example.com' \
  --keylength ec-256

通配符证书只能走 DNS-01,这一条没有替代方案。

3. 续期成功但 Nginx 没重载

这是最隐蔽的一种:新证书已经写到磁盘,但 Nginx 进程还持有旧证书的内存副本。表现出来是「续期日志全是成功,网站上还是过期证书」。

# 续期的 deploy hook 必须做两件事:重载 + 验证
cat /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

# 内容示例:
#   nginx -t && systemctl reload nginx
# 注意:reload 不是 restart。reload 平滑重载不中断连接,
#       且失败时旧进程继续跑(不会因为新配置有问题而全站挂掉)

为什么强调 nginx -t &&:如果某一版配置有语法错误,systemctl reload nginx 会失败,但旧配置继续服务。不过此时如果你只写了 reload 没写 -t,失败信息只进 journal,续期脚本仍然返回 0,看起来「一切正常」。加上 -t 并让脚本非零退出,告警才有机会触发。

4. 系统时间不准

ACME 协议对时间敏感:服务器时间偏差过大,签名校验会失败,报错是含糊的 JWS verification error 或 badNonce。小内存 VPS 长时间休眠后恢复,时钟漂移很常见。

timedatectl status | grep -E 'synchronized|NTP'
# 未同步时手动强制一次
chronyc makestep   # 或 ntpdate -u pool.ntp.org

5. 速率限制打满

Let's Encrypt 有「同一注册域名每周 50 张」的限制,以及「同一组域名重复签发失败 5 次后锁定 1 小时」。反复手工重试会把自己锁住,表现为「突然怎么都签不出来」。这时候不要继续重试,等一小时再用 --dry-run 验证配置是否正确。

# 强烈建议:先用 staging 环境跑通,再对生产环境签发
certbot renew --dry-run --staging

--dry-run 本身已经走 staging,不会消耗生产配额,是排查「到底哪一步失败」的最佳手段。

6. 证书链不完整

只部署了 cert.pem 而没有部署 fullchain.pem,浏览器端能靠缓存补上中间证书,但手机端、Android 老版本、以及部分 API 客户端会直接报证书错误。判据是这条命令的输出里应该有两段 BEGIN CERTIFICATE:

openssl s_client -connect www.example.com:443 -servername www.example.com \
  </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE'

返回 1 说明只有叶子证书,链不完整;返回 2 或以上才正常。

三、三个数字:把「静默失败」变成可观测

续期这件事最怕的不是失败,而是失败没人知道。只需要在监控里守三个数字,就能覆盖 95% 的场景:

  • 剩余天数:低于 21 天告警。90 天证书在第 60 天续期,若续期失败,到第 69 天还剩 21 天 —— 留出三周修复窗口,绰绰有余。
  • 续期任务的最后成功时间:超过 70 天没有一次成功,说明续期链路已经断了,即使证书还没过期。
  • 线上实际生效的到期时间:这是检测「续期成功但没重载」的唯一手段,必须从外部 TLS 握手取,不能读磁盘文件。
#!/bin/bash
# /usr/local/bin/cert-check.sh —— 放 crontab 每天跑一次
DOMAINS="www.example.com example.com"

for d in $DOMAINS; do
  end=$(echo | openssl s_client -connect "$d:443" -servername "$d" 2>/dev/null \
        | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
  [ -z "$end" ] && { echo "CRIT $d 握手失败"; continue; }

  days=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
  if [ "$days" -lt 21 ]; then
    echo "WARN $d 剩余 ${days} 天(到期 $end)"
  else
    echo "OK   $d 剩余 ${days} 天"
  fi
done

这个脚本的关键在 openssl s_client 那一步:它走的是真实 TLS 握手,拿到的就是浏览器看到的证书。磁盘上可以是新的,线上仍然是旧的 —— 只有这样测才能发现。

四、上线顺序:先验证,再切换,最后加监控

  1. 先用 --dry-run 或 --staging 跑通完整链路,确认验证能过、deploy hook 能重载。
  2. 确认 nginx -t 通过,再正式签发。
  3. 签发后立刻做一次全量验证:外部握手到期时间、证书链层数、CN/SAN 是否覆盖所有域名、http 是否正常跳转。
  4. 最后加监控脚本并观察一周,确认每天的输出都符合预期。

顺序不能颠倒。先加监控再签发,会在还没配好验证路径时就收到一堆告警,反而掩盖真正的问题。

五、收口清单

  • 确认只有一套程序在管证书,删掉多余的 cron。
  • 80 端口的 /.well-known/acme-challenge/ 用 ^~ 前缀放行,且放在所有跳转之前。
  • 有 CDN 的站优先改用 DNS-01。
  • deploy hook 里必须 nginx -t && systemctl reload nginx,失败要非零退出。
  • 每日从外部握手取到期天数,低于 21 天告警。
  • 每月跑一次 certbot renew --dry-run,确认链路没断。

六、多域名与通配符:一张证书覆盖几个站点

很多人手上不止一个域名:主站、博客、一个小工具站。最省事的做法是给每个域名各签一张证书,各自独立续期、独立失败。更省心的做法是一张证书覆盖多个 SAN,续期一次全部搞定 —— 但也带来一个新的风险:任何一个域名的验证失败,整张证书都签不出来。

所以先做一个判断:这些域名是不是「同生共死」的。如果是同一个业务的不同入口(example.com 与 www.example.com),合并成一张完全合理。如果是一个主站加几个各自独立的小站,合并反而把风险绑在了一起 —— 小站哪天解析挂了,主站的续期跟着一起失败。

# 查看现有证书覆盖了哪些域名
openssl x509 -in /etc/letsencrypt/live/www.example.com/cert.pem -noout -text   | grep -A2 'Subject Alternative Name'

# 合并签发(注意 -d 全部列出,且通配符必须走 DNS-01)
certbot certonly --webroot -w /var/www/html   -d example.com -d www.example.com -d blog.example.com

# 拆分:为独立业务单独签一张,互不影响
certbot certonly --webroot -w /var/www/blog   -d blog.example.com

用了通配符 *.example.com 之后要注意一个语义细节:通配符只匹配一层子域。*.example.com 覆盖 blog.example.com,但不覆盖 a.b.example.com。二级子域要另外写 *.b.example.com,或者直接在 SAN 里列出具体域名。

另一个实践中的坑是 Nginx 的 server 块与证书必须一一对应。同一个 443 端口上多个 server_name,Nginx 按 SNI 选择对应的 server 块,每个块里的 ssl_certificate 决定返回哪张证书。如果某个域名的 server 块漏配了证书、只留下一个 default_server,访问那个域名就会拿到第一张证书 —— 浏览器报「证书与域名不符」,而不是「证书过期」,报错性质看起来完全不一样,很容易查错方向。

# 逐个域名验证拿到的是不是「自己那张家」
for d in example.com www.example.com blog.example.com; do
  cn=$(echo | openssl s_client -connect "$d:443" -servername "$d" 2>/dev/null        | openssl x509 -noout -subject 2>/dev/null)
  echo "$d -> $cn"
done

三个域名输出三张不同证书、各自的 CN 都对上,才说明 SNI 分流是正确工作的。如果全部输出同一个 CN,说明有 server 块没生效,去看 nginx -T | grep -A3 server_name 的实际生效配置。

七、小结

证书自动续期不是「配一次就永远不用管」的东西。它的失败几乎全是静默的:路径被跳转挡住、CDN 缓存了 404、新证书没重载、时间漂移、配额打满 —— 每一种都不会在续期命令返回时告诉你。可靠的做法是把「信任磁盘上的证书文件」换成「信任外部握手看到的到期时间」,再补一个每天跑的测试任务。三个数字守住,这件事就能真正变成无人值守。

Last modification:November 1st, 2026 at 10:25 pm

Leave a Comment