做站十来年,见过太多站点的证书过期不是因为没钱,而是因为「明明配了自动续期却没人知道它没生效」。Let's Encrypt 的证书只有 90 天,一旦自动续期在某个环节静默失败,你通常会在第 91 天早上收到浏览器的大红警告,而此时距离流量和信任的损失已经发生了。这篇文章讲的是:如何让续期从「大概配了」变成「可以证明它在工作」。
一、先确认你到底用的是什么方式在续期
最常见的误区是把 certbot 的手工申请和系统包自带的 timer 混为一谈。登录服务器先跑三条命令,把现状摸清楚。
# 1. 看证书本身的有效期和签发者
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -dates -issuer -subject
# 2. 看系统里有哪些续期任务
systemctl list-timers --all | grep -Ei 'certbot|acme|dehydrated'
crontab -l 2>/dev/null | grep -i certbot
# 3. 看最近一次续期的日志
journalctl -u certbot.timer --since '30 days ago' --no-pager | tail -40如果第 2 步里同时存在 cron 和 systemd timer,大概率有两个续期器在互相打架:一个把证书更新了,另一个还在用旧的 lineage 路径,结果 Nginx 加载的还是老文件。这种情况在从旧机器迁移、或者按网上教程装过两次 certbot 的服务器上极其常见。判断方法很简单,看 /etc/letsencrypt/renewal/ 下每个域名对应的 conf 文件,里面的 renew_before_expiry 和 archive_dir 是否一致。
另外要注意 snap 版 certbot 和 apt 版 certbot 的 timer 名字不同(snap 的通常是 snap.certbot.renew.timer),清点的时候别漏掉。
二、webroot 验证失败:九成问题在「路径可达性」而不是权限
webroot 方式续期的原理是:ACME 服务器来访问 http://你的域名/.well-known/acme-challenge/随机串,你要让这个文件被真实地返回。失败通常表现为 Invalid response ... 404 或 Timeout during connect。按下面的顺序查,不要一上来就 chmod 777。
1. 手工复现一次验证请求
# 先放一个测试文件
echo 'hello-acme' > /var/www/example.com/.well-known/acme-challenge/test
chmod 644 /var/www/example.com/.well-known/acme-challenge/test
# 从公网侧访问(不要用 curl localhost,那绕过了 CDN 和防火墙)
curl -i http://example.com/.well-known/acme-challenge/test
# 同时看 Nginx 这边到底有没有收到请求
tail -f /var/log/nginx/access.log | grep acme-challenge三种结果对应三种病因:
- 返回 200 但内容不对:说明请求被某个规则重写到了别的目录,典型是伪静态的
try_files把.well-known也吞了。 - 返回 404 且 access.log 有记录:root 指错了目录,或者
location ^~ /.well-known/acme-challenge/块里写了别的 alias。 - 返回 404/超时 但 access.log 完全没记录:请求根本没到这台机子。查 CDN 是否缓存了 404、防火墙是否只放了 443、以及是否有 DNS 劫持/CDN 回源到别的节点。
2. 修 try_files 的正确姿势
伪静态站最容易踩这个坑,因为 try_files $uri $uri/ /index.php?$query_string; 会把挑战文件也交给 PHP 处理。在 server 块里加高优先级的例外:
location ^~ /.well-known/acme-challenge/ {
root /var/www/example.com;
default_type "text/plain";
try_files $uri =404;
}^~ 的作用是命中后不再尝试正则 location,与 WordPress/Typecho 的重写规则不冲突。改完 nginx -t && systemctl reload nginx,再复现一次上面的 curl。
3. CDN 场景的额外一层
如果域名接了 CDN,要确认 /.well-known/ 路径是回源的、而且不被缓存。有的 CDN 默认对 404 做 5 分钟缓存,你刚把文件放好、验证器来访问却拿到缓存里的 404,就会连着失败三次触发限流。做法是给这个路径单独配一条「不缓存、强制回源」的规则,或者干脆走 DNS-01 验证。
三、DNS-01:绕开端口与 CDN 的更稳方案
当你无法保证 80/443 从公网直达(比如内网机器、Cloudflare 开了小黄云、或者域名解析权不在你手上),DNS-01 是更好的选择。它通过在 DNS 里写 TXT 记录来证明所有权,完全不依赖 HTTP。
# 以 Cloudflare 为例,先拿到只对目标 zone 有编辑权限的 API Token
export CF_Token="你的token"
export CF_Account_ID="你的accountid"
certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cf.ini \
-d example.com -d '*.example.com' \
--preferred-challenges dns-01 -m you@example.com --agree-tos -n几点实战注意:
- Token 权限要最小化:Zone.DNS 的 Edit,范围限定到具体 zone。放到
chmod 600的文件里,不要写在 crontab 行内,否则ps可见。 - 泛域名证书必须走 DNS-01,这是 ACME 协议强制的,别再试 webroot 签
*.example.com。 - DNS 传播有延迟。certbot 默认会等一段时间,但自建 DNS 或二级解析商可能更慢,失败的报错往往是 TXT 记录「没找到」而不是「不对」。
- DNS-01 每次要改 DNS 记录,如果解析商有 API 限流(比如每分钟几十次),批量签多域名时把域名拆成两次任务跑。
四、systemd timer:为什么「配了」却不「跑」
certbot 包自带的 timer 通常一天跑两次,触发条件写得很聪明:只有当证书剩余有效期小于 30 天才真正去续。所以「看日志没动静」是正常的,不能作为它没工作的证据。
# 正确的检查方式:不是看有没有跑,而是看有没有成功续过
certbot certificates
# 关注 EXPIRY DATE 和 VALID 天数
# 用干跑验证整条链路真的能走通(不会消耗限流额度)
certbot renew --dry-run--dry-run 是关键。它用 staging 环境完整跑一遍验证和签发流程,如果这一步通过,基本可以确信正式续期也会通过。建议把它写进自己的月度检查清单,或者做成一个只在每天固定时间跑一次的独立 timer。
如果你用的是自建的 systemd unit,常见错误有这几个:
- unit 里没写
After=network-online.target,开机时网络还没起来就开始跑,必然超时。 deploy-hook里重载的服务写错(比如service nginx reload在 Debian 上要写/usr/sbin/nginx -s reload),导致证书更新了但没被加载。- timer 的
Persistent=true没开,机器在预定时间关机,那次续期就永久跳过了。开了之后,开机补跑。
五、deploy-hook:让新证书真正被用上
证书文件更新不等于服务用上了新证书。最稳妥的做法是统一在 /etc/letsencrypt/renewal-hooks/deploy/ 下放脚本,所有 lineage 续期成功后都会执行。
#!/bin/sh
# /etc/letsencrypt/renewal-hooks/deploy/reload-services.sh
set -e
nginx -t && nginx -s reload
systemctl reload postfix 2>/dev/null || true
systemctl reload dovecot 2>/dev/null || true
echo "$(date -Is) cert deployed" >> /var/log/cert-deploy.log注意 hook 里要 set -e 但不要因为个别服务不存在就整体失败,所以对可选的 || true。重载 Nginx 前一定先 nginx -t:如果配置本身有问题,reload 会失败,而证书已经换了,至少保证现有连接不中断。
六、一个能看见的监控:剩余天数告警
最实用的兜底不是「相信 hook」,而是「证书剩余天数低于阈值就告警」。哪怕所有自动化都失败,你也有 20 天时间手动介入。
#!/bin/sh
# /usr/local/bin/check-cert.sh
DOMAINS="example.com www.example.com"
WARN=20
FAIL=0
for d in $DOMAINS; do
END=$(echo | openssl s_client -servername "$d" -connect "$d:443" 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2)
DAYS=$(( ( $(date -d "$END" +%s) - $(date +%s) ) / 86400 ))
if [ "$DAYS" -lt "$WARN" ]; then
echo "WARN $d expires in $DAYS days" >&2
FAIL=1
fi
done
exit $FAIL把它挂进现有的监控(或 crontab 每天一次,失败时走邮件/短信),这样「证书快过期」变成了一个可度量的事实,而不是等到用户截图给你才知道。
七、排错速查表
| 现象 | 最可能原因 | 动作 |
|---|---|---|
| Invalid response / 404 | webroot 路径不可达或被重写 | 公网 curl 复现 + 查 access.log |
| Timeout during connect | 80 端口被挡或 CDN 未回源 | 换 DNS-01 或放行 80 |
| too many certificates | 反复重试触发限流 | 改用 --dry-run,等一周 |
| 证书更新了但浏览器还是旧的 | deploy-hook 未重载 | 补 hook 并 reload |
| timer 显示 inactive | 证书未进 30 天窗口 | 正常现象,用 dry-run 验证 |
最后一句经验:自动化本身也需要被监控。证书续期这件事,真正可靠的组合是「DNS-01 走通 + deploy hook 重载 + 剩余天数告警」,三道里任意一道漏了,另外两道还能兜住。