跳转至

title: SSL/TLS 握手排障决策树:从 ClientHello 到握手失败 6 类根因 description: SSL/TLS 握手层排障:openssl s_client 30+ 参数速查、tcpdump 抓握手 filter 表达式、6 类握手失败根因(证书链/过期/CN-SAN/协议版本/TLS1.3 0-RTT/OCSP stapling)决策树、浏览器与 curl 差异、nginx ssl_* 参数矩阵、证书轮转 checklist。 tags: [TLS, SSL, openssl, 握手, 排障, 证书] lang: zh date: 2026-10-27 original: true answer: | TLS 握手失败九成集中在 6 类根因:证书链缺失、证书过期、CN/SAN 不匹配、协议版本不匹配、TLS 1.3 0-RTT 恢复失败、OCSP stapling 失败。本文给一张 openssl s_client 命令矩阵 + tcpdump filter 决策树 + nginx ssl_* 参数速查,配合浏览器与 curl 差异对照,把握手层排障收成一套可复用的排查流程。 faq: - q: "openssl s_client 报 unverified leaf 一定是证书链问题吗?" a: "绝大多数是。unverified leaf 字面就是"叶子证书无法被本地信任链验证",最常见原因是服务端没把中间证书(intermediate CA)一起发出去,本地信任库里只有根 CA。用 openssl s_client -showcerts 看服务端返回了几张证书,只有一张就是链断了。补齐 chain.pem 或让 CDN/LB 拼上中间证书即可。" - q: "curl 报 SSL certificate problem: unable to get local issuer certificate,浏览器却正常,为什么?" a: "浏览器有内置根证书库 + 自动从中间证书拉链补齐机制,curl 默认用系统 CA 库且不做链补齐。同一个域名:浏览器用系统 CA store,macOS/Linux 差异也很大。生产上用 openssl s_client -CAfile /etc/ssl/certs/ca-bundle.crt 显式指定同一份 CA,两者就会给出一致结果。" - q: "no shared cipher 一定是 cipher 不匹配吗?" a: "不一定是 cipher suite 列表的问题,也很可能是协议版本没交集。比如客户端只允许 TLS 1.3,服务端 ssl_protocols 只到 TLSv1.2,openssl 会报 wrong version number 或 no shared cipher。排查顺序:先用 -tls1_3 / -tls1_2 强制指定版本,能通就是版本问题;都能通但还是 no shared cipher 才是 cipher suite 交集为空。" - q: "nginx 里 ssl_stapling on 但 stapling response is not valid 怎么排查?" a: "三个方向:1) 证书是不是自签或无 OCSP 响应 URL(openssl x509 -text -noout | grep -A2 OCSP);2) stapling_responder 没显式配,nginx 从 cert 抓 OCSP URI 但 DNS 解析失败,配个明确 URL 或 resolver 1.1.1.1;3) stapling_verify off 时也要保证 URL 可达。Let's Encrypt 默认支持,GoDaddy 部分产品不支持 stapling,会一直失败。" - q: "TLS 1.3 0-RTT 恢复失败会导致什么?" a: "0-RTT 依赖客户端保存的 NewSessionTicket,重连时携带 early_data 扩展。失败表现为握手退回到 1-RTT 正常流程,功能不受影响但延迟升高。常见诱因:服务端 ssl_early_data 参数漏配、ticket 密钥轮换(ssl_session_tickets off 或 ticket_key 过期)、CDN 层把 early_data 剥掉。用 -early_data hello-only 或 full 显式验证。" - q: "生产证书轮转怎么做到不中断?" a: "三件套:1) certbot renew --pre-hook \"curl -fsS -o /dev/null localhost/healthz\" --deploy-hook \"nginx -t && nginx -s reload\";2) 双证书并存期,新旧证书 SAN 覆盖同一 SNI,客户端不感知;3) reload 是 graceful 的,已有连接不断。测试用 curl -vI 或 openssl s_client -servername 显式看 leaf 的 notAfter 是否已换新。"


SSL/TLS 握手排障决策树:从 ClientHello 到握手失败 6 类根因

1. 握手到底在什么时候失败——一次完整握手的字节流视角

先说结论:绝大多数线上 HTTPS 报警,curl -I 一敲是 200,用户却在报"证书不受信任"或者"连接被重置"。这类问题不落在 HTTP 层,落在 TLS 握手层——就是 ClientHello 到 Finished 之间那 1-2 个 RTT 里出了岔子。

我在生产环境上最常见的排查起点不是看 nginx error.log(那里只记录握手后的错误),而是先把握手的字节流拉出来看。用一行命令就能拿到一次完整握手的十六进制:

# 抓一次完整 TLS 1.3 握手的记录层
openssl s_client -connect example.com:443 \
  -servername example.com \
  -showcerts \
  -msg \
  -tls1_3 \
  < /dev/null 2>&1 | head -80

正常情况下,TLS 1.3 的完整握手长这样(-msg 展开的记录):

C: ClientHello        [0x16, 0x03, 0x01, 0x00, 0x??, ...]
C: 0-RTT Application Data (if any)
S: ServerHello
S: {EncryptedExtensions}
S: {Certificate}
S: {CertificateVerify}
S: {ServerFinished}
C: {ClientFinished}
S: {NewSessionTicket}   ← 0-RTT 复用的凭证
S: {NewSessionTicket}   ← 通常会发两张,一张单用一张复用

TLS 1.2 多一个 ChangeCipherSpec + KeyExchange + ClientKeyExchange + CertificateVerify 序列,往返数是 2-RTT 而不是 1-RTT。判断握手在哪一步挂掉,只需要看最后一包是 ClientHello(客户端还没说话就死)、ServerHello(服务端只回了一半就死)、Finished(几乎完成了)还是 Application Data(成功了但业务层还有问题)。

我一般会做两件事建立"手感":

# 1. 客户端主动握手,抓所有明文握手报文(-msg 只显示非加密部分)
openssl s_client -connect example.com:443 -servername example.com -msg -tls1_3 < /dev/null 2>&1 | grep -E "^\s+(C|S):"

# 2. tcpdump 抓服务端侧握手包(在 nginx 机器上跑)
sudo tcpdump -ni any 'tcp port 443 and (tcp[tcpflags] & tcp-syn != 0 or tcp[tcpflags] & tcp-fin != 0)' -c 20

第一个命令看客户端视角的握手字节流,第二个命令看握手是不是有 RST/FIN 提前切断。这两个视图合起来,握手层 90% 的问题都能定位到具体步骤。

TLS 1.3 一个特别值得注意的现象是:握手完成后服务端会立刻再发两张 NewSessionTicket,客户端会静默接收并存储。这张 ticket 就是 0-RTT 的钥匙——服务端如果把它禁了或者密钥换了,下一跳连接就退回到 1-RTT,但握手本身不会失败,只是延迟升高。别把这个误诊成握手失败。

2. openssl s_client 命令矩阵:30+ 参数分层速查

openssl s_client 是 TLS 层排障的第一件武器。它的参数分层我建议这么记:

A. 连接参数(必用)

# -connect 是必须的,-servername 是 SNI,几乎必带
openssl s_client -connect host:443 -servername host

# 走代理(CONNECT 隧道)
openssl s_client -connect host:443 -servername host -proxy proxy:8080

# 通过已有连接(比如从本地代理端口)
openssl s_client -connect 127.0.0.1:31280 -servername host -proxy

# 用指定本地出口 IP(多网卡场景)
openssl s_client -connect host:443 -servername host -bind 10.0.0.5

B. 证书与链

# 显示服务端返回的所有证书(排查链缺失核心参数)
openssl s_client -connect host:443 -servername host -showcerts

# 用指定 CA bundle 验证
openssl s_client -connect host:443 -servername host -CAfile /etc/ssl/certs/ca-bundle.crt

# 用系统默认 CA 库验证
openssl s_client -connect host:443 -servername host -CApath /etc/ssl/certs

# 只验证但不拒绝(用于观察)
openssl s_client -connect host:443 -servername host -verify_quiet

# 打印完整证书链验证结果
openssl s_client -connect host:443 -servername host -verify_return_error

C. 协议版本控制

# 强制 TLS 1.3
openssl s_client -connect host:443 -servername host -tls1_3

# 强制 TLS 1.2
openssl s_client -connect host:443 -servername host -tls1_2

# 允许版本范围(openssl 1.1.1+)
openssl s_client -connect host:443 -servername host -min_proto TLSv1.2 -max_proto TLSv1.3

# 禁用 TLS 1.3(对比 1.2 表现)
openssl s_client -connect host:443 -servername host -no_tls1_3

D. Cipher suite

# 查看 openssl 支持的 cipher
openssl ciphers -v 'ALL'

# 只允许某几个 suite
openssl s_client -connect host:443 -servername host -cipher 'ECDHE-ECDSA-AES256-GCM-SHA384'

# 显示实际协商到的 cipher
openssl s_client -connect host:443 -servername host | grep -A2 "Cipher    :"

E. 调试输出

# -msg: 打印所有 TLS 报文(含加密记录)
# -debug: 打印更多调试信息
# -state: 打印握手状态机变化
# -quiet: 关闭交互提示
# -sess_out/-sess_in: 保存/加载会话(做 0-RTT 或 session resumption 测试)
# -reconnect: 复用同一个 session 重连 N 次
# -verify_time: 覆盖系统时间验证过期证书

# 完整调试组合
openssl s_client -connect host:443 -servername host -msg -state -debug -quiet

F. 0-RTT 与 Session Resumption

# 保存当前 session
openssl s_client -connect host:443 -servername host -sess_out sess.pem

# 用保存的 session 重连(触发 0-RTT)
openssl s_client -connect host:443 -servername host -sess_in sess.pem

# 强制带 early data
openssl s_client -connect host:443 -servername host -sess_in sess.pem -early_data < /dev/null

G. OCSP

# 查看 stapled OCSP response
openssl s_client -connect host:443 -servername host -ocsp_resp | grep -A20 "OCSP Response"

# 单独发 OCSP 请求(不走 stapling)
openssl ocsp -issuer /path/to/ca.pem -cert /path/to/leaf.pem -url http://ocsp.example.com -resp_text

H. 后处理

# 只输出一行结果,用于脚本
openssl s_client -connect host:443 -servername host -verify_return_error -brief < /dev/null

# 用 -connect 后跟 /dev/null 关闭 stdin,避免进入交互
echo | openssl s_client -connect host:443 -servername host

# 提取证书到文件
openssl s_client -connect host:443 -servername host -showcerts < /dev/null 2>&1 | awk '/BEGIN CERT/,/END CERT/' > chain.pem

一张完整命令模板,我在生产上排查时会直接复制它,把 host 换掉:

#!/usr/bin/env bash
# tls-diag.sh — 一次跑完 TLS 层所有关键诊断
HOST="${1:-example.com}"
PORT="${2:-443}"

echo "===== 1. 快速验证 ====="
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" -brief 2>&1

echo "===== 2. 证书链 ====="
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" -showcerts 2>/dev/null \
  | awk '/BEGIN CERT/,/END CERT/' | grep -c "BEGIN CERTIFICATE"
echo "^ 上面是服务端返回的证书张数(应该 ≥2,1 张通常是链缺失)"

echo "===== 3. 协议版本探测 ====="
for V in -tls1_3 -tls1_2 -tls1_1 -tls1; do
  R=$(echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" $V -brief 2>&1 | grep -E "(Protocol|Cipher)" | tr '\n' ' ')
  echo "$V → $R"
done

echo "===== 4. SNI 对比(有 SNI vs 无 SNI) ====="
echo "With SNI:"
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" -brief 2>&1 | grep "subject"
echo "Without SNI:"
echo | openssl s_client -connect "$HOST:$PORT" -brief 2>&1 | grep "subject"

echo "===== 5. OCSP stapling ====="
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" -ocsp_resp 2>/dev/null \
  | grep -A1 "OCSP Response" | head -5

echo "===== 6. 0-RTT 复测 ====="
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" -sess_out /tmp/s.pem 2>/dev/null >/dev/null
echo "Session 保存成功,尝试重连 0-RTT:"
echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" -sess_in /tmp/s.pem -early_data 2>&1 | grep -E "(Early|Reused|Session-ID)"

3. tcpdump 抓握手包:TCP 层 vs TLS 层的 filter 表达式

Wireshark 有 TLS 解密功能(要配 session key log 才行),但现场没 GUI 的时候,tcpdump 反而是首选。

只抓 TLS 握手相关的包:

# 抓所有 443 端口 TCP 包
sudo tcpdump -ni any 'tcp port 443'

# 只看 SYN(新连接建立)
sudo tcpdump -ni any 'tcp port 443 and tcp[tcpflags] & tcp-syn != 0 and tcp[tcpflags] & tcp-ack = 0'

# 只看 SYN-ACK(服务端响应)
sudo tcpdump -ni any 'tcp port 443 and tcp[tcpflags] & (tcp-syn|tcp-ack) == (tcp-syn|tcp-ack)'

# 只看 RST(异常中断)
sudo tcpdump -ni any 'tcp port 443 and tcp[tcpflags] & tcp-rst != 0'

# 只看 443 端口上包长 <200B 的(握手包一般都很小)
sudo tcpdump -ni any 'tcp port 443 and (tcp[12] & 0xff < 200)'

TLS 记录层过滤(tcpdump 的 TLS 层支持有限,需要 tcp[?] 偏移计算):

# TLS Record Header 前 5 字节:type(1) + version(2) + length(2)
# TLS Handshake type=0x16(record type)
# 从 IP header 之后跳过 14(eth)+20(ip)+20(tcp) = 54,但 tcp 头可能更长

# 抓 ClientHello(type=22,握手 type=1,通常包大小 300-500B)
sudo tcpdump -ni any -X 'tcp port 443 and tcp[12] & 0xff < 600 and tcp[12] & 0xff > 200'

# 抓 ServerHello(type=22,握手 type=2,通常包大小 200-350B)
sudo tcpdump -ni any -X 'tcp port 443 and (tcp[12] & 0xff < 400)'

# 抓 TLS Alert(握手失败时服务端会发 alert)
# Alert 记录:type=21,长度 7
sudo tcpdump -ni any -X 'tcp port 443 and tcp[0]=21 and tcp[3]=0 and tcp[4]=7'

实战组合(我常用的):

# 抓 30 秒的完整握手,导出 pcap,之后 wireshark 打开
sudo tcpdump -ni any -w /tmp/tls.pcap 'tcp port 443' &
TCPDUMP_PID=$!

# 同时触发一次连接
for i in 1 2 3; do
  echo | openssl s_client -connect example.com:443 -servername example.com -tls1_3 2>&1 | tail -20
  sleep 1
done

sleep 2
kill $TCPDUMP_PID
ls -lh /tmp/tls.pcap

在 wireshark 里打开后的关键 filter:

# 只看 ClientHello
tls.handshake.type == 1

# 只看 ServerHello
tls.handshake.type == 2

# 只看 Alert(握手失败时的关键信息)
tls.alert_message

# 抓 SNI 扩展(判断 SNI 是否发送)
tls.handshake.extensions_server_name

# 抓证书链长度
tls.handshake.certificate

# 抓 cipher suite 协商
tls.handshake.conversation

# 抓所有握手相关字段(最有用)
tls.handshake

# 抓 TLS 1.3 early data
tls.handshake.extensions.early_data

# 只看失败的握手
tls.handshake.type == 21  # alert

一个我踩过的坑:tcpdump 抓不到解密后的 TLS 记录内容,只能看到"某方向某大小"的包。想看真正的握手内容(cipher 是什么、支持的版本有哪些),必须用 wireshark 配 session key log:

# 在客户端导出 session key log
export SSLKEYLOGFILE=/tmp/tls-keys.log
curl -vI https://example.com > /dev/null
cat /tmp/tls-keys.log  # 里面是 KEYLOG 格式的 key,wireshark 直接导入

# wireshark 里:Preferences → Protocols → TLS → (Pre)Master-Secret log filename → /tmp/tls-keys.log

没有 wireshark 就用 ssldump 代替,它是纯命令行 TLS 解析工具:

# 抓包 + ssldump 实时解析
sudo tcpdump -ni any 'tcp port 443' -w /tmp/tls.pcap &
ssldump -r /tmp/tls.pcap -k /tmp/tls-keys.log

4. 六类握手失败根因决策树

按线上出现频率从高到低排:证书链缺失 > 协议版本不匹配 > CN/SAN 不匹配 > 证书过期 > OCSP stapling 失败 > TLS 1.3 0-RTT 恢复失败。

4.1 证书链缺失(unverified leaf)

症状:openssl s_client 报 verify error:num=20:unable to get local issuer certificate,或者 verify return:1 (unverified leaf)。浏览器侧的报错是"NET::ERR_CERT_AUTHORITY_INVALID"或"Your connection is not private"。curl 报 SSL certificate problem: unable to get local issuer certificate。

根因:服务端只发了叶子证书,没把中间证书(intermediate CA)发出去。客户端信任库里通常只有根 CA,没有中间 CA,无法构建信任链。

排查:

# 1. 看服务端返回了几张证书
echo | openssl s_client -connect example.com:443 -servername example.com -showcerts < /dev/null 2>&1 \
  | awk '/BEGIN CERT/,/END CERT/' | grep -c "BEGIN CERTIFICATE"
# 输出 1 张 → 链断了

# 2. 看每张证书的 subject 和 issuer
echo | openssl s_client -connect example.com:443 -servername example.com -showcerts < /dev/null 2>&1 \
  | awk '/BEGIN CERT/,/END CERT/' | openssl crl2pkcs7 -nocrl -certfile - | openssl pkcs7 -print_certs -noout

# 3. 拿到链后单独验证完整性
openssl verify -CAfile /etc/ssl/certs/ca-bundle.crt chain.pem

修复:

nginx 侧最常见:

# 错:只加载 leaf
ssl_certificate /etc/ssl/example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/example.com/privkey.pem;

# 对:fullchain.pem 应该是 leaf + intermediate(+ 可选 root)
# certbot 生成的 fullchain.pem 已经是拼好的

certbot 场景下,ssl_certificate 必须指 fullchain.pem 而不是 cert.pem:

# certbot 目录结构
ls /etc/letsencrypt/live/example.com/
# cert.pem       # 只有 leaf
# chain.pem      # 只有中间证书
# fullchain.pem  # leaf + chain(正确引用这个)
# privkey.pem    # 私钥

Caddy、Traefik、Envoy 也有同样问题——检查它们的证书来源是不是"只喂了 leaf"。

一个容易忽略的场景:CDN/WAF 前面挂了自定义证书,用户以为证书是 CDN 发的,实际是 CDN 用了平台证书,中间链是 CDN 平台的链。这时候客户端证书信任库里要有该平台的根 CA,否则一样报错。

4.2 证书过期/未生效(time out of validity)

症状:verify error:num=10:certificate has expired 或 verify error:num=9:certificate is not yet valid。浏览器显示"Your connection is not private · NET::ERR_CERT_DATE_INVALID"。

根因:叶子证书 notBefore 或 notAfter 超出当前时间。

排查:

# 直接看证书有效期
echo | openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 \
  | openssl x509 -noout -dates

# 输出类似:
# notBefore=Nov  5 12:00:00 2025 GMT
# notAfter=Feb  3 12:00:00 2026 GMT

# 用 -verify_time 模拟指定时间验证
echo | openssl s_client -connect example.com:443 -servername example.com \
  -verify_time 1740787200 < /dev/null 2>&1 | grep verify

修复:

# certbot 手动续期
certbot renew --cert-name example.com --non-interactive --agree-tos

# 续期后强制 reload nginx
nginx -t && nginx -s reload

# 校验新证书已生效
echo | openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 | openssl x509 -noout -dates

一个我遇到的隐蔽场景:服务器时间被 NTP 时钟源打错。证书本身还在有效期内,但服务器时钟漂移了 8 小时导致所有新连接都握手失败。检查:

# 服务器时间
date -u
# 对比 NTP 官方时间
curl -s https://worldtimeapi.org/api/timezone/Etc/UTC | jq .utc_datetime
# 检查 ntpd/chrony 状态
timedatectl show --property=NTPSynchronized
chronyc tracking 2>/dev/null || ntpq -p 2>/dev/null

生产上我一般会在监控里加一条 kubelet_node_time_offset_seconds > 5 的告警,避免时钟漂移这种"看似证书问题实则时钟问题"的情况。

4.3 CN/SAN 不匹配(hostname verification failed)

症状:verify error:num=52:unhandled critical extension 或 verify error:num=62:certificate does not match server name。浏览器:"This page contains both safe and insecure content" 或"NET::ERR_CERT_COMMON_NAME_INVALID"。curl:SSL certificate problem: hostname mismatch。

根因:客户端访问的域名和证书里的 CN(Common Name)或 SAN(Subject Alternative Name)不匹配。

排查:

# 看证书的 SAN
echo | openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 \
  | openssl x509 -noout -text | grep -A2 "Subject Alternative Name"

# 输出示例:
# X509v3 Subject Alternative Name:
#     DNS:example.com, DNS:*.example.com, DNS:api.example.com

# 测试不同域名的握手(SNI 决定服务端返回哪张证书)
for D in example.com api.example.com foo.example.com evil.com; do
  echo "[$D]"
  echo | openssl s_client -connect example.com:443 -servername "$D" < /dev/null 2>&1 | grep -E "(subject=|verify)"
  echo
done

修复:

# 换证书,SAN 覆盖目标域名
certbot certonly --webroot -w /var/www/html --certname example.com -d example.com -d "*.example.com" -d api.example.com

# 或者用通配符(注意:通配符只覆盖单层)
# *.example.com 匹配 api.example.com 但不匹配 a.b.example.com 也不匹配 example.com 本身

几个通配符的坑:

  • *.example.com 不匹配 example.com 本身(这是 RFC 6125 的规定),得单独把 example.com 加进 SAN。
  • *.example.com 不匹配 a.b.example.com(只一层通配)。
  • *.example.com 不匹配 sub.example.com.evil.com(域名是后缀匹配)。
  • 浏览器忽略 CN 字段只用 SAN(2017 年 Chrome 60 之后就强制),但服务端校验(比如 Java)可能还在看 CN。

一个特别隐蔽的场景:证书是通配的,客户端访问的是证书签发的父域,比如证书 SAN 是 *.sub.example.com,客户端访问 sub.example.com,浏览器报错但服务端 s_client 报 verify OK。原因:openssl 默认走严格 SAN 匹配,但很多实现(尤其是老的 Java TrustManager)会宽容回退到 CN 匹配。

4.4 协议版本不匹配(no shared cipher / handshake failure)

症状:no shared cipher、wrong version number、handshake failure。这几个错都可能出现在这里。

根因:客户端支持的最高版本 < 服务端支持的最低版本,或者 cipher suite 交集为空。

排查:

# 1. 逐版本测试
for V in -tls1_3 -tls1_2 -tls1_1 -tls1; do
  R=$(echo | openssl s_client -connect example.com:443 -servername example.com $V -brief 2>&1 | grep -E "Protocol|Cipher" | tr '\n' ' ')
  echo "$V → $R"
done

# 2. 看服务端支持的 cipher(openssl 1.1.1+)
openssl s_client -connect example.com:443 -servername example.com -brief < /dev/null 2>&1 | grep Cipher

# 3. 对比两端支持的协议(ssllabs 是标准工具)
curl -s "https://api.ssllabs.com/api/v3/analyze?host=example.com&publish=off"
# 或者 curl -s "https://api.ssldownloads.com/api/v2/check?host=example.com"

修复:

nginx 侧的 ssl_protocols 和 ssl_ciphers:

# 生产推荐(TLS 1.2 + 1.3 都要开)
ssl_protocols TLSv1.2 TLSv1.3;

# TLS 1.3 的 cipher 不能配置(RFC 8446 规定),只能用默认
# TLS 1.2 的 cipher 列表(推荐 Mozilla Intermediate 配置)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;

ssl_prefer_server_ciphers off;  # TLS 1.3 场景下建议 off,让客户端选最优
ssl_ecdh_curve X25519:prime256v1:secp384r1;

特殊报错定位:

  • wrong version number:通常是 TCP 443 端口背后其实是明文 HTTP 服务(比如 nginx 没配 ssl on),openssl 把 HTTP 响应解析成了 TLS 记录头,第一个字节不是 0x16 就报这个。用 curl -I http://example.com:443 试试,如果通了就是这个问题。
  • no shared cipher:真的 cipher 交集为空。检查客户端是否被企业策略限制(浏览器企业证书策略、curl 的 --tls-max 1.2 被禁用某些 cipher)。
  • sslv3 alert handshake failure:服务端主动 reject,通常是 ssl_protocols 里没这个版本。

一个我遇到过但比较罕见的场景:中间有个做 TLS 1.3 转 1.2 的负载均衡器(比如某些云 WAF)。客户端到 WAF 用 TLS 1.3,WAF 到 nginx 用 TLS 1.2,nginx 侧 ssl_protocols 如果禁了 1.2,就会在 WAF→nginx 段握手失败,客户端完全看不到报错。这种问题要在 LB 层抓包才能定位。

4.5 TLS 1.3 0-RTT 恢复失败

症状:连接仍然成功,但延迟升高了 1 个 RTT。抓包看到客户端发送了 early_data 扩展,但服务端在 ServerHello 里回了 early_data.rejected。握手从 0-RTT 退回到 1-RTT。

根因:服务端不接受 0-RTT 恢复,或者 NewSessionTicket 已过期。

排查:

# 1. 保存 session
echo | openssl s_client -connect example.com:443 -servername example.com -tls1_3 \
  -sess_out /tmp/s.pem < /dev/null 2>&1 | grep -E "(Reused|Session-ID|NewSessionTicket)"

# 2. 用保存的 session 尝试 0-RTT
echo | openssl s_client -connect example.com:443 -servername example.com -tls1_3 \
  -sess_in /tmp/s.pem -early_data 2>&1 | grep -E "(Early|Session-ID)"

# 3. 抓包看 early_data 扩展
sudo tcpdump -ni any -X 'tcp port 443' | grep -A2 "17 00"
# 更实用的做法:wireshark 里 filter: tls.handshake.extensions.early_data

修复:

# nginx 侧开启 0-RTT(1.25.0+)
ssl_early_data on;

# 如果不想开:
ssl_early_data off;

# 关闭 session tickets(会导致 0-RTT 直接不生效)
ssl_session_tickets off;

0-RTT 的三个坑(都是我在生产遇到的):

  1. ticket 密钥轮换:nginx 默认用 ssl_session_cache 的内存存储,reload 后 ticket 就失效。想让 ticket 跨 reload 存活,需要配置 ssl_session_ticket_key 指向持久化文件,或者根本别指望 0-RTT 跨 reload 存活。
  2. CDN/LB 层剥离:Cloudflare、AWS ALB、某些企业 LB 会把 early_data 扩展剥掉,即使 nginx 开了 0-RTT,客户端也感知不到。用 curl -v --early-data 抓一次响应,看 Early data was accepted 还是 Early data was rejected。
  3. 重放攻击面:0-RTT 数据可能被重放,服务端必须做幂等处理。GET 请求 OK,POST 请求必须有去重(比如幂等 key)。这是 RFC 8446 §8 明确要求的服务端责任。

判断"0-RTT 到底有没有生效"的最快方法:

# curl 7.88+ 支持 -w 里的 early_data_accepted
curl -w "\nEarly data accepted: %{early_data_accepted}\n" -o /dev/null -s https://example.com
# 输出 Early data accepted: 1 表示生效,0 表示退回 1-RTT

4.6 OCSP stapling 失败

症状:握手本身成功,但客户端(尤其是 Chrome)会看到 "stapled response is not valid" 或者 "This site uses OCSP stapling, but the response was not valid"。curl 看不到这个提示,因为 curl 不做 stapling 校验。

根因:nginx 声明了 ssl_stapling on 但拿不到有效的 OCSP 响应。

排查:

# 1. 看 leaf 证书是不是有 OCSP URI
echo | openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 \
  | openssl x509 -noout -text | grep -A1 "OCSP"
# 没有 Output → 证书不支持 OCSTAPLING,ssl_stapling 一定失败

# 2. 直接请求 OCSP 端点看返回什么
openssl ocsp -issuer /etc/ssl/letsencrypt/R3.pem \
  -cert /etc/letsencrypt/live/example.com/cert.pem \
  -url http://ocsp.digicert.com -resp_text
# 看返回是不是 "Certification Status: Good"

# 3. 看 nginx 抓到的 stapling 响应
tail -100 /var/log/nginx/error.log | grep -i stapling
# 典型报错:
# SSL_do_handshake() failed (SSL: error:14094418:SSL routines:ssl3_read_bytes:tlsv1 alert internal error)
# openssl ssl3_read_bytes...

修复:

# 基础配置
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/letsencrypt/chain.pem;

# 关键:resolver 必须显式配,nginx 不能从 /etc/resolv.conf 读
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;

# 显式指定 OCSP responder URL(可选但推荐)
ssl_stapling_responder https://ocsp.digicert.com;

几个特殊场景:

  • 自签证书:没 OCSP URI,ssl_stapling on 永远失败。要么关掉 ssl_stapling off,要么用自签 CA 签发时显式加 OCSP URI(一般不这么做)。
  • GoDaddy 部分产品:不给 OCSTAPLING 端点。这种证书 ssl_stapling 关了就好,别纠结。
  • 企业内网 CA:OCSP 端点在内网 DNS,nginx 的 resolver 要指向内网 DNS(比如 10.0.0.53),公共 DNS 找不到内网 CA 的 OCSP 服务。

5. 浏览器与 curl 看到的现象为什么不一样

这是排查里最容易迷惑的一点:curl 报错但浏览器正常,或者反过来。

核心差异:

维度 浏览器 curl
CA 库 系统级 + 平台额外补丁(Apple Root CA / Microsoft 更新) 系统 CA 库,通常更小
链补齐 会主动从 AIA 扩展拉中间证书 不做链补齐
SNI 强制发送 需要 -servername 显式发送
HSTS 有存储,会强制走 HTTPS 不做(除非 --hsts)
HSTS preload 内置 preload list 无
OCSP stapling 严格校验 stapled response 不校验
证书链长度 一般接受 3-4 张 默认无硬限制
OCSP CRL 检查 有些浏览器会主动请求 不请求
TLS 1.3 0-RTT 支持 7.88+ 才支持

排查思路:

# 1. 让 curl 使用浏览器的 CA 库(跨平台统一)
# macOS Safari/Chrome 系统 CA
curl --cacert /etc/ssl/cert.pem -I https://example.com

# Linux 用系统 CA
curl --cacert /etc/ssl/certs/ca-certificates.crt -I https://example.com

# 或者用 openssl s_client 指定同一份 CA,模拟浏览器行为
echo | openssl s_client -connect example.com:443 -servername example.com \
  -CAfile /etc/ssl/certs/ca-bundle.crt -verify_return_error < /dev/null

# 2. 看浏览器 devtools 里的连接信息
# Chrome: DevTools → Network → 右键 → Copy → Copy as cURL
# 会给你一行 curl 命令,包含浏览器的真实参数

# 3. Chrome 内部错误诊断
# chrome://net-internals/#certs → 输入域名看证书详情
# chrome://net-internals/#traces → 抓握手事件流
# chrome://net-internals/#ocsp → 看 OCSP 请求

一个典型场景:curl 报 unable to get local issuer certificate,浏览器正常。原因:浏览器内置了 Let's Encrypt 的 ISRG Root X1,但 Ubuntu 20.04 的默认 CA 包太老没有。修复:

apt update && apt install ca-certificates --reinstall
update-ca-certificates
# 或者临时用 curl --cacert /etc/ssl/certs/ca-certificates.crt

反向场景:浏览器报错,curl 正常。常见原因:

  • HSTS preload:域名被 preload,浏览器强制 HTTPS,而服务端 80 端口有证书问题(比如 80 端口只监听 HTTP,没有证书)。curl 走 http:// 就绕过了。
  • CT log 校验失败:浏览器做 Certificate Transparency 校验(Chrome 强制),ct 日志里没找到该证书的审计记录。curl 不做这个检查。
  • 企业代理注入:公司代理替换了证书(TLS 解密审计),浏览器识别是"未知 CA"报错,curl 用了企业根证书就能过。

6. nginx 侧配置踩坑速查

一个我认为"够生产用"的 nginx TLS 配置片段,我把每个参数注释清楚:

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    http2 on;                              # 1.25.0+ 拆出来了,1.25 之前是 listen 里带 http2

    server_name example.com;
    root /var/www/html;

    # === 证书 ===
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;  # 用于 stapling 验证

    # === 协议版本 ===
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;          # 1.3 场景让客户端选

    # === Cipher suite(TLS 1.2 用)===
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
    ssl_ecdh_curve X25519:prime256v1:secp384r1;
    ssl_dhparam /etc/nginx/dhparam.pem;    # 2048 位 DH,用 openssl dhparam -out dhparam.pem 2048 生成

    # === 会话管理 ===
    ssl_session_cache shared:SSL:10m;       # 10 MB 共享内存,约 1 万 session
    ssl_session_timeout 1d;                 # 会话缓存 1 天
    ssl_session_tickets off;                # 关掉票据(0-RTT 会失效但更安全)

    # === OCSP Stapling ===
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_stapling_responder https://ocsp.digicert.com;
    resolver 1.1.1.1 8.8.8.8 valid=300s;    # 显式 resolver,别依赖系统

    # === 0-RTT(可选,nginx 1.25+)===
    ssl_early_data off;                     # 默认关,开启要小心幂等

    # === 其他 ===
    ssl_buffer_size 8k;
    ssl_early_bytes 32k;
    ssl_session_jitter 10m;                 # 避免雪崩式 session 过期
}

参数矩阵速查:

参数 默认值 影响面
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 决定支持的版本
ssl_ciphers HIGH:!aNULL:!MD5 TLS 1.2 cipher 列表
ssl_prefer_server_ciphers on off 时客户端选,on 时服务端选
ssl_session_cache none 会话复用存储
ssl_session_timeout 5m 会话缓存时间
ssl_session_tickets on 开启后可做 session resumption
ssl_stapling off 是否 stapling OCSP
ssl_stapling_verify off 是否验证 stapled response 签名
ssl_early_data off(1.25+) 是否允许 0-RTT
ssl_buffer_size 4k 加密 buffer 大小
ssl_early_bytes 0 早发加密数据的字节数
ssl_session_jitter 0 会话过期抖动,避免雪崩

常见报错对照:

# "SSL_do_handshake() failed (SSL: error:14094418:SSL routines:ssl3_read_bytes:tlsv1 alert internal error)"
→ 客户端不支持 ssl_protocols 里指定的版本
→ 或者 cipher suite 里有一个客户端不接受

# "no suitable key share received"
→ TLS 1.3 场景下服务端要求的 group 客户端都不支持
→ 检查 ssl_ecdh_curve 和客户端能力

# "OCSP response is not valid, verify error:num=20"
→ ssl_stapling_verify on 但 stapled 响应签名验证失败
→ 检查 ssl_trusted_certificate 是否是完整链

# "unknown ssl_early_data"
→ nginx 版本低于 1.25,去掉这行

7. 生产环境证书轮转 checklist

证书轮转的目标:零中断切换。任何一次 reload 都要保证新连接的握手用新证书,旧连接不断。

certbot 自动续期模板:

# /etc/systemd/system/certbot-renew.service.d/override.conf
# 或者用 certbot 的 timer + hook

# 标准 timer
systemctl enable --now certbot.timer

# /etc/letsencrypt/renewal-example.com.conf 里的 renewal hook
[[renewal hooks]]
deploy_hook = "nginx -t && nginx -s reload"

# 手动跑一次续期验证
certbot renew --dry-run --cert-name example.com

验证脚本:

#!/usr/bin/env bash
# cert-verify.sh — 证书轮转前后跑一遍
HOST="${1:-example.com}"
CERT_FILE="/etc/letsencrypt/live/${HOST}/fullchain.pem"

echo "=== 证书信息 ==="
openssl x509 -in "$CERT_FILE" -noout -subject -dates -ext subjectAltName

echo "=== 服务端握手验证 ==="
echo | openssl s_client -connect "$HOST:443" -servername "$HOST" < /dev/null 2>&1 | grep -E "(subject=|issuer=|notAfter|Verify return code|Protocol|Cipher)"

echo "=== 剩余天数 ==="
EXP=$(openssl x509 -in "$CERT_FILE" -noout -enddate | cut -d= -f2)
DAYS=$(( ( $(date -d "$EXP" +%s) - $(date +%s) ) / 86400 ))
echo "剩余 ${DAYS} 天"

if [ "$DAYS" -lt 14 ]; then
  echo "⚠️ 剩余不足 14 天,立即续期"
  exit 1
fi

监控告警规则(Prometheus + Alertmanager 示例):

# prometheus rule
groups:
- name: tls-cert-expiry
  rules:
  - alert: TLSCertExpiringSoon
    expr: probe_ssl_earliest_cert_expiry - time() < 14 * 24 * 3600
    for: 30m
    labels:
      severity: warning
    annotations:
      summary: "{{ $labels.instance }} 证书 14 天内到期"
  - alert: TLSCertExpiringVerySoon
    expr: probe_ssl_earliest_cert_expiry - time() < 3 * 24 * 3600
    for: 5m
    labels:
      severity: critical
    annotations:
      summary: "{{ $labels.instance }} 证书 3 天内到期,立即处理"

轮转 checklist(按顺序执行):

  1. certbot renew --dry-run 确认续期流程通(DNS/HTTP 都通)
  2. certbot renew 实际续期
  3. nginx -t 检查新配置(ssl_certificate 指向的 new fullchain)
  4. nginx -s reload(不是 restart,reload 不断已有连接)
  5. openssl s_client 验证新证书已生效
  6. 监控面板确认 probe_ssl_earliest_cert_expiry 已重置
  7. 保留旧证书备份 90 天(回滚用)

回滚方案:

# 保留备份
mkdir -p /etc/letsencrypt/live/example.com/backup-$(date +%Y%m%d)
cp -a /etc/letsencrypt/live/example.com/*.pem /etc/letsencrypt/live/example.com/backup-$(date +%Y%m%d)/

# 回滚:把 backup 里的文件覆盖回去
cp -a /etc/letsencrypt/live/example.com/backup-20261010/*.pem /etc/letsencrypt/live/example.com/
nginx -s reload

一个我踩过但比较隐蔽的坑:certbot 生成的新证书文件权限。默认 chmod 600 privkey.pem 是对的,但如果 nginx worker 以非 root 用户跑,读不到 600 权限的文件会握手失败并记录 SSL_CTX_use_PrivateKey_file() failed。检查:

ls -la /etc/letsencrypt/live/example.com/
# 应该看到:
# -rw-r--r-- 1 root root ... chain.pem
# -rw-r--r-- 1 root root ... fullchain.pem
# -rw------- 1 root root ... privkey.pem
# -rw-r--r-- 1 root root ... cert.pem

# 修权限
chmod 644 /etc/letsencrypt/live/example.com/*.pem
chmod 600 /etc/letsencrypt/live/example.com/privkey.pem
chown -R root:root /etc/letsencrypt/live/example.com/

8. 常见误区与速查 Cheat Sheet

误区 1:把 nginx 的 502/504 当握手失败

502/504 是 HTTP 层错误,握手必须已经成功。握手失败表现为 curl 的 openssl 报错、浏览器的证书警告、或者干脆 TCP RST。判断:curl -vI https://...,如果看到 Connected to 之后立刻有 SSL connection using 就是握手成功了;如果卡在 Connected to 之后超时或者报错 SSL_connect 就是握手失败。

误区 2:no shared cipher 就一定是 cipher 问题

也可能是协议版本问题(TLS 1.3 vs 1.2 不匹配)、也可能是 DH 参数不匹配。先分版本测试再改 cipher。

误区 3:ssl_stapling on 一定安全

如果 ssl_stapling_verify off(默认),nginx 不验证 stapled response 的签名,恶意中间人可能注入伪造的 OCSP 响应。生产上必须开 ssl_stapling_verify on + ssl_trusted_certificate。

误区 4:证书过期前一定会有 certbot 邮件提醒

certbot 只在续期成功/失败时发邮件,不主动发"证书将在 X 天后过期"。得靠自己的监控告警,或者配置 certbot 的 renew_before_expiry(默认 30 天,但可以更早)。

误区 5:Chrome 的 chrome://ssl 是最权威的证书视图

它展示的是 Chrome 已经加载的证书,可能包含缓存。要拿最新的服务端证书,用 chrome://net-internals/#certs 手动刷新,或者干脆用 openssl s_client。

误区 6:SAN 通配符 *.example.com 覆盖 www.example.com 之外的所有子域

只覆盖一层。api.example.com OK,api.v2.example.com 不 OK,example.com 本身也不 OK。

误区 7:HTTPS 握手失败一定跟证书有关

也可能是:端口没监听(curl -v 卡在 Trying...)、IP 层不通(防火墙、路由)、SNI 被中间设备改写了(企业代理)。先用 tcping 或 nc -zv host 443 确认 TCP 通,再看 TLS 层。

误区 8:TLS 1.3 一定比 1.2 好

TLS 1.3 更紧凑(1-RTT)、更快(0-RTT)、更安全(去掉老 cipher)。但 TLS 1.2 兼容老客户端、老中间设备、企业代理。生产环境建议双开:

ssl_protocols TLSv1.2 TLSv1.3;

误区 9:openssl s_client 报错就是服务端问题

s_client 是客户端,它的行为受本机 CA 库、openssl 版本、命令行参数影响。同一个域名,本机 s_client 报错、跳板机上 s_client 正常,是常见现象。用 -CAfile 显式指定 CA 让结果可比。

误区 10:OCSP stapling 失败会导致连接失败

不会。stapling 只是让客户端不用主动发 OCSP 请求,握手本身仍然成功。浏览器会 fallback 到主动 OCSP 请求或者直接忽略。所以 ssl_stapling 失败通常只在日志里看到,不影响用户流量——但会增加握手时延(Chrome 会主动查 OCSP)。


速查表:报错 → 根因 → 修复

报错关键字 根因 修复
unverified leaf / unable to get local issuer certificate 证书链缺失 加载 fullchain.pem
certificate has expired / certificate is not yet valid 证书过期/未生效 续期或校时间
hostname mismatch / does not match server name CN/SAN 不匹配 换证书覆盖目标域
no shared cipher / wrong version number 版本或 cipher 不匹配 调 ssl_protocols / ssl_ciphers
stapling response is not valid OCSP stapling 失败 显式配 resolver + stapling_verify
early_data.rejected / Early data was rejected 0-RTT 恢复失败 检查 ssl_early_data / ticket key
tlsv1 alert internal error 服务端主动 reject 检查 ssl_protocols / cipher
no suitable key share received TLS 1.3 group 不匹配 检查 ssl_ecdh_curve
ssl3_read_bytes:sslv3 alert protocol version 版本被服务端拒 强制 -tls1_2 或改 ssl_protocols

速查表:一条命令解决

# 全面握手诊断
echo | openssl s_client -connect HOST:443 -servername HOST -showcerts -msg -tls1_3 < /dev/null 2>&1 | head -80

# 只看协议版本和 cipher
echo | openssl s_client -connect HOST:443 -servername HOST -brief < /dev/null

# 只看证书链
echo | openssl s_client -connect HOST:443 -servername HOST -showcerts < /dev/null 2>&1 | openssl crl2pkcs7 -nocrl -certfile - | openssl pkcs7 -print_certs -noout

# 只看证书有效期
echo | openssl s_client -connect HOST:443 -servername HOST < /dev/null 2>&1 | openssl x509 -noout -dates

# 只看 OCSP stapling 状态
echo | openssl s_client -connect HOST:443 -servername HOST -ocsp_resp < /dev/null 2>&1 | grep -i ocsp

# 测试 0-RTT
echo | openssl s_client -connect HOST:443 -servername HOST -tls1_3 -sess_in sess.pem -early_data < /dev/null

# curl 一行诊断(含协议版本、cipher、握手时间)
curl -svI --tlsv1.2 https://HOST 2>&1 | grep -E "(SSL connection|subject:|issuer:|HTTP/|time_connect|time_starttransfer)"

# 抓握手包 + 快速解析
sudo tcpdump -ni any -c 20 -X 'tcp port 443' &
sleep 1 && echo | openssl s_client -connect HOST:443 -servername HOST < /dev/null

# nginx 侧查当前 TLS 配置
nginx -T 2>/dev/null | grep -iE "(ssl_protocols|ssl_ciphers|ssl_stapling|ssl_early_data|ssl_session)"

速查表:nginx ssl_ 参数一页纸*

# 版本
ssl_protocols TLSv1.2 TLSv1.3;

# Cipher(TLS 1.2 用)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_ecdh_curve X25519:prime256v1:secp384r1;
ssl_dhparam /etc/nginx/dhparam.pem;

# 会话
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_session_jitter 10m;

# Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/letsencrypt/chain.pem;
resolver 1.1.1.1 valid=300s;

# 0-RTT
ssl_early_data off;

# 其他
ssl_buffer_size 8k;
ssl_early_bytes 32k;

速查表:协议版本探测一行流

for V in -tls1_3 -tls1_2 -tls1_1 -tls1; do
  echo "[$V] $(echo | openssl s_client -connect HOST:443 -servername HOST $V -brief < /dev/null 2>&1 | grep -E 'Protocol|Cipher' | tr '\n' ' ')"
done

速查表:证书到期监控告警阈值

  • 剩余 90 天:INFO 级别,仅通知
  • 剩余 30 天:WARNING,触发 certbot renew
  • 剩余 14 天:WARNING,人工介入
  • 剩余 3 天:CRITICAL,立即处理
  • 剩余 0 天:P0,服务降级

速查表:openssl s_client 常用参数

参数 用途
-connect host:port 目标地址
-servername HOST SNI
-showcerts 显示完整链
-CAfile /path 指定 CA
-CApath /dir 指定 CA 目录
-tls1_2 / -tls1_3 强制版本
-no_tls1_3 禁用 1.3
-min_proto / -max_proto 版本范围
-cipher LIST 指定 cipher
-msg 打印 TLS 报文
-debug 更多调试
-state 状态机
-sess_out /path 保存 session
-sess_in /path 加载 session
-early_data 触发 0-RTT
-reconnect N 复用 session N 次
-ocsp_resp 抓 stapled OCSP
-verify_return_error 验证失败即退出
-verify_quiet 静默验证
-verify_time N 覆盖系统时间
-proxy [host:port] 走代理
-bind IP 指定本地出口
-quiet 关闭交互提示
-brief 简化输出
-alpn list 指定 ALPN 协议
-nextprotos 旧版 ALPN

到这里 6 类握手失败根因和排查工具都过了一遍。实际生产上排障时我一般先跑第 8 章的"一条命令解决"里的 openssl s_client -showcerts -msg,看错误发生在哪一步,然后按第 4 章的决策树往下走。如果 openssl 看不出问题,再切到 tcpdump 抓包 + wireshark 分析 TLS 记录。整套流程跑下来,绝大多数 TLS 握手失败都能定位到具体配置行。有一点我也不完全确定:TLS 1.3 场景下的 0-RTT 恢复失败在监控里其实很难主动发现(因为它表现为延迟升高而不是错误率升高),我在生产里是通过 curl -w '%{early_data_accepted}' 定期探测来发现的,如果这块有更优雅的姿势欢迎在评论区指正。