2026 年 4 月 13 日晚上,一个合作方打电话过来问"你们接口是不是挂了"。我们内部的监控全是绿色:接口能用 HTTP 调通、状态码 200、延迟正常——问题出在 TLS 那一层。浏览器打开站点是整页的红色警告 NET::ERR_CERT_DATE_INVALID,合作方的 Java 客户端回调直接抛 PKIX path validation failed: certificate has expired,curl 的报错最直白:
curl: (60) SSL certificate problem: certificate has expired
证书在 4 月 12 日 15:33 GMT 就过期了,我们是过了大约 30 小时才知道的。这 30 小时里,凡是校验证书的调用方全部失败,只有我们自己用 curl -k 和自己写的 Go 客户端(当时没开严格校验)在正常跑——所以直到有人打电话,内部没有任何告警。
这篇按我实际的排查顺序写:先确认"过期的是哪张证书",再看续期为什么没成,最后是四种我遇到过的失败原因和现在的预防措施。
一、第一步永远是看 notAfter,而且要看两个地方
很多人第一时间就去翻 /etc/letsencrypt/live/,但那是"磁盘上的证书",不等于"线上正在用的证书"。这两者可能完全不同——我这次就是。两条命令并排跑:
# ① 线上真正在用的那张:从 443 握手拿
echo | openssl s_client -connect www.ruilin.xin:443 -servername www.ruilin.xin 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
# ② 磁盘上的文件
openssl x509 -noout -dates -in /etc/letsencrypt/live/www.ruilin.xin/fullchain.pem
我拿到的结果是:
# 握手
notBefore=Jan 12 15:33:12 2026 GMT
notAfter =Apr 12 15:33:12 2026 GMT ← 已经过期
# 磁盘文件
notAfter =Jul 11 15:33:12 2026 GMT ← 三个多月后才过期
这两行日期不一致,本身就是结论:续期其实成功了,磁盘上的证书是新的,只是线上还在用内存里的旧证书。这一步省掉了后面所有的猜测,所以我把"两个日期对照"放在排查顺序的第一位。
顺带一提,-servername 不能省。同一个 IP 上可能有多个站点,不带 SNI 时握手拿到的可能是默认 server 的证书,日期看着"正常",于是排查方向整个跑偏。
二、再看续期链路:certbot 说什么
确认磁盘证书是新的之后,接着验证"续期这件事本身能不能成":
certbot certificates # 列出每张证书的域名、到期时间、renewal conf 路径
certbot renew --dry-run --cert-name www.ruilin.xin -v # 走 staging 环境完整演练一遍
tail -n 100 /var/log/letsencrypt/letsencrypt.log # 真正的报错都在这里
systemctl list-timers 'certbot*' # 定时任务真的在跑吗
我的 --dry-run 是通过的,日志里一行红字都没有。这就是这次事故最讽刺的地方:--dry-run 只证明"证书能签下来",证明不了"签下来之后线上真的换了"。前者是 ACME 链路问题,后者是部署问题,两者的排查和监控完全不是一回事。
再看 nginx 的内存状态,确认第 ④ 类原因:
ps -o lstart= -p "$(cat /run/nginx.pid)" # 进程启动时间
stat -c '%y %n' /etc/letsencrypt/live/www.ruilin.xin/fullchain.pem
进程启动于 4 月 9 日,证书文件 4 月 10 日更新——文件比进程新,说明更新之后从没 reload。这就是根因。
三、四种常见的续期失败原因
后来我把这四种都遇到或复盘过一遍(有的在自己机器上,有的在别人交接过来的服务器上)。它们的外观很像,日志关键字和验证命令不一样:
| 现象 / 日志关键字 | 原因 | 一条验证命令 |
|---|---|---|
Timeout during connect、Invalid response ... 404、NXDOMAIN | ① 域名解析换过 IP | dig +short A www.ruilin.xin; dig +short AAAA www.ruilin.xin |
Invalid response from http://.../.well-known/...: 404(跳转后是首页) | ② 80 端口被 301 跳走,校验跟过去拿不到 token | curl -sSL -o /dev/null -w '%{http_code} %{url_effective}\n' http://www.ruilin.xin/.well-known/acme-challenge/probe |
The webroot plugin requires --webroot-path、公网 404 而本机 200 | ③ webroot 路径或权限不对 | sudo -u nginx cat /var/www/html/.well-known/acme-challenge/probe |
日志全绿、certbot certificates 显示已续期,线上仍是旧证书 | ④ 续期成功但 nginx 没 reload | 第一节那两条命令,看"文件 mtime vs 进程启动时间" |
① 域名解析换过 IP。 迁移机器、上 CDN、换负载均衡之后,ACME 的 http-01 校验会打到新地址上,如果新机器上还没有那套 webroot,就是 404 或超时。最隐蔽的一种是 AAAA 记录没跟着改:A 记录已经指向新机器,AAAA 还指着老机器(或者那台机器根本没有 IPv6),而 Let's Encrypt 优先走 IPv6,于是校验稳定地打在错误的地方。迁移域名时我现在的动作是 A 和 AAAA 一起改、一起查。另外,如果前面挂了 CDN,404 会被缓存,certbot --debug-challenges 可以让挑战挂在那里等你手动 curl 一次,看看返回体到底是什么。
② 80 端口被 301 跳到 https。 这里有个细节要说清楚:校验器是允许 HTTP 跳转的,http 跳到 https 也在允许范围内,所以"配了 301"本身不是失败原因。真正失败的是跳过去之后拿不到 token——比如跳到 CDN 域名、跳转规则把路径吞掉变成跳首页、或者跳到的 443 vhost 是另一个站点直接返回 404。验证方式就是上面那条 curl -sSL,看 %{url_effective} 落在哪:如果输出是 404 https://www.ruilin.xin/index.html,说明路径被吃掉了。
修法是在 80 端口的 server 块里给 acme 路径开一条例外,剩下的才 301:
server {
listen 80;
server_name www.ruilin.xin;
location ^~ /.well-known/acme-challenge/ {
root /var/www/html;
default_type text/plain;
allow all;
}
location / { return 301 https://$host$request_uri; }
}
^~ 是前缀匹配且优先于正则 location;return 301 必须放在自己的 location 里,不能写在 server 级——写在 server 级就等于所有路径无条件跳转,acme 路径也不例外。
③ webroot 路径不对、权限不对。 certbot 的 webroot 路径记在 /etc/letsencrypt/renewal/www.ruilin.xin.conf 里的 webroot_path,它和 nginx 配置里的 root 很容易各说各话,尤其是站点目录改过一次之后。分层验证最快,从内到外四步:
mkdir -p /var/www/html/.well-known/acme-challenge
echo ok > /var/www/html/.well-known/acme-challenge/probe
sudo -u nginx cat /var/www/html/.well-known/acme-challenge/probe # ① 运行用户读得到吗
curl -s http://127.0.0.1/.well-known/acme-challenge/probe # ② 本机 nginx 能提供吗
curl -s http://www.ruilin.xin/.well-known/acme-challenge/probe # ③ 从外部能取到吗
权限上的常见误区是只给最末一级 chmod 755:路径上每一级(/var/www、/var/www/html、.well-known、acme-challenge)都要对 nginx 运行用户可读、可执行,中间有一级是 700 就全断。CentOS 系还有 SELinux 这一层:chcon -R -t httpd_sys_content_t /var/www/html/.well-known,否则会出现"本机 curl 200、公网拿不到"的诡异组合。
④ 续期成功但没 reload。 这是我这次的根因。nginx 只在启动和 reload 时读取证书文件,之后一直用内存里的那份,换文件不会自动生效。修法是一个 deploy hook:
# /etc/letsencrypt/renewal-hooks/deploy/99-reload-nginx.sh
#!/bin/sh
set -e
nginx -t && systemctl reload nginx
chmod +x 之后,放在 renewal-hooks/deploy/ 下对所有证书生效。先 nginx -t 再 reload 是有必要的:配置写错时 reload 会失败,但至少你知道原因,而不是把正在服务的 nginx 弄挂。另外提一句,续期成功并不会自动帮你修好进程状态——它只是把文件放好,剩下的是部署的活儿。
四、修复与预防
当晚的修复很简单:nginx -t && nginx -s reload,握手一看 notAfter 立刻变成 7 月,故障解除。真正费时间的是之后加的这些:
-
监控对象换成"握手结果",不要读文件。文件权限、路径、进程内存这些问题,只有从外面握手才看得见:
end=$(echo | openssl s_client -connect www.ruilin.xin:443 -servername www.ruilin.xin 2>/dev/null \ | openssl x509 -noout -enddate | cut -d= -f2) echo $(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 )) # 剩余天数阈值我定的是 30 天 warning、14 天 critical。30 天这个数字不用猜:Let's Encrypt 证书有效期 90 天,certbot 默认在剩余 30 天内续期,所以剩余不足 30 天就等于"这一轮续期没成功",正好是告警线。
-
certbot renew --dry-run进监控,每周跑一次,采集 exit code 和日志里的Failed/Timeout/404关键字。它走 staging 环境,不消耗正式配额,失败了也影响不到线上证书。 -
deploy hook 里做
nginx -t && systemctl reload nginx,并且在 reload 之后再握手一次确认notAfter变了,把结果写进日志——"续期成功但没生效"这种问题在原来的日志里是全程绿色的。 -
私钥权限要允许 nginx 运行用户读取。默认
privkey.pem是0600 root:root,如果 worker 进程不是 root,reload 会失败,报cannot load certificate ... Permission denied:chgrp nginx /etc/letsencrypt/archive/www.ruilin.xin/privkey*.pem chmod 640 /etc/letsencrypt/archive/www.ruilin.xin/privkey*.pem这里有个坑:
live/目录里是符号链接,改链接本身没用;而且每次续期会按archive/下真实文件的权限重新建立链接,所以这条规则要么写进 deploy hook,要么每次手动补齐,同时确认archive目录本身是0755可进入的。 -
别自己改续期窗口。
certbot.timer每天跑两次加随机延迟是够用的,把"剩余 3 天才续"写进配置只是在给自己制造紧张。 -
ssl_certificate用fullchain.pem,不要用cert.pem。少一段中间证书时,浏览器大多能自己补上,但老 Java 客户端和部分 Android 会报"证书链不完整",这种报错看起来和过期完全不像,很难往证书方向想。
如果重来一次,我改的其实只有一件事:证书这件事的监控对象不该是"证书文件",而是"443 握手的结果"。文件是过程,握手才是用户看到的东西。我们当时盯着 certbot.timer 有没有跑、/etc/letsencrypt/live/ 里文件新不新,这些都是"我以为在生效"的证据;而真正该被监控的那句话是——"现在从外面连上来,拿到的证书还有几天有效期"。