证书过期事故:一次凌晨被叫醒的教训
对个人站长来说,最冤枉的一种线上故障是"证书过期"。网站代码没动、服务器没重启、数据库好得很,结果某天早上访客一打开就跳红色警告页,流量断崖式下跌,Google 那边甚至开始标记你的站点"不安全"。而这一切的起因,只是某张 90 天有效期的 Let's Encrypt 证书没有在半夜自动续上。
更让人恼火的是,这类问题往往不是"没配自动续期",而是配了自动续期,但它悄悄失败了几个月,你没发现。certbot 的 systemd timer 每天跑、exit code 也正常,但续期请求因为 DNS 解析、80 端口被占用、webroot 路径写错等原因失败,日志静静躺在那里,直到证书真正过期才暴露。
本文把 HTTPS 证书自动续期这条链路拆成几段——续期机制怎么跑、为什么静默失败、怎么加监控、出事了怎么手动救,并给一套可以直接照抄的排查和自检脚本。文中命令同时覆盖 certbot 和 acme.sh 两套主流工具,你用哪套都能对号入座。
续期是怎么"自动"的:timer 与 cron 的真实行为
很多人以为装了 certbot 就万事大吉,实际上自动续期依赖一个独立的后台任务。用 apt 装的 certbot,会带一个 systemd timer:
systemctl list-timers | grep certbot
# 典型输出
# NEXT LEFT UNIT ACTIVATES
# Tue 2026-09-24 00:00:00 UTC 6h certbot.timer certbot.service
systemctl status certbot.timer
systemctl cat certbot.timer关键在于 certbot.timer 每天触发两次(OnCalendar=*-*-* 00,12:00:00 加随机延迟),但它调用的 certbot renew 只在证书剩余有效期小于 30 天时才会真正续期。所以 timer 天天跑,绝大多数时候是"什么也没做,正常退出"——exit code 0。这就埋下了第一个坑:任务失败和任务什么都没做,在退出码上看起来是一样的。
如果你用的是 acme.sh,机制不同:它靠 cron 行驱动,安装时写入 crontab:
crontab -l | grep acme
# 0 0 * * * "/root/.acme.sh"/acme.sh --cron --home "/root/.acme.sh" > /dev/null注意这里把输出重定向到了 /dev/null——续期失败的信息全部被丢掉,这是 acme.sh 默认安装的经典陷阱。想留下日志,得改成写文件或走 syslog。
第一件该做的事:确认你的续期任务真的存在,并强制演练一次续期。certbot 有现成的演练命令,它不会真的替换证书,只验证续期流程能不能走通:
certbot renew --dry-run
# acme.sh 对应:acme.sh --renew-all --dry-run这是整个排查流程里最有价值的一条命令。dry-run 通过,说明验证方式、路径、权限这条链路是通的;dry-run 报错,那就等于提前发现了三个月后必然发生的事故。
静默失败的五大成因
下面这些是我实际遇到过或帮别人排查过的原因,按出现频率排序。
原因一:webroot 路径写错或权限不足
用 --webroot 验证时,ACME 服务器会在 http://你的域名/.well-known/acme-challenge/xxx 下放一个文件,然后从公网请求它。如果 webroot 指向的目录和 Nginx 实际提供的根目录不一致,或者该目录对 Nginx 用户(通常是 www-data)不可读,验证就会 404:
certbot certonly --webroot -w /var/www/html -d example.com --dry-run
# 报错示例:
# Invalid response from http://example.com/.well-known/acme-challenge/xxx: 404排查方法:手动造一个测试文件,看看能不能从公网访问到。这比反复跑 certbot 看报错高效得多。
mkdir -p /var/www/html/.well-known/acme-challenge
echo "hello-acme" > /var/www/html/.well-known/acme-challenge/testfile
chown -R www-data:www-data /var/www/html/.well-known
curl -s http://example.com/.well-known/acme-challenge/testfile
# 应该输出 hello-acme。若是 404,问题在 webroot 或 Nginx location 配置另外,如果 Nginx 里给 /.well-known/ 配了 deny all、或者把根目录 rewrite 到 index.php 的规则太激进,也会导致验证文件访问不到。给 ACME 验证路径单独放行是个好习惯:
location ^~ /.well-known/acme-challenge/ {
root /var/www/html;
default_type "text/plain";
allow all;
}原因二:验证方式与端口冲突
默认的 --standalone 会临时占用 80 端口自己起一个服务器。如果这时 Nginx 正在跑,就会报 Address already in use。解决方法二选一:用 webroot 而不是 standalone(推荐,不停服),或者给 standalone 配置 --http-01-port 并在防火墙放行。
还有一类更隐蔽的:80 端口被重定向到 443。ACME 的 http-01 验证要求通过 http 访问到验证文件,如果你在 Nginx 里做了一个"所有 80 请求 301 到 https"的全局跳转,验证请求跟着跳转过去,某些验证场景下会失败。稳妥做法是让 /.well-known/acme-challenge/ 在 80 端口的 server 块里不被重定向。
原因三:DNS 解析问题或 CNAME 指向了 CDN
https 验证(dns-01)需要写 TXT 记录。如果域名托管在 Cloudflare 且开着橙云代理,_acme-challenge 记录可能被代理,导致验证失败。这时需要把该记录设为DNS only(灰云),或者改用 Cloudflare API 做 DNS 验证。
http-01 验证也可能被 CDN 挡住:如果域名解析到 CDN,ACME 服务器请求验证文件时打到的是 CDN 边缘节点,而你的源站还没同步到那个文件,就会 404。ACME 官方于是提供了绕过机制,但配置繁琐。最省事的做法是直接把 _acme-challenge 指向源站 IP(灰云),或者干脆用 DNS 验证。
原因四:cron 环境变量缺失
手动跑 certbot renew 成功,放到 cron 里就失败——十有八九是 PATH 或容器环境问题。cron 的默认 PATH 很干净(通常只有 /usr/bin:/bin),如果你用的 certbot 装在其他路径,或者脚本里依赖某个命令,就会 command not found。排查时把 strace 或简单日志加上:
# crontab 里加上日志重定向,别再丢进 /dev/null
17 3 * * * /usr/bin/certbot renew --quiet >> /var/log/certbot-cron.log 2>&1还有一种情况:certbot 装在 Docker 容器里,宿主机的 cron 跑不到容器内的 certbot。这种要么用宿主机的 certbot 直接读挂载出来的证书目录,要么用 ACME 官方的 DNS 插件 + API 方式在宿主机上签。
原因五:证书文件权限被改,Nginx reload 失败
续期成功但网站还是旧证书,问题往往出在续期后的 reload 钩子上。Let's Encrypt 默认把私钥放在 /etc/letsencrypt/live/域名/privkey.pem,属主是 root、权限 0600,Nginx 以 www-data 跑的时候可能读不到,导致 reload 失败、继续用内存里的旧证书。给一条 --deploy-hook 让续期后自动 reload:
certbot renew --deploy-hook "systemctl reload nginx"但请先手动测试这条命令能不能跑通——systemctl reload nginx 在权限、容器环境下都可能失败。更好的做法是写一个独立的 hook 脚本,加上日志,出问题能查到。
加一层监控:让证书过期之前就报警
因为 certbot 的失败是静默的,监控就成了必需品。最土但最有效的方法:写个脚本每天检查所有证书的剩余天数,低于 15 天就发通知。下面这段可以直接放进 cron:
#!/bin/bash
# /usr/local/bin/check-certs.sh
# 检查 /etc/letsencrypt/live 下所有证书的剩余有效期
WARN_DAYS=15
ALERT_LOG=/var/log/cert-expiry-check.log
HOST=$(hostname)
for dir in /etc/letsencrypt/live/*/; do
domain=$(basename "$dir")
cert="$dir/fullchain.pem"
[ -f "$cert" ] || continue
end=$(openssl x509 -enddate -noout -in "$cert" | cut -d= -f2)
end_ts=$(date -d "$end" +%s)
now_ts=$(date +%s)
days=$(( (end_ts - now_ts) / 86400 ))
if [ "$days" -lt "$WARN_DAYS" ]; then
echo "$(date '+%F %T') [$HOST] WARN: $domain 证书仅剩 $days 天" >> "$ALERT_LOG"
# 这里接上你的通知渠道:邮件 / Telegram / Server酱 / 企业微信机器人
# curl -s "https://api.telegram.org/bot/sendMessage" \
# -d chat_id= -d text="$domain 证书仅剩 $days 天"
else
echo "$(date '+%F %T') [$HOST] OK: $domain 剩余 $days 天" >> "$ALERT_LOG"
fi
done 如果嫌自己写脚本麻烦,也可以用现成的 ssl-cert-check 工具,或者把域名加进 Uptime Kuma / 健康检查服务的 HTTPS 到期监控里——很多监控服务都自带"证书剩余天数"这个指标。重点是报警阈值要留足时间:15 天意味着你还有两周去处理,如果只留 3 天,那天你正好出差就完了。
真过期了怎么救:不停服的手动续期流程
假设监控没配上,证书已经过期了,网站正在报错。这时候别慌,按下面顺序走,通常五分钟内能恢复。
第一步,确认是不是真的过期。用 openssl 直接读线上证书,比在浏览器里看更准:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates -subject
# notAfter=... 就是到期时间,和当前时间对比第二步,强制续期。certbot 平时不给还没到期的证书续期,但过期了可以直接续:
certbot renew --force-renewal
# 或者只处理指定域名
certbot certonly --webroot -w /var/www/html -d example.com -d www.example.com --force-renewal第三步,reload Nginx 加载新证书。注意是 reload 不是 restart,reload 不中断现有连接:
nginx -t && systemctl reload nginx第四步,线上复验。再跑一次第一步的 openssl 命令,确认 notAfter 已经更新。浏览器可能有缓存,用 curl -v 或者换台机器验证更可靠。
如果续期报错,看 /var/log/letsencrypt/letsencrypt.log——certbot 的详细错误全在里面,比终端输出详细得多:
tail -100 /var/log/letsencrypt/letsencrypt.logacme.sh 的日志则在 ~/.acme.sh/acme.sh.log,同样先看这里再动手。
把续期做成"不可能静默失败"
最后总结一下让这条链路可靠的核心原则,按重要程度排:
- 定时 dry-run 演练。每个月手动跑一次
certbot renew --dry-run,比任何事后补救都便宜。 - 绝不静默。
/dev/null是续期脚本的头号杀手,所有 cron 任务都必须留日志。 - 续期后必须 reload。加
--deploy-hook,并单独测试这个钩子。 - 加到期监控。剩余 15 天报警,给你留出处理窗口。
- 优先用 webroot + DNS 验证,避免 standalone 的端口抢占和 80 跳转问题。
证书续期这件事的可怕之处不在技术难度,而在它的静默性——它可以在你完全没察觉的情况下失败好几个月。所以配置的目标不是"跑起来",而是"失败了会大声告诉你"。做到这一点,你就再也不会在某个凌晨因为一张过期的证书而被叫醒。