免费证书的坑不在于申请,而在于续期。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.org5. 速率限制打满
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 握手,拿到的就是浏览器看到的证书。磁盘上可以是新的,线上仍然是旧的 —— 只有这样测才能发现。
四、上线顺序:先验证,再切换,最后加监控
- 先用
--dry-run或--staging跑通完整链路,确认验证能过、deploy hook 能重载。 - 确认
nginx -t通过,再正式签发。 - 签发后立刻做一次全量验证:外部握手到期时间、证书链层数、CN/SAN 是否覆盖所有域名、http 是否正常跳转。
- 最后加监控脚本并观察一周,确认每天的输出都符合预期。
顺序不能颠倒。先加监控再签发,会在还没配好验证路径时就收到一堆告警,反而掩盖真正的问题。
五、收口清单
- 确认只有一套程序在管证书,删掉多余的 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、新证书没重载、时间漂移、配额打满 —— 每一种都不会在续期命令返回时告诉你。可靠的做法是把「信任磁盘上的证书文件」换成「信任外部握手看到的到期时间」,再补一个每天跑的测试任务。三个数字守住,这件事就能真正变成无人值守。