Nginx 与网络排查 列表全部文章
Nginx 与网络排查

早上全站“您的连接不是私密连接”:一张证书过期,让我提前体验了“社死”

某天早上全站弹“连接不是私密连接”,根因是 HTTPS 证书过期。用 openssl s_client 和 curl -v 看证书有效期与证书链,梳理 certbot/acme.sh 自动续期失败的常见原因,并给出提前 30 天告警的检查脚本。

更多

周一早上八点四十,我刚到公司楼下,手机就开始像被扔进洗衣机一样震动。客服群里第一条:“官网打不开,提示不安全”;第二条:“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 和小程序是没有的,所以对业务的伤害比网页更大。第一动作还是:不要动服务,先把证据保存下来,然后用命令看证书。

二、排查路线图

看证书问题我固定走这条路:先看“到底是哪张证书、有效期到哪天”,再看“链完不完整”,最后看“为什么没续上”。

① 看线上证书openssl s_clientnotAfter 是哪天? ② 看证书链-showcerts中间证书齐不齐? ③ 看磁盘文件磁盘上的证书是否已经是新的? ④ 看续期日志为什么没续上 ⑤ 修复续期+reload ③ 的关键对比:磁盘文件新、线上证书旧 → 续期成功但 Nginx 没 reload

第二张图,说明“为什么自动续期会悄悄失败”,一张图记住四个常见断点:

定时任务cron / systemd timer 向 CA 申请ACME 订单 域名验证HTTP-01 / DNS-01 写入新证书/etc/letsencrypt/live reload Nginx加载新证书 断点 A:cron 没跑用户被删/服务器迁移 断点 B:80 端口被挡防火墙 / 强制跳转异常 断点 C:DNS 验证失败API token 过期/权限变更 断点 D:没 reload磁盘新,内存旧 任何一个环节失败都可能是“静悄悄”的:没有人收到通知,证书就自然过期了

三、动手:一步步把证书“验明正身”

第 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 2026

notAfter 是 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 调用。

五、止血与修复

止血:先让证书恢复有效

  1. 手动续期:certbot 用 sudo certbot renew --force-renewal --deploy-hook "systemctl reload nginx"(注意频率限制,别反复强制);acme.sh 用 acme.sh --renew -d demo.example.com --force;
  2. 如果续期又因验证失败:先解决验证问题。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;
        }
    }
  3. 验证并加载:nginx -t && systemctl reload nginx,然后用第 1 步的 openssl 命令核对 notAfter;再用 curl -vI 在另一台机器上确认没有 “unable to get local issuer certificate”;
  4. 通知业务: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
done

openssl 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 自动续期。
— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人