证书过期是自建站里最常见、也最没必要的一次停站事故。它和硬件故障、数据库崩掉不一样:后者你无法预知,前者在日历上写着到期日期,而且提前三十天就有机会发现。绝大多数人栽在同一个位置——「自动续期」配了,但从来没人验证它到底成没成。定时任务静静报错一年,某天早上浏览器弹出不安全警告,用户流失、搜索排名掉落,你才开始查日志。
下面这套流程,是我在一台跑着十几个站点的单台服务器上用了两年逐步打磨出来的:签发走 webroot 模式不动 Nginx,续期靠 systemd timer 而不是 cron,验证靠「对外真连一次 + 看剩余天数」而不是看日志里那句 success。全程不需要登录证书厂商控制台,也没有任何付费环节。
一、先搞清楚你的证书现在归谁管
动手之前先盘点。很多人服务器上其实有三套证书在打架:面板或一键脚本装的、手动 certbot 装的、CDN 托管自动签的。它们各自有自己的续期任务,互相覆盖,出问题时你不知道该看哪份日志。先把状态摸清楚。
# 1. 系统里有几份证书文件
ls -l /etc/letsencrypt/live/ 2>/dev/null
ls -l /etc/nginx/ssl/ 2>/dev/null
# 2. Nginx 实际在用的是哪一份
grep -rn "ssl_certificate " /etc/nginx/ | grep -v "#"
# 3. 这份证书还有多少天到期
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem \
-noout -subject -issuer -dates第三步的输出里 notAfter 就是硬截止时间。把这个日期写进你的台账,它是后面所有判断的基准。
如果第二步显示 Nginx 引用的路径和第三步检查的路径不是同一个文件,说明你已经在打架状态里了。这种站点的典型症状是:续期任务显示成功,但网站证书一直是旧的。原因是任务续了新文件,Nginx 加载的是另一个目录里的旧文件,重载也救不回来。
二、为什么选 webroot 而不是 standalone
ACME 协议验证域名所有权有两种主流方式。standalone 模式会临时占用 80 端口起一个小服务,这就要求 Nginx 先停掉,验证完再启动;一旦脚本中途失败,站点就停在那了。webroot 模式则是把验证文件写进你站点的一个隐藏目录,让 Nginx 照常用现有配置把它 serve 出去,全程零停机、不动运行时。
对已经上线的站点,只有 webroot 是合理选择。配置方式:
# 在站点的 server 块里加一条,让验证路径能读到文件
location ^~ /.well-known/acme-challenge/ {
root /var/www/acme;
default_type text/plain;
try_files $uri =404;
}
# 然后申请证书,注意 --webroot-path 要和上面的 root 一致
mkdir -p /var/www/acme/.well-known/acme-challenge
chown -R www-data:www-data /var/www/acme
certbot certonly --webroot -w /var/www/acme \
-d example.com -d www.example.com \
--email you@example.com --agree-tos --no-eff-email两个必查项,缺一个就会在验证阶段返回 404:location 的 root 与 -w 参数必须是同一路径;目录权限必须让 Nginx 的工作用户(Debian/Ubuntu 是 www-data,CentOS 是 nginx)可读。如果站点前面还挂了 CDN,还要确认 /.well-known/ 这条路径没有被 CDN 缓存规则或防盗链规则拦截——这是 webroot 验证失败的第三大原因,也是最隐蔽的一个。
三、续期用 systemd timer,不用 cron
certbot 装好后系统通常已经自带一个 timer。先确认它活着,再决定要不要自己写 cron。
systemctl list-timers | grep -i certbot
systemctl status certbot.timer --no-pager
cat /etc/cron.d/certbot 2>/dev/null如果 certbot.timer 存在且 active,就不要再加 cron 任务。两条续期路径同时存在时,会在同一时间段内争抢同一份 .well-known 目录和同一把锁,最坏情况是两边都失败。这就是很多教程里「加了 cron 之后反而续不上了」的真实原因。
timer 相比 cron 有三个实际好处:随机延迟可配(避开整点集体请求 CA 接口被限流)、失败会自动重试、日志进 journald 可以直接 journalctl -u certbot 查。如果你的机器是老系统只有 cron,那就写成每天跑两次,certbot 自身会在「离到期还有 30 天」之前直接跳过,不会浪费请求:
# /etc/cron.d/certbot-renew
0 3,15 * * * root certbot renew --quiet --deploy-hook "systemctl reload nginx"--deploy-hook 是这里的关键。只在真正续期成功时才重载 Nginx,避免每天两次无意义 reload;而如果没有这个 hook,续期成功后 Nginx 内存里还是旧证书,直到下次你手动重载为止——这是「续期成功了但浏览器还报警」的头号原因。
四、dry-run 只能证明「能跑通」,不能证明「会续上」
几乎每篇教程都会让你跑 certbot renew --dry-run。它有用,但经常被误读:dry-run 用的是 staging 环境,验证的是「Nginx 配置能不能让验证文件被访问到」,它不代表你的生产续期一定能成功,因为生产环境还有限流、有 CA 端策略、有磁盘权限差异。
更完整的验证是四层,我每次改完配置都跑一遍:
# 第 1 层:dry-run,确认 ACME 验证通路
certbot renew --dry-run
# 第 2 层:模拟一次真实续期(消耗一次真实签发额度)
certbot renew --force-renewal --cert-name example.com
# 第 3 层:对外真连一次,看服务端返回的证书是不是新的
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates -subject
# 第 4 层:看剩余天数,设一个告警阈值
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -checkend 1209600 \
&& echo "剩余超过14天" || echo "警告:14天内到期"第 3 层最容易被跳过,也最有价值。它走的是公网、经过 CDN、经过你全部的代理链路,返回的正是用户浏览器看到的那张证书。我在一次排查中发现:源站证书剩 80 天,公网拿到的剩 3 天——因为 CDN 侧还托管着另一份证书,源站续期和它毫无关系。
第 4 层的 -checkend 1209600 意思是检查 14 天后是否仍有效(秒数)。这个命令天生适合做监控脚本的返回值,比 grep 日志里的字符串靠谱得多。
五、把检查变成每天自动跑的告警
人不会每天看证书,所以检查必须由机器做。下面这段脚本放在 cron 里每天跑一次,只有在真的有问题时才发消息,其他时候完全静默。
#!/bin/sh
# /usr/local/bin/cert-check.sh
THRESHOLD=1209600 # 14 天,单位秒
FAIL=0
for d in /etc/letsencrypt/live/*/; do
name=$(basename "$d")
[ "$name" = "README" ] && continue
f="$d/fullchain.pem"
[ -f "$f" ] || continue
if ! openssl x509 -in "$f" -noout -checkend $THRESHOLD >/dev/null 2>&1; then
echo "CERT EXPIRING: $name $(openssl x509 -in "$f" -noout -enddate)"
FAIL=1
fi
done
if [ "$FAIL" = "1" ]; then
# 换成你自己的通知方式
printf "zz1984 cert check failed on %s\n" "$(hostname)"
fi注意脚本输出的是「哪些域名快要到期」,而不是「检查通过了」。静默即健康,这条原则在证书监控上尤其重要——否则你会把通知静音,然后它就彻底失效了。
还有一个常被忽略的角点:证书续期成功了,但你写的告警还在响。原因是脚本读的是文件,而 Nginx 内存里是旧证书,公网仍在提供旧证书。所以检查脚本应该优先用第 3 层的对外探测,源文件检查只作为兜底。两个都做,成本几乎为零。
六、六种典型故障与对应处置
一、续期成功但网站还是旧证书。缺 --deploy-hook 或 hook 里的 reload 失败(配置有语法错误时 reload 会拒绝)。处置:nginx -t 先体检,再手动 systemctl reload nginx。
二、验证返回 404。webroot 路径与 location root 不一致、目录权限不对、或者 CDN 拦了 /.well-known/。逐项排除,不要凭感觉改配置。
三、报 rate limit exceeded。同一域名一周内重复签发次数超限。这时不要再试,等窗口过去;临时用 staging 环境调试配置(--staging),确认无误后再走生产。
四、报 account key 或账号被锁。常见于多台机器共用同一份 /etc/letsencrypt 拷贝。每台机器应该用自己的账号,或者统一在一台机器签发后分发证书文件,不要共享账号密钥。
五、泛域名证书续期失败。泛域名必须走 DNS-01 验证,无法用 webroot。需要在你的 DNS 服务商处配置 API 凭据,用 certbot 的 dns 插件自动写入 TXT 记录。这一步没有任何捷径。
六、证书链不完整,部分老设备报错。用了 cert.pem 而不是 fullchain.pem。Nginx 的 ssl_certificate 必须指向 fullchain,中间证书缺失时桌面浏览器可能因为缓存而正常,手机和旧版客户端则直接握手失败。
七、给证书加一道「业务层」的保险
技术手段之外,最便宜的一道保险是台账。我的做法很简单:在运维文档里维护一张表,记录域名、签发方式、Nginx 引用路径、负责的验证模式(webroot 还是 DNS)、以及最近一次人工验证的日期。三十行不到的表格,在出问题时能省掉半小时的翻找。
另外建议在站点的 server 块里加一个显式的证书路径注释,标出它是自动签发的,避免未来某次「优化配置」时被人手工换成了别处的文件:
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # auto: certbot webroot
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # do not replace manually这类注释看上去像废话,但半年后接手的人(往往就是你自己)会感谢它。
八、小结:证书管理的三条判据
第一,续期是自动的,验证必须是手动的。自动任务只保证「尝试了」,只有 dry-run、真实续期、对外探测这三步都过,才能说「会续上」。
第二,看公网拿到的证书,不看本地文件。CDN、负载均衡、多级代理都可能让本地文件和用户看到的证书不是同一张。
第三,检查要静默,静默即健康。每天发一条「一切正常」的通知,等于培养你忽略通知的习惯,这比不告警更危险。
把这三条做扎实,证书过期这种事基本可以从你的故障清单里划掉。剩下的精力,留给那些真正不可预知的问题。