为什么你的证书总是"莫名过期"
几乎每个个人站长都经历过这样的场景:早上打开网站,浏览器弹出刺眼的红色警告"您的连接不是私密连接",一看日历才发现——SSL 证书昨天过期了。更尴尬的是,明明当初装过 certbot,也配过自动续期,为什么它还是没有续上?
这篇文章不讲空泛的概念,而是把 Let's Encrypt 证书自动续期这件事从头到尾拆开:ACME 协议到底在做什么、certbot 的续期机制怎么运作、nginx 环境下的 hooks 怎么写、以及那些真正会让续期悄悄失败的坑。
一、先搞懂 ACME 协议在做三件事
Let's Encrypt 使用的是 ACME 协议(Automatic Certificate Management Environment)。不管用 certbot、acme.sh 还是 lego,本质流程都是三步:
1. 账户注册:客户端生成一对账户密钥,向 ACME 服务器注册。这个账户密钥和你的域名证书私钥是两回事,后面"账户被吊销"的坑就出在这里。
2. 域名所有权验证:这是最关键的一步。ACME 服务器会给你一个随机 token,要求你在指定位置证明"这个域名是你的"。常用两种方式:
- HTTP-01:在
http://你的域名/.well-known/acme-challenge/<token>放一个特定内容的文件,ACME 服务器从公网来抓。 - DNS-01:在
_acme-challenge.你的域名加一条 TXT 记录。适合泛域名(wildcard)证书。
3. 签发证书:验证通过后,客户端生成证书私钥并提交 CSR,ACME 服务器签发证书返回。
理解了这三步,你就明白了一个核心事实:自动续期失败,90% 的概率是卡在第 2 步——域名验证。因为续期时 ACME 服务器依然要重新验证一次所有权,而不是因为你以前验证过就免检。
二、certbot 的续期机制:两个 systemd 单元
用 apt install certbot python3-certbot-nginx 装的 certbot,会在系统里注册两个东西:
systemctl list-timers | grep certbot
# 通常你会看到:
# certbot.timer ... next: 今天 08:23
# certbot.service 静态certbot 自带一个 certbot.timer,默认每天跑两次——随机选一个时间(避免全世界的服务器同时冲击 Let's Encrypt 的接口),执行 certbot renew。
注意 certbot renew 和 certbot certonly 的区别:renew 会遍历 /etc/letsencrypt/renewal/ 下每一个 *.conf,逐个判断"这张证书是否在 30 天内到期"。只有在 30 天内才真正去续。所以看到 certbot renew 输出 "not due for renewal" 是正常的,不代表出错。
手动模拟一次试运行,这是上线后必做的检查:
certbot renew --dry-run--dry-run 会使用 Let's Encrypt 的 staging 环境,完整走一遍验证和签发流程,但不消耗正式的速率配额。如果这一步能过,说明你的自动续期基本是健康的。
三、坑一:HTTP-01 验证被 301 重定向吃掉了
这是 nginx 站点最经典的失败场景。你的配置大概是这样的:
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}看起来没问题,但 ACME 服务器访问 http://example.com/.well-known/acme-challenge/xxx 时,会被 301 跳到 https://...。ACME 规范允许跟随重定向,但如果跳转后的 HTTPS 证书恰好也要续期(或已经过期),就会形成死锁——要验证新证书,得先有有效的旧证书。
正确做法是显式放行 acme-challenge 路径,不要重定向:
server {
listen 80;
server_name example.com www.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/html;
default_type "text/plain";
allow all;
}
location / {
return 301 https://$host$request_uri;
}
}注意 ^~ 前缀——它告诉 nginx"这个 location 一旦匹配,就不要再去做正则匹配了",可以防止某些通配 location 抢先拦截。
四、坑二:webroot 路径和 nginx root 不一致
很多人第一次签发证书时用的是 certbot --nginx(自动改配置),后面某次又手动用了 --webroot 指定一个目录,结果 renewal conf 里记录的 webroot 和 nginx 实际的 root 对不上,验证文件写到了错误的地方。
检查方法,直接看续期配置文件:
cat /etc/letsencrypt/renewal/example.com.conf重点看这几行:
[renewalparams]
authenticator = webroot
webroot_path = /var/www/html,
[[webroot_map]]
example.com = /var/www/html然后确认 nginx 里 location /.well-known/acme-challenge/ 的 root 与 webroot_path 完全一致。路径不一致是那种"平时看不出问题、续期那天才炸"的隐形杀手。
五、坑三:防火墙与 CDN 挡住验证请求
如果你在服务器前面挂了 Cloudflare 之类的 CDN,并且开启了"强制 HTTPS"或者"Under Attack 模式",ACME 的 HTTP-01 请求会在 CDN 层就被处理掉,根本到不了你的 nginx。
两种解决方案:
- 暂停 CDN 代理(把 DNS 记录改成灰色云朵"DNS only"),验证完再打开。缺点是要手动操作,不适合全自动续期。
- 改用 DNS-01 验证。这也是泛域名证书唯一的办法。以 Cloudflare 为例,用 acme.sh 加 dns_cf 插件后,续期时通过 API 自动写 TXT 记录,完全绕开 HTTP 层,稳定性极高。
顺带说一句,如果你服务器上有 fail2ban 或者自研的 WAF 规则,注意别把 Let's Encrypt 的验证服务器 IP 段给封了。ACME 官方文档里有明确的 IP 段列表,可以直接加白名单。
六、坑四:nginx reload 没有跟上
证书续期成功了,但网站还是提示证书过期——因为 nginx 还没重新加载配置。证书文件被替换了,但 nginx 进程内存里缓存的还是旧证书。
certbot 的 --deploy-hook 就是干这个的。在续期配置里加上:
certbot renew --deploy-hook "systemctl reload nginx"或者写进 renewal conf(推荐,一次写好永久生效):
[renewalparams]
renew_hook = systemctl reload nginx更精细一点,如果一台机器上有多个 web 服务(比如 nginx 上还挂了 OpenResty、haproxy),可以用一个部署脚本统一处理:
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-services.sh
systemctl reload nginx 2>/dev/null
systemctl reload openresty 2>/dev/null
echo "$(date) certs deployed, services reloaded" >> /var/log/cert-deploy.logchmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-services.sh放在 renewal-hooks/deploy/ 目录下的脚本,会在每次成功续期后自动执行,不需要改任何单个域名的 conf。这是最省心的做法。
七、坑五:速率限制把你锁在门外
Let's Encrypt 有严格的速率限制,最常撞上的两条:
- 同一注册域名每周 50 张证书(Registered Domain 维度,即 example.com 及其子域共享额度)
- 同一账户每 3 小时 300 次新订单
调试阶段反复试错,很容易把额度耗光,然后要等一周。所以务必:
- 调试一律用
--dry-run(走 staging 环境,独立配额且宽松得多); - 实在要用 staging 签真证书,加
--server https://acme-staging-v02.api.letsencrypt.org/directory,但记得浏览器不认 staging 证书,仅用于连通性验证。
八、一套可靠的自检清单
把下面这些做成一条命令,每周跑一次,基本能消灭"证书突然过期"事故:
#!/bin/bash
# cert-check.sh — 证书健康巡检
echo "=== 1. 当前系统所有证书到期日 ==="
for d in /etc/letsencrypt/live/*/; do
name=$(basename "$d")
if [ -f "$d/fullchain.pem" ]; then
exp=$(openssl x509 -enddate -noout -in "$d/fullchain.pem" | cut -d= -f2)
days=$(( ($(date -d "$exp" +%s) - $(date +%s)) / 86400 ))
printf "%-30s %s (%s天后)
" "$name" "$exp" "$days"
fi
done
echo ""
echo "=== 2. 续期定时器状态 ==="
systemctl is-active certbot.timer && systemctl list-timers certbot.timer --no-pager | head -2
echo ""
echo "=== 3. 域名线上实际证书 ==="
for dom in example.com www.example.com; do
echo | openssl s_client -servername "$dom" -connect "$dom:443" 2>/dev/null \
| openssl x509 -noout -subject -enddate 2>/dev/null | sed "s/^/[$dom] /"
done
echo ""
echo "=== 4. 最近一次续期日志 ==="
tail -n 5 /var/log/letsencrypt/letsencrypt.log配合一个最简单的告警——当剩余天数小于 15 时发邮件或者推送到钉钉/飞书机器人——整套自动续期才算真正闭环。只要还有人需要"想起来去看一眼",这套系统就迟早会出事。
九、小结
证书自动续期这件事,工具本身已经很成熟了,真正会出问题的永远是环境:重定向规则、webroot 路径、CDN 代理、reload 缺失、速率限制。把这五点逐一核对一遍,加上每周自动巡检和到期告警,你的网站就不会再出现那个红色的安全警告了。
对个人站长来说,这类"配置一次、长期稳定"的基础设施工作,投入产出比其实非常高——花两个小时把 certbot 彻底配好,往后几年都能省下每个月提心吊胆的成本。