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 的三个坑(都是我在生产遇到的):
- ticket 密钥轮换:nginx 默认用
ssl_session_cache的内存存储,reload 后 ticket 就失效。想让 ticket 跨 reload 存活,需要配置ssl_session_ticket_key指向持久化文件,或者根本别指望 0-RTT 跨 reload 存活。 - CDN/LB 层剥离:Cloudflare、AWS ALB、某些企业 LB 会把 early_data 扩展剥掉,即使 nginx 开了 0-RTT,客户端也感知不到。用
curl -v --early-data抓一次响应,看Early data was accepted还是Early data was rejected。 - 重放攻击面: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(按顺序执行):
certbot renew --dry-run确认续期流程通(DNS/HTTP 都通)certbot renew实际续期nginx -t检查新配置(ssl_certificate指向的 new fullchain)nginx -s reload(不是 restart,reload 不断已有连接)openssl s_client验证新证书已生效- 监控面板确认
probe_ssl_earliest_cert_expiry已重置 - 保留旧证书备份 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 兼容老客户端、老中间设备、企业代理。生产环境建议双开:
误区 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}' 定期探测来发现的,如果这块有更优雅的姿势欢迎在评论区指正。