证书过期这件事,大部分站长都吃过一次教训,于是上了自动续期。但真正的生产事故往往不是「忘了续期」,而是续期之后出问题:证书链少了一张中间证书,部分安卓机打不开;OCSP 响应地址连不上,首屏多等了四秒;服务器吊销了旧证书,但客户端缓存的 OCSP 响应还没过期,浏览器照样放行。这些都属于「证书生命周期」的边缘地带,平时不出问题,出事就是全站不可访问。
一、先搞清楚证书链为什么必须完整
浏览器验证证书时,需要从站点证书一路追到受信任的根证书。中间那一两层叫中间证书,很多服务器只部署了域名证书本身,把中间证书漏了。桌面 Chrome 通常会自动补齐(它内置了中间证书缓存),所以站长的电脑上一切正常;但老安卓、部分 Java 客户端、微信内置浏览器不会补,直接报「证书链不完整」。
标准做法是把「域名证书 + 中间证书」按顺序拼进一个文件,Nginx 的 ssl_certificate 指这个拼接文件,ssl_certificate_key 指私钥。顺序不能反,域名证书必须在前。Let's Encrypt 生成的 fullchain.pem 就是已经拼好的,直接用它就行,别用 cert.pem。
验证方法是让服务器自己说明它发了什么:
echo | openssl s_client -connect www.example.com:443 -servername www.example.com -showcerts 2>/dev/null | grep -E '^ *[0-9]+ s:'输出应该是两到三行,依次是叶子证书、中间证书,最后一行如果是根证书,说明多发了,虽然不致命但浪费字节。只有一行就说明链断了,必须补。
二、OCSP stapling:省一次外部请求,但也可能拖慢握手
传统 OCSP 验证是客户端拿着证书去 CA 的 OCSP 服务器问「这张证书吊销了吗」,这一步多一次外部请求,而且 CA 的 OCSP 服务器经常慢甚至挂掉。OCSP stapling 的思路是:让服务器自己去 CA 取回签名的 OCSP 响应,握手时一并发给客户端,客户端不用再出门。
Nginx 里开启只要三行:
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/chain.pem;
ssl_stapling_responder http://ocsp.int-x3.letsencrypt.org;第三个参数 ssl_trusted_certificate 是最容易漏的:它需要一份包含中间证书和根证书的完整链文件,用于验证 OCSP 响应的签名。如果只给叶子证书,ssl_stapling_verify on 会静默失效,stapling 打不开。
第四个 responder 建议显式写死。Nginx 默认会从证书的 AIA 字段里解析 OCSP 地址,但某些 CA 的证书里 AIA 字段有多个 URL 或用 IPv6 地址,解析失败时 stapling 就哑了。写死一个 IPv4 可达的地址最省心。
排查要点:openssl s_client -status 的输出里要看 OCSP Response Status: successful 和 This Update 的时间。如果显示 no response sent,说明 Nginx 没取到响应,通常是出站被防火墙挡了或者 trusted_certificate 配错。还有一个常被忽略的现象:Nginx 冷启动后第一次握手往往没有 stapling 响应,因为它要异步去取,抓一次可能看不到,多抓几次再看。
如果站点 SSL 是终止在 CDN 或负载均衡上,那 OCSP stapling 要在那一层开,源站 Nginx 开了也没用,因为客户端根本连不到它。这一点搞混会白折腾半天。
三、吊销状态:CRL 已经基本退场
CRL(证书吊销列表)是早期方案,客户端定期下载一个可能几百 KB 的列表。现在主流浏览器已经基本不用 CRL 了,因为列表太大、更新太慢。取而代之的是 OCSP 和浏览器厂商自己的吊销推送(Chrome 的 CRLSet)。
这意味着一个现实:如果你的私钥泄露了,光去 CA 申请吊销是不够的,因为部分客户端依赖缓存的 OCSP 响应,最长可能几小时到几天仍然认为证书有效。真正可靠的补救是尽快换一张新证书、同时把旧证书的吊销提交出去,然后盯着访问日志看有没有异常流量。别指望「吊销」是个瞬间生效的开关。
顺带说一个反向问题:有些站长为了「安全」把 OCSP 的 Must-Staple 扩展(证书里的 must-staple 标志)打开,结果 stapling 一旦取不到响应,全站直接不可访问,比不 stapling 风险更大。除非你有完善的监控,不建议开。
四、续期本身:让 renew 之后自动生效
certbot 续期只是把新文件写到磁盘,Nginx 不会自动读新证书,必须 reload。很多人 cron 里只写了 certbot renew --quiet,结果证书续了但进程还在用旧的,到期那天照样挂。正确写法是用 --deploy-hook:
certbot renew --quiet --deploy-hook "systemctl reload nginx"--deploy-hook 只在真的续了证书时才触发,比无脑每次 reload 好。reload 是平滑的,不会断连接。
还有两个细节。第一,certbot 的续期定时任务在 Debian 上是 certbot.timer(systemd 时代)而不是 cron,用 systemctl list-timers 确认它真的在跑,别以为装了就有。第二,续期时间窗是到期前 30 天,如果续期失败了不会有明显告警,只在日志里留一行,建议自己加一个监控:每天早上跑一次 openssl s_client 取到期时间,小于 20 天就发消息,这条监控能救你一次。
五、多域名证书的「全有或全无」
一个证书上挂多个域名(SAN 证书)时,如果某个域名的 DNS 验证失败了,certbot 默认整张证书续期失败,其他域名一起遭殃。而且失败的那个域名往往已经不再解析了——你早就不用的测试子域,成了全站证书过期的原因。
处置方式:续期时用 --cert-name 明确指定证书名,然后用 certbot certificates 列出每张证书的域名列表和到期日,把已经不用的域名从证书里剔除(certbot --cert-name xxx -d a.com -d b.com 会重签并只保留这两个)。
更稳的架构是每个域名一张证书,而不是一张证书挂十个域名。这样某个域名出问题只影响它自己,而且单张证书小一点,TLS 握手也轻一点。缺点是管理成本略高,用证书清单加一张巡检表就能解决。
六、换证书之后的验证清单
每次续期或换证书后,按这个顺序验一遍,五分钟能覆盖 90% 的问题:
第一步,看链是否完整:openssl s_client -showcerts 至少两行 s:。第二步,看到期时间:openssl s_client ... | openssl x509 -noout -dates,确认是新的日期。第三步,看 OCSP:加 -status 参数,确认响应成功且时间是近期的。第四步,用不带 SNI 的方式连一次,检查默认站点是否发错证书(多站点共用 443 时常见)。第五步,从外部网络用一个在线 TLS 检测工具跑一次,看协议版本、加密套件、链路径是否全绿。
最后提醒一个容易翻车的操作习惯:不要在半夜自动续期之后什么都不看就不管了。自动化的价值在于省人力,不是省眼睛。每天花十秒看一条到期天数,比事故之后花两小时救火划算。
七、证书监控的最小可用实现
前面提到每天查一次到期天数,这里给一个能直接用的写法。它不依赖任何第三方服务,也不用装监控软件,二十行以内的 shell 就够:
#!/bin/sh
HOST=www.example.com
END=$(echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null | openssl x509 -noout -enddate | cut -d= -f2)
LEFT=$(( ( $(date -d "$END" +%s) - $(date +%s) ) / 86400 ))
echo "$HOST 证书剩余 ${LEFT} 天"
[ "$LEFT" -lt 20 ] && echo "WARN: 需要检查续期"放进 cron 每天跑一次,输出直接进日报。关键是不只监控自己发的证书,还要监控「客户端实际收到的那张」。如果 SSL 终止在 CDN,源站的证书正常但 CDN 上的证书过期,本地查源站是查不出来的——这种情况应该直接连域名 443 去查,也就是上面这个脚本的写法,而不是去读 /etc/nginx/ssl 下的文件日期。两者偶尔会不一致,而客户端只认后者。
还有一个经常被忽略的指标:证书的 SAN 列表里每个域名都要能正常握手,别只测主域名。多域名证书里挂着一个已经停用的子域时,客户端访问那个子域会直接报错,而主域名一切正常,很容易被漏掉。
另一个值得加进监控的项是「协议与套件是否被意外改动」。系统升级 OpenSSL 之后,某些服务重启可能带上旧的默认配置,把 TLS 1.0 又打开了。检查方式是一次握手加版本限定:openssl s_client -tls1_2 应该成功,-tls1 应该失败。偶尔跑一次,能及时发现配置漂移。