周一早上八点四十,我刚到公司楼下,手机就开始像被扔进洗衣机一样震动。客服群里第一条:“官网打不开,提示不安全”;第二条:“App 首页一直转圈”;第三条是运营发来的截图——浏览器一整页红色,正中间写着 “您的连接不是私密连接 NET::ERR_CERT_DATE_INVALID”。我盯着那个红色页面,脑海里只有一个念头:我们的证书,好像是三个月前我自己签的。
HTTPS 证书过期是最“蠢”也最“痛”的一类事故:它不复杂、100% 可预防,但一旦发生就是全站不可用,而且浏览器、App、小程序、第三方回调会一起挂。这篇把排查、修复、自动续期失败的原因和监控脚本一次性写进备忘录。
一、现象:所有入口一起红
先确认范围。证书过期的特征是:所有用 HTTPS 访问该域名的客户端同时报错,且报错信息包含 “expired / 过期 / DATE_INVALID”。这和“后端挂了”很不一样——后端可能一切正常,CPU 都在打瞌睡。不同客户端的表现:
- Chrome:
NET::ERR_CERT_DATE_INVALID,提示“此网站的安全证书已过期”; - curl:
curl: (60) SSL certificate problem: certificate has expired; - Java 客户端(HttpClient / OkHttp):
javax.net.ssl.SSLHandshakeException: PKIX path validation failed: java.security.cert.CertPathValidatorException: validity check failed; - App / 小程序:请求直接失败,通常没有可点击“继续访问”的选项,用户看到的只是“网络异常”。
注意这最后一点:浏览器至少还给你一个“高级 → 继续访问”的逃生通道,App 和小程序是没有的,所以对业务的伤害比网页更大。第一动作还是:不要动服务,先把证据保存下来,然后用命令看证书。
二、排查路线图
看证书问题我固定走这条路:先看“到底是哪张证书、有效期到哪天”,再看“链完不完整”,最后看“为什么没续上”。
第二张图,说明“为什么自动续期会悄悄失败”,一张图记住四个常见断点:
三、动手:一步步把证书“验明正身”
第 1 步:看线上到底在用哪张证书
关键是要从外部连一次,看的是“Nginx 现在给客户端出示的证书”,而不是磁盘上的文件。两个易忘的点:-servername 用来发 SNI(一台机器多个域名时不带它可能拿到默认证书),</dev/null 用来让 s_client 不要卡在等待输入:
$ echo | openssl s_client -connect demo.example.com:443 -servername demo.example.com 2>/dev/null \
| openssl x509 -noout -dates -subject -issuer
notBefore=Jun 14 03:12:45 2026 GMT
notAfter=Sep 12 03:12:44 2026 GMT
subject=CN = demo.example.com
issuer=C = US, O = Let's Encrypt, CN = R11
$ date -u
Mon Sep 14 00:41:07 UTC 2026notAfter 是 9 月 12 日,今天已经是 14 号——过期两天了。(注意:证书时间是 GMT,北京时间要加 8 小时。而且 90 天有效期的证书,续期窗口只有最后 30 天,过期这件事是有足够多时间可以避免的。)
如果想直接让 openssl 告诉你验证结果,可以看 s_client 的完整输出:
$ echo | openssl s_client -connect demo.example.com:443 -servername demo.example.com 2>&1 | grep -E 'verify|Verify|depth'
depth=0 CN = demo.example.com
verify error:num=10:certificate has expired
notAfter=Sep 12 03:12:44 2026 GMT
Verify return code: 10 (certificate has expired)第 2 步:用 curl -vI 换个视角
$ curl -vI https://demo.example.com 2>&1 | grep -E 'expire|SSL|subject|issuer|start date'
* SSL certificate problem: certificate has expired
* Closing connection
curl: (60) SSL certificate problem: certificate has expired
# 想看证书详情而不被拦住,临时加 -k(仅用于诊断):
$ curl -vkI https://demo.example.com 2>&1 | grep -E 'start date|expire date|subject|issuer'
* subject: CN=demo.example.com
* start date: Jun 14 03:12:45 2026 GMT
* expire date: Sep 12 03:12:44 2026 GMT
* issuer: C=US; O=Let's Encrypt; CN=R11-k 只能用来诊断,千万别为了“先跑起来”把它写进生产代码。事故里我见过最惨的“修复”,是有人在客户端里把证书校验整个关掉,等于给中间人开了一扇门。
第 3 步:磁盘上的证书是新的还是旧的?
$ sudo certbot certificates
Found the following certs:
Certificate Name: demo.example.com
Domains: demo.example.com www.demo.example.com
Expiry Date: 2026-09-12 03:12:44+00:00 (INVALID: EXPIRED)
Certificate Path: /etc/letsencrypt/live/demo.example.com/fullchain.pem
Private Key Path: /etc/letsencrypt/live/demo.example.com/privkey.pem
$ sudo openssl x509 -in /etc/letsencrypt/live/demo.example.com/fullchain.pem -noout -enddate
notAfter=Sep 12 03:12:44 2026 GMT磁盘上的文件也是旧的,说明续期根本没成功(如果磁盘是新的、线上是旧的,那就是断点 D:续上了但没 reload)。接着看续期日志:
$ sudo tail -n 30 /var/log/letsencrypt/letsencrypt.log
...
2026-08-14 03:00:02,118:INFO:certbot._internal.renewal:Cert is due for renewal, auto-renewing...
2026-08-14 03:00:05,431:ERROR:certbot._internal.renewal:Failed to renew certificate demo.example.com with error: Some challenges have failed.
...
Detail: 10.0.0.9: Invalid response from http://demo.example.com/.well-known/acme-challenge/xxxx: 404
$ sudo certbot renew --dry-run # 不真正签发,只测流程;证书快到期时用它复现问题
$ systemctl list-timers | grep certbot这就是“80 端口验证”那个断点:/.well-known/acme-challenge/ 在 80 端口返回了 404(被 rewrite 到了 https 首页,或被 WAF 拦了)。具体原因总结在下一节。
第 4 步:证书链完整吗?(浏览器正常但 curl/Java 报错)
还有一种和“过期”长得不像的事故:浏览器能开,curl 和 Java 后端却报错。这是典型的“缺中间证书”。用 -showcerts 看服务器到底发了几张证书:
$ echo | openssl s_client -connect demo.example.com:443 -servername demo.example.com -showcerts 2>/dev/null | grep -E '^ *[0-9]+ s:|^ *i:|Verify return'
0 s:CN = demo.example.com
i:C = US, O = Let's Encrypt, CN = R11
Verify return code: 21 (unable to verify the first certificate)只有 0 号(叶子证书),没有 1 号(中间证书)。正常应该看到 0 s: 和 1 s: 两段。对应的报错:
# curl
curl: (60) SSL certificate problem: unable to get local issuer certificate
# Java
javax.net.ssl.SSLHandshakeException: PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target为什么浏览器能开?因为浏览器会缓存它见过的中间证书,或者通过证书里的 AIA 字段自动下载缺失的中间证书;而 curl/OpenSSL 和 Java 默认不会去补齐,服务器发什么就验证什么。所以一个真正的“HTTPS 配置正确”标准是:用 openssl / curl 从一台干净机器测试通过,而不是“我浏览器能开”。
四、根因:为什么证书会“悄悄”过期
1. 90 天有效期 + 自动续期的四个断点
Let's Encrypt 证书的有效期是 90 天,certbot 默认在“剩余寿命不足 1/3”时续期(约到期前 30 天),并且通常会装一个定时任务(cron 或 systemd timer)每天跑两次 certbot renew。自动续期链条上的每个环节都可能断:
- 80 端口验证被拦:HTTP-01 验证只走 80 端口。Let's Encrypt 官方文档说明,它会跟随最多 10 次重定向,且只接受跳转到
http:或https:、端口 80 或 443。常见踩坑:全站return 301 https://...之后,/.well-known/acme-challenge/也被跳走,而 443 又正好是那张过期证书(或者跳到了不同的域名/端口);云安全组/防火墙只开了 443,没开 80;WAF 或 CDN 拦截了验证路径; - DNS 验证失败:通配符证书(
*.demo.example.com)必须用 DNS-01 验证,需要脚本通过 DNS 服务商的 API 写_acme-challenge的 TXT 记录。API 的 AccessKey/token 过期、被轮换、权限被收回,续期就会失败,而且是定时任务里悄悄失败,没人在终端前看到; - reload 没执行:certbot 或 acme.sh 更新了磁盘上的文件,但 Nginx 是长期运行的进程,证书是启动/reload 时读进内存的,不 reload 就继续用旧证书。certbot 需要
--deploy-hook "systemctl reload nginx"(或--post-hook);acme.sh 需要--install-cert ... --reloadcmd "systemctl reload nginx"; - 定时任务本身没在跑:服务器迁移、用户被删除、crontab 没带过去、systemd timer 被 disable、容器重建后 cron 没了。
acme.sh 的推荐姿势(来自其官方 wiki)是用 --issue 签发之后,再用 --install-cert 把证书安装到固定路径并声明 reload 命令,之后自动续期时它会重复执行这个 reload:
$ acme.sh --issue -d demo.example.com -w /var/www/demo
$ acme.sh --install-cert -d demo.example.com \
--key-file /etc/nginx/ssl/demo.key \
--fullchain-file /etc/nginx/ssl/demo.fullchain.pem \
--reloadcmd "systemctl reload nginx"
$ acme.sh --list # 查看各域名下次续期时间2. 证书链不完整
Nginx 的 ssl_certificate 必须指向 fullchain.pem(叶子 + 中间证书),而不是只含叶子的 cert.pem。certbot 官方文档也说明:fullchain.pem 是服务器证书加上中间证书,cert.pem 只有服务器证书。手动买的证书常见的错误是只装了下载包里的 xxx.crt,忘了把中间证书文件拼进去:cat server.crt intermediate.crt > fullchain.pem(顺序是叶子在前,中间在后)。
3. 客户端也会“翻脸”:小程序与 App 的连带影响
证书还有另一类连带问题:TLS 版本和协议套件。小程序、iOS App(ATS)要求 HTTPS 且 TLS 至少 1.2,证书必须由受信任 CA 签发、域名匹配。如果你为了“兼容老设备”留着 TLS 1.0/1.1,或者为了“安全”把 Nginx 的 ssl_protocols 只留了 TLSv1.3、而某些老 Android 设备只支持到 TLS 1.2 以下,都会造成部分用户握手失败,而且错误只出现在客户端,服务端日志里几乎看不到。排查时可用 openssl 逐个协议版本测试:
$ openssl s_client -connect demo.example.com:443 -servername demo.example.com -tls1_2 < /dev/null 2>&1 | grep -E 'Protocol|Cipher'
Protocol : TLSv1.2
Cipher : ECDHE-RSA-AES128-GCM-SHA256
$ openssl s_client -connect demo.example.com:443 -servername demo.example.com -tls1_3 < /dev/null 2>&1 | grep -E 'Protocol|Cipher'
Protocol : TLSv1.3还有一个连带影响:你调用的第三方回调(支付、短信、Webhook)如果对方校验你的证书,你的证书过期会导致回调失败,而不是页面报错,这类问题往往过几个小时对账时才被发现。所以证书过期的“影响面盘点”要包含:官网、H5、App API、小程序合法域名、支付回调、内部服务之间的 HTTPS 调用。
五、止血与修复
止血:先让证书恢复有效
- 手动续期:certbot 用
sudo certbot renew --force-renewal --deploy-hook "systemctl reload nginx"(注意频率限制,别反复强制);acme.sh 用acme.sh --renew -d demo.example.com --force; - 如果续期又因验证失败:先解决验证问题。HTTP-01 需要让 80 端口的
/.well-known/acme-challenge/不被跳转:server { listen 80; server_name demo.example.com; location /.well-known/acme-challenge/ { root /var/www/certbot; # 验证文件所在目录,不做跳转 } location / { return 301 https://$host$request_uri; } } - 验证并加载:
nginx -t && systemctl reload nginx,然后用第 1 步的 openssl 命令核对notAfter;再用curl -vI在另一台机器上确认没有 “unable to get local issuer certificate”; - 通知业务:App / 小程序有本地缓存或连接池的,可能需要用户重开;回调类的第三方在恢复后补推。
根治:让过期变成“提前 30 天吵醒你”
核心思路:不要相信“自动续期一定能成功”,要监控“证书的实际剩余天数”。一个足够简单的脚本:
#!/usr/bin/env bash
# check_cert.sh —— 检查域名证书是否将在 30 天内过期,不足则告警
DOMAINS="demo.example.com api.demo.example.com"
WARN_SECONDS=$((30*24*3600)) # 30 天
for d in $DOMAINS; do
# -checkend N: 如果证书在 N 秒内会过期,返回非 0
if ! echo | openssl s_client -connect "$d:443" -servername "$d" 2>/dev/null \
| openssl x509 -noout -checkend "$WARN_SECONDS" >/dev/null; then
end=$(echo | openssl s_client -connect "$d:443" -servername "$d" 2>/dev/null \
| openssl x509 -noout -enddate)
echo "[ALERT] $d 证书将在 30 天内过期:$end"
# 这里调用钉钉/企业微信机器人 webhook 发送告警(webhook 地址放环境变量,不写进脚本)
fi
doneopenssl x509 -checkend 秒数 会在证书在这段时间内过期时以非 0 退出码结束,这样脚本连日期计算都不用写。把它放进 cron,每天跑一次,从外部检查线上实际出示的证书(这样 reload 没执行的断点也能被发现)。如果你有 Prometheus,也可以用 blackbox_exporter 的 probe_ssl_earliest_cert_expiry 指标,规则设置成“剩余小于 30 天告警,小于 7 天升级”。
再加三个兜底:
- 续期命令加
--deploy-hook/--reloadcmd,并让 Nginx 在配置里指向固定的live路径,别指向某个具体的历史文件; - 把续期失败的邮件通知邮箱设成有人看的团队邮箱,而不是三年前离职同事的;
- 每季度做一次
certbot renew --dry-run演练,确认整条链路(80 端口、DNS API、reload)还通着。
六、复盘
| 做法 | 为什么 |
|---|---|
用 openssl s_client -servername 从外部查证书,而不是只看磁盘文件 | 能同时发现“没续期”和“续了但没 reload” |
| 证书有效期监控,提前 30 天告警、7 天升级 | 自动续期是“假设”,剩余天数才是“事实” |
Nginx 用 fullchain.pem,并用非浏览器客户端验证 | 缺中间证书时浏览器往往正常,curl/Java/小程序会挂 |
| HTTP-01 的验证路径不参与 301 跳转 | 验证只走 80 端口,被跳走或被防火墙挡会续期失败 |
| 通配符证书的 DNS API 凭证纳入轮换清单 | 凭证一过期,定时续期会悄悄失败 |
| 续期后必须 reload(deploy-hook / reloadcmd) | Nginx 只在启动或 reload 时读取证书 |
| 盘点影响面:官网、App、小程序、支付回调、内部 HTTPS | 过期后受影响的远不止浏览器 |
| 不要靠客户端关闭证书校验来“临时解决” | 等于放弃 HTTPS 的意义,还留下隐患 |
一句话备忘:证书问题三连——
openssl s_client -servername看notAfter、-showcerts看链、certbot certificates/ 续期日志看原因;修好必须 reload,监控要盯“线上证书剩余天数”而不是“续期任务有没有跑”。
那天我把证书续上、reload、验证,全程 11 分钟,然后花了 3 分钟在群里认错,又花了半小时写监控脚本。现在我的手机里多了一条“证书还有 30 天到期”的提醒,每次响起我都会有种奇怪的亲切感——比起周一早上八点四十的“官网不安全”,它温柔多了。
参考资料
- Certbot 用户指南——renew、certificates 命令,pre/post/deploy hook,证书文件(fullchain.pem 等)说明,以及 HTTP-01 需要 80 端口。
- Let's Encrypt:Challenge Types——HTTP-01 只走 80 端口、最多跟随 10 次重定向且只接受 80/443 端口,DNS-01 用于通配符证书。
- acme.sh Wiki:How to issue a cert——webroot、standalone、DNS API 等签发方式,以及 cron 自动续期。
一起聊聊