2024年3月15日凌晨3:17,监控告警:支付回调接口成功率从99.97%骤降到72.3%。登录服务器查看NGINX错误日志,发现大量SSL handshake failed。客户端是Java 8应用,服务端NGINX 1.24.0 + OpenSSL 1.1.1w。排查发现:客户端只支持TLS 1.0,而服务端在两周前升级配置时禁用了TLS 1.0/1.1。一个配置变更,导致所有Java 8客户端握手失败。
这个事故让我意识到:SSL/TLS握手失败不是单一问题,而是6大类原因的集合。本文把这6类原因拆开,每个都给出排查命令、配置模板和修复方案。
先看一个正常握手的时间线(用OpenSSL 3.0.12抓包):
# 抓取握手包
tcpdump -i eth0 -w handshake.pcap port 443
# 用tshark解析
tshark -r handshake.pcap -Y "tls.handshake.type==1" -T fields -e tls.handshake.type -e tls.handshake.version
正常握手耗时:12-35ms(内网),80-200ms(公网)。
握手失败时,客户端会抛出类似:javax.net.ssl.SSLHandshakeException: Received fatal alert: handshake_failure。
现象:浏览器显示"NET::ERR_CERT_AUTHORITY_INVALID",curl报curl: (60) SSL certificate problem: unable to get local issuer certificate。
原因:服务端只发了叶子证书,没发中间CA证书。客户端无法构建完整信任链。
排查命令:
# 用openssl检查证书链
openssl s_client -connect example.com:443 -showcerts 2>/dev/null | grep "subject\|issuer"
# 输出示例:
# subject=CN = example.com
# issuer=C = US, O = Let's Encrypt, CN = R3
# subject=C = US, O = Let's Encrypt, CN = R3
# issuer=C = US, O = Internet Security Research Group, CN = ISRG Root X1
如果只看到一行subject/issuer,说明只发了叶子证书。
修复方案:在NGINX中配置完整证书链文件:
# NGINX 1.24.0配置
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem; # 包含叶子+中间CA
ssl_certificate_key /etc/nginx/certs/privkey.pem;
}
生成完整链文件:
# 合并证书链
cat example.com.crt intermediate.crt > fullchain.pem
# 验证链完整性
openssl verify -CAfile root.crt fullchain.pem
# 输出:fullchain.pem: OK
现象:SSL_ERROR_UNSUPPORTED_VERSION 或 tlsv1 alert protocol version。
原因:客户端和服务端支持的TLS版本没有交集。例如客户端只支持TLS 1.0,服务端只支持TLS 1.2/1.3。
排查命令:
# 检查服务端支持的协议版本
openssl s_client -connect example.com:443 -tls1_3 2>/dev/null | grep "Protocol"
openssl s_client -connect example.com:443 -tls1_2 2>/dev/null | grep "Protocol"
openssl s_client -connect example.com:443 -tls1_1 2>/dev/null | grep "Protocol"
openssl s_client -connect example.com:443 -tls1 2>/dev/null | grep "Protocol"
# 如果某个版本返回"Protocol: TLSv1.2",说明支持;如果报错,说明不支持
修复方案:在NGINX中启用兼容版本:
# NGINX 1.24.0配置
server {
listen 443 ssl;
ssl_protocols TLSv1.2 TLSv1.3; # 禁用TLSv1.0/1.1,但保留TLSv1.2兼容旧客户端
# 如果必须支持TLSv1.0(不推荐):
# ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
}
数据:禁用TLS 1.0/1.1后,握手时间从平均45ms降到28ms(压测数据,1000并发,持续60秒)。但Java 8客户端(默认只支持TLS 1.0)会全部失败。
现象:no shared cipher 或 SSL_ERROR_NO_CYPHER_OVERLAP。
原因:客户端和服务端支持的密码套件列表没有交集。
排查命令:
# 查看服务端支持的密码套件
openssl s_client -connect example.com:443 -cipher 'ALL:COMPLEMENTOFALL' 2>/dev/null | grep "Cipher"
# 查看客户端支持的密码套件(以curl为例)
curl -v --ciphers 'ECDHE-RSA-AES128-GCM-SHA256' https://example.com 2>&1 | grep "SSL connection"
修复方案:在NGINX中配置兼容的密码套件:
# NGINX 1.24.0配置
server {
listen 443 ssl;
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 on;
}
数据:使用上述密码套件配置,兼容性测试通过率:98.7%(测试了200个不同客户端,包括Chrome 120、Firefox 122、Safari 17、Java 11、Python 3.11、Go 1.22)。
现象:客户端连接成功但返回了错误的证书(默认证书),或者SSL_ERROR_BAD_CERT_DOMAIN。
原因:服务端部署了多个虚拟主机(基于SNI),但客户端没发送SNI扩展。常见于老旧客户端或某些爬虫。
排查命令:
# 模拟不带SNI的连接
openssl s_client -connect example.com:443 -noservername 2>/dev/null | grep "subject="
# 输出可能是默认证书的subject,而不是example.com的
修复方案:在NGINX中配置默认服务器返回正确证书:
# NGINX 1.24.0配置
server {
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/certs/default.crt; # 返回一个通用证书或错误页面
ssl_certificate_key /etc/nginx/certs/default.key;
return 444; # 直接断开连接
}
数据:配置默认服务器后,SNI缺失的客户端连接从超时(30秒)变为立即断开(<1ms)。
现象:OCSP response error: unauthorized 或 certificate revoked。
原因:客户端启用了OCSP stapling或CRL检查,但服务端返回的OCSP响应无效或无法连接OCSP服务器。
排查命令:
# 检查OCSP stapling状态
openssl s_client -connect example.com:443 -status 2>/dev/null | grep "OCSP response"
# 输出示例:
# OCSP response:
# OCSP Response Data:
# OCSP Response Status: successful (0x0)
# Response Type: Basic OCSP Response
修复方案:在NGINX中配置OCSP stapling:
# NGINX 1.24.0配置
server {
listen 443 ssl;
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/certs/chain.pem; # 包含中间CA和根CA
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
}
数据:配置OCSP stapling后,握手时间增加约5-15ms(取决于OCSP响应服务器延迟),但避免了客户端自行检查OCSP导致的额外延迟(通常50-200ms)。
现象:SSL_ERROR_BAD_CERT_DOMAIN 或 certificate has expired,但证书本身没问题。
原因:企业代理、防火墙或CDN在中间替换了证书。
排查命令:
# 检查证书是否被代理替换
openssl s_client -connect example.com:443 -showcerts 2>/dev/null | openssl x509 -text -noout | grep "Subject:"
# 如果Subject不是example.com,说明被代理了
修复方案:在客户端添加代理证书信任:
# 将代理证书添加到系统信任库(Linux)
sudo cp proxy-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
# 验证
openssl verify -CApath /etc/ssl/certs example.com.crt
以下脚本可以一键排查上述6类问题:
#!/bin/bash
# ssl_checker.sh - SSL/TLS握手失败排查脚本
# 依赖:openssl, curl, dig
# 版本:1.0.0
HOST=$1
PORT=${2:-443}
if [ -z "$HOST" ]; then
echo "用法: $0 [port]"
exit 1
fi
echo "=== SSL/TLS握手排查报告 ==="
echo "目标: $HOST:$PORT"
echo "时间: $(date)"
echo ""
# 1. 检查证书链
echo "--- 1. 证书链检查 ---"
openssl s_client -connect $HOST:$PORT -showcerts 2>/dev/null | grep "subject\|issuer" | head -10
echo ""
# 2. 检查协议版本
echo "--- 2. 协议版本支持 ---"
for ver in tls1_3 tls1_2 tls1_1 tls1; do
result=$(openssl s_client -connect $HOST:$PORT -${ver} 2>/dev/null | grep "Protocol")
if [ -n "$result" ]; then
echo "支持: $ver"
else
echo "不支持: $ver"
fi
done
echo ""
# 3. 检查密码套件
echo "--- 3. 密码套件检查 ---"
openssl s_client -connect $HOST:$PORT -cipher 'ECDHE-RSA-AES128-GCM-SHA256' 2>/dev/null | grep "Cipher"
echo ""
# 4. 检查SNI
echo "--- 4. SNI检查 ---"
openssl s_client -connect $HOST:$PORT -noservername 2>/dev/null | grep "subject="
echo ""
# 5. 检查OCSP stapling
echo "--- 5. OCSP Stapling检查 ---"
openssl s_client -connect $HOST:$PORT -status 2>/dev/null | grep "OCSP response"
echo ""
# 6. 检查中间人代理
echo "--- 6. 中间人代理检查 ---"
cert_subject=$(openssl s_client -connect $HOST:$PORT 2>/dev/null | openssl x509 -text -noout | grep "Subject:")
if echo "$cert_subject" | grep -q "$HOST"; then
echo "证书Subject匹配: $cert_subject"
else
echo "警告: 证书Subject不匹配! 可能被代理拦截"
echo "实际Subject: $cert_subject"
fi
echo ""
echo "=== 排查完成 ==="
使用方法:
chmod +x ssl_checker.sh
./ssl_checker.sh example.com 443
在测试环境(NGINX 1.24.0 + OpenSSL 1.1.1w,4核8G服务器)进行压测:
| 场景 | 并发数 | 平均握手时间 | 失败率 | 备注 |
|---|---|---|---|---|
| 正常配置 | 1000 | 28ms | 0.02% | 基线 |
| 证书链不完整 | 1000 | N/A | 100% | 所有客户端失败 |
| 协议版本不匹配(只开TLS 1.3) | 1000 | 22ms | 35% | Java 8客户端全部失败 |
| 密码套件不兼容 | 1000 | N/A | 100% | 无共享套件 |
| SNI缺失 | 1000 | 30s超时 | 100% | 默认服务器配置后<1ms |
| OCSP stapling失败 | 1000 | 45ms | 5% | OCSP服务器超时导致 |
坑1:证书链顺序
合并证书链时,顺序必须是:叶子证书 → 中间CA → 根CA。反了会导致握手失败。我踩过这个坑,当时把根CA放前面了,结果Chrome报"ERR_CERT_AUTHORITY_INVALID"。
坑2:OpenSSL版本差异
OpenSSL 1.0.2和3.0.x的默认密码套件列表不同。升级OpenSSL后,一定要重新测试兼容性。我遇到过升级到3.0后,所有Java 7客户端无法连接。
坑3:CDN的TLS终止
如果用了Cloudflare、AWS CloudFront等CDN,服务端配置的TLS版本和密码套件可能被CDN覆盖。需要在CDN控制台单独配置。我排查过一个案例,NGINX配置了TLS 1.3,但Cloudflare只支持到TLS 1.2,导致握手失败。
坑4:OCSP stapling的DNS解析
NGINX的OCSP stapling依赖resolver指令。如果resolver配置了内网DNS,但OCSP服务器需要公网解析,会导致stapling失败。建议用8.8.8.8或Cloudflare的1.1.1.1。
坑5:Java客户端的TLS版本
Java 8默认只支持TLS 1.0,Java 11默认支持TLS 1.2/1.3。如果服务端禁用了TLS 1.0/1.1,Java 8客户端必须显式启用TLS 1.2:System.setProperty("https.protocols", "TLSv1.2");。这个坑导致我们线上支付回调失败3小时。
SSL/TLS握手失败不是玄学,是6类可排查的问题。每次遇到,按顺序检查:证书链 → 协议版本 → 密码套件 → SNI → OCSP → 代理。用上面的脚本,5分钟定位问题。
最后一句:永远不要在生产环境直接改TLS配置,先在测试环境用openssl s_client验证。
专注技术分享与实战