证书过期不是「忘了续」,而是「续了没生效」
用 Let's Encrypt 的个人站长几乎都遇到过这种场面:某天早上打开网站,浏览器红着脸告诉你 NET::ERR_CERT_DATE_INVALID,一查发现证书三天前就过期了。但奇怪的是,服务器上的 certbot 定时任务明明在跑,日志里也写着续期成功。问题到底出在哪?
本文不教你「怎么申请第一张证书」——那种教程遍地都是。本文要解决的是更折磨人的第二阶段问题:证书明明续期成功了,客户端却还在用旧的。这个坑涉及 ACME 挑战方式、Nginx 配置加载、systemd 定时器、以及通配符证书的特殊要求,环环相扣,任何一环出错都会导致「续了等于没续」。
先搞清楚 certbot 的三种运行形态
在你排查任何问题之前,必须先知道服务器上的 certbot 到底是怎么被调起来的。很多人踩坑就踩在「我不知道它是谁在跑」。
形态一:独立运行的 systemd timer。通过 apt install certbot 安装的版本,通常会自带一个 certbot.timer。查看命令:
systemctl list-timers | grep certbot
systemctl status certbot.timer如果是 timer 驱动的,续期逻辑由 certbot renew 负责,它会扫描 /etc/letsencrypt/renewal/ 下每张证书的配置文件,检查剩余有效期是否小于 30 天,小于才真正续期。
形态二:Certbot 的 nginx 插件自己管。如果你当初用 --nginx 安装,续期时 certbot 会尝试自己修改并 reload Nginx。这种情况下,如果 Nginx 配置后来被手工改动过、或者 --nginx 插件解析配置失败,续期就会静默地跳过 reload。
形态三:acme.sh 或 Caddy 等第三方客户端。如果你用的是 acme.sh,它的续期靠的是 crontab -l 里的一条定时任务,默认每天凌晨随机分钟执行。检查:
crontab -l | grep acme
~/.acme.sh/acme.sh --list先确认形态,再谈排查。盲目地「再手动跑一次 certbot renew」往往掩盖了真正的病根。
头号病根:续期成功,但服务没重新加载证书
这是最高频、也最隐蔽的一种失败。Let's Encrypt 的证书文件是就地覆盖的:/etc/letsencrypt/live/example.com/fullchain.pem 是软链接,指向 ../../archive/example.com/fullchain5.pem 这样的归档文件。续期成功后软链接指向新的归档文件,但——正在运行的 Nginx 进程仍然持有旧文件的内存句柄。
Nginx 只有在收到 reload(systemctl reload nginx)或重启时,才会重新打开证书文件。如果续期钩子没有触发 reload,Nginx 就会继续吐着旧证书,直到证书真正过期那天,报错才浮出水面。
验证方法——比较磁盘上的证书和实际服务返回的证书指纹:
# 磁盘上证书的到期时间
openssl x509 -enddate -noout -in /etc/letsencrypt/live/example.com/fullchain.pem
# 线上服务实际返回的证书到期时间
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -enddate -noout如果这两个日期不一致(线上返回的更早),那就是「没 reload」实锤了。
修复方式是配置 deploy hook。certbot 支持 --deploy-hook,在证书真正更新的那次续期后执行。编辑 renewal 配置或使用命令行:
certbot renew --deploy-hook "systemctl reload nginx"更稳妥的做法是写一个脚本,让钩子只在实际更新时运行(deploy hook 本身就满足这个条件,它只在证书变化后触发),并做好错误处理:
#!/bin/bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
systemctl reload nginx
if [ $? -ne 0 ]; then
echo "nginx reload 失败" 1>&2
exit 1
fi注意:放在 renewal-hooks/deploy/ 目录下的脚本,certbot 会自动为每一次成功续期执行,不需要额外指定 --deploy-hook。这是比写在命令行里更稳妥的方式,因为它不会因为某次手动执行忘了带参数而失效。
第二号病根:HTTP-01 挑战被 CDN 或跳转打乱
新证书签发失败,日志通常会给一个明确线索。先看 certbot 的日志:
grep -i 'error\|failed\|challenge' /var/log/letsencrypt/letsencrypt.log | tail -30如果看到 Timeout during connect 或 Invalid response,大概率是 HTTP-01 挑战没走通。HTTP-01 的原理是:Let's Encrypt 从一个随机的出口 IP 访问 http://example.com/.well-known/acme-challenge/一串token,读取文件内容验证你对域名的控制权。
这里有几个常见的破坏点:
- CDN 缓存了 404。如果你套了 Cloudflare 之类的 CDN,
/.well-known/路径的 404 结果可能被缓存,导致后续挑战全都失败。解决:在 CDN 里对/.well-known/*设置 bypass cache。 - HTTP 强制跳转 HTTPS。你的 80 端口做了 301 跳到 443,而 certbot 的挑战文件放在别处,跳转后 404。certbot 通常能处理跳转,但如果跳转目标本身有 CDN 拦截就会失败。
- Nginx 的 default_server 抢了请求。挑战请求的 Host 头如果没匹配到正确的 server 块,会落到默认站点,读到空目录。
一个诊断技巧是手动模拟挑战请求:把 webroot 目录设好,然后自己 curl 一下那个 token 文件,确认从公网能读到纯文本。读不到,就是 Nginx 路由或 CDN 的问题,与 certbot 无关。
通配符证书必须用 DNS-01,别硬套 HTTP-01
很多人想给自己的所有子域颁发一张通配符证书 *.example.com,于是写:
certbot certonly --webroot -d example.com -d '*.example.com'这会直接报错:Wildcard domains require DNS-01 challenge。因为 HTTP-01 无法验证「某个随机子域」的归属——你不知道 Let's Encrypt 会来访问哪个子域,也就没法提前放好文件。通配符只能靠 DNS-01:在域名的 DNS 里放一条 _acme-challenge 的 TXT 记录。
DNS-01 的自动化依赖 DNS 服务商的 API。用 certbot 的 DNS 插件(如 certbot-dns-cloudflare):
# 安装插件
apt install python3-certbot-dns-cloudflare
# 准备凭证文件(务必 chmod 600)
cat > /root/.secrets/cloudflare.ini <<'EOF'
dns_cloudflare_api_token = 你的API令牌
EOF
chmod 600 /root/.secrets/cloudflare.ini
# 申请通配符证书
certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
-d example.com -d '*.example.com'用 API 令牌而不是全局 API Key,可以限制权限只到「编辑 DNS」范围,更安全。DNS-01 的一个隐患是传播延迟:有些 DNS 服务商(尤其是免费套餐)的 TXT 记录生效慢,certbot 查询权威服务器时读到旧记录就判定失败。可以在插件参数里加大等待时间,比如给 cloudflare 插件加 --dns-cloudflare-propagation-seconds 60。
为什么「手动跑一次 renew」验证不了自动续期
很多站长遇到证书问题时,第一反应是手动敲一遍 certbot renew,看到「证书未到期,跳过」或「续期成功」,就以为问题解决了。这是一个危险的错觉。手动执行和定时器自动执行,走的是两套完全不同的环境。
手动执行时,你是以 root 身份在交互式 shell 里运行,环境变量、PATH、工作目录都是你登录会话的。而 systemd timer 触发时,进程运行在一个干净的最小环境里,PATH 可能不含 /usr/local/bin,工作目录是根目录,你写在 shell 配置文件里的自定义变量一律不存在。如果 deploy hook 脚本依赖某个环境变量、或者用相对路径引用文件,手动跑成功、自动跑失败,就成了必然。
所以正确的验证方法不是手动跑,而是检查 timer 的真实日志和触发记录:
# 看 timer 是否按时触发过
systemctl list-timers certbot.timer
journalctl -u certbot.service --since "30 days ago"更进一步的验证是 certbot renew --dry-run(下文详述),它能在 staging 环境完整模拟一次自动续期,包括钩子执行的环境,比手动真跑更能暴露问题。记住一个原则:能自动跑通,才算配置完成;手动跑通,只说明命令本身没写错。
模拟续期:不做这一步,你永远不知道它是否真的能自动续
证书申请成功后,最危险的心态就是「设置好了应该没问题」。ACME 的续期逻辑只有在临近过期(不足 30 天)时才会真正执行,你没法等 60 天来验证。所以必须用 --dry-run 做一次全真模拟:
certbot renew --dry-run--dry-run 会走完整的 ACME 流程(包括挑战、签发、钩子),但用的是 staging 环境,不会消耗真实额度,也不会覆盖你的证书。如果 dry-run 失败,说明自动续期一定会挂,务必解决后再走。
需要注意:某些 deploy hook 在 dry-run 时也会执行,如果你不希望模拟时真去 reload 服务,可以加条件判断,检查环境变量 $RENEWED_LINEAGE 是否存在。
如果连 dry-run 都跑不起来,注意 staging 环境有严格的速率限制(每个域名每分钟 5 次失败验证),频繁重试会被临时封禁。等一会儿再试,别死循环猛撞。
一套完整的排查清单
把上面的内容浓缩成一张可以照着做的清单,遇到证书问题按顺序走:
- 第一步,确认谁在续期。
systemctl list-timers | grep certbot和crontab -l,先确定调度器存在且真的在跑。 - 第二步,看日志不猜。
/var/log/letsencrypt/letsencrypt.log是唯一的事实来源,搜 error 关键字,别凭感觉。 - 第三步,比对证书指纹。磁盘上的到期时间 vs 线上
openssl s_client返回的到期时间,不一致就是没 reload。 - 第四步,检查 deploy hook。确认
renewal-hooks/deploy/下有 reload 脚本,且脚本可执行、无语法错误。 - 第五步,检查挑战路径。HTTP-01 的
/.well-known/acme-challenge/不能被 CDN 缓存,不能被跳转吃掉,不能被 default_server 抢走。 - 第六步,wildcard 走 DNS-01。需要 API 令牌,注意传播延迟,凭证文件权限 600。
- 第七步,跑 dry-run。模拟成功才算真正配置完成,否则一切都是侥幸。
最后提醒一点:给证书过期加个监控。哪怕自动续期配置得再完美,DNS 服务商改版 API、CDN 换了策略、磁盘写满导致 certbot 无法写文件——这些都会让续期在某一天突然失灵。一个最简单的方案是写个脚本每天检查证书剩余天数,少于 14 天就给自己发一封邮件告警。把「证书过期」从一个惊吓事件,降级成一个可预期的提醒,这才是一个成熟站长该有的运维习惯。