前言
排查 TLS 握手失败时,最常听到的一句话是:"日志里全是 200,哪有握手失败?"
这句话本身没错,但它说明找错了地方。Nginx 处理一个 HTTPS 连接的顺序是:接受 TCP 连接 → 完成 TLS 握手 → 解析 HTTP 请求行 → 匹配 location → 处理请求 → 写一行 access log。握手失败发生在第二步,此时 HTTP 请求还没有被解析出来,压根没有"请求"可以记录,所以access.log里连行都不会有。它只会出现在error.log里,形如:
2024/01/01 10:00:00 [error] 1234#0: *100 SSL_do_handshake() failed (SSL: error:0A00010B:SSL routines::wrong version number) while SSL handshaking, client: 203.0.113.10, server: 0.0.0.0:443另一个让人困惑的现象是"我这儿好端端的,用户说证书报错"。原因通常是用户走了另一个 SNI、落到了默认 server 上,用的是另一张证书——而你看的那台 server 的日志当然是干净的。
本文按"为什么看不到 → 那些错误文本分别是什么意思 → 怎么让日志把 SNI 和协议版本记下来 → 怎么用openssl s_client从客户端视角复现"的顺序展开。示例基于nginx 1.24 / 1.25(Debian 12、Ubuntu 22.04、RHEL 9 均可),并会明确指出 nginx 1.19.4、1.25.1 这类版本分界点,因为不少指令在版本之间有写法变化。
一、先确认:你该看的是 error.log,不是 access.log
除了"握手失败没有请求"之外,还有两个细节要一起记住:
info级别也相关。握手相关日志不全是error级,比如客户端在进行到一半时主动断开,可能是[info]或[warn]。所以排查期间不要把error_log的级别设得比info更高(例如只收error),否则会漏掉关键线索。- 只有握手成功的连接,才能用 access log 的变量观察到握手结果。
$ssl_protocol、$ssl_cipher、$ssl_server_name这些变量在 access log 里能打出来,前提是握手已经成功。所以 access log 的用途是"审计成功的握手长什么样",而不是"定位失败的握手"。
排查的起手式:
# 1. 确认 error_log 的位置和级别 nginx -T | grep -E '^\s*error_log' # 2. 直接看 SSL 相关的行(含已轮转的压缩日志) grep -iE 'SSL_do_handshake|SSL:|ssl_' /var/log/nginx/error.log | tail -50 zcat /var/log/nginx/error.log.*.gz 2>/dev/null | grep -iE 'SSL_do_handshake' | tail -50 # 3. systemd 环境下也可能在 journal 里 journalctl -u nginx --since "1 hour ago" | grep -iE 'ssl|handshake' # 4. 确认 Nginx 链接的 OpenSSL 版本(这决定了错误码的格式) nginx -V 2>&1 | tr ' ' '\n' | grep -iE 'openssl|ssl'二、读懂错误文本:按文本归类,不要背十六进制码
第一个必须知道的事实:error:XXXXXXXX:里的十六进制数值在不同 OpenSSL 版本之间格式都不一样。OpenSSL 1.1.1 时代是error:14094410:SSL routines:ssl3_read_bytes:...这种三段式;OpenSSL 3.x 改成了error:0A000410:SSL routines::...这种两段式。所以拿十六进制码去搜索、对着别的文章抄结论,非常容易对错号。
错误文本(英文描述部分)才是稳定的,按它归类即可:
| error.log 中的关键文本 | 含义 | 常见成因 |
|---|---|---|
wrong version number | 收到的不是 TLS 记录 | 明文 HTTP 打到了 HTTPS 端口;或反代上游本该是 TLS 却用了明文 |
http request | 在 TLS 握手期收到了 HTTP 报文 | 同上,用户的 URL 写成了http://但连的是 443 |
no shared cipher | 双方没有共同的可选套件 | ssl_ciphers配得过窄;老客户端只支持被禁用的套件 |
unsupported protocol | 客户端协议版本被ssl_protocols拒绝 | 只开了 TLSv1.2/1.3,而客户端只会说更老的版本 |
bad key share/no suitable key share | TLS 1.3 密钥交换组不匹配 | 客户端与服务端的命名组(named group)没有交集 |
certificate unknown/unknown ca/bad certificate | 客户端不认可服务端证书 | 自签证书、证书链不完整、过期、域名不匹配 |
sslv3 alert handshake failure | 客户端主动发回握手失败告警 | 客户端侧策略拒绝(版本、套件、证书任一项) |
peer closed connection in SSL handshake | 客户端在握手中途断开 | 网络扫描、健康探测、客户端因策略拒绝后立刻断开 |
unexpected eof while reading | 对端在 TLS 层提前关闭 | 探测流量、客户端超时 |
no "ssl_certificate" is defined | 配置错误 | listen ... ssl所在 server 没有配证书 |
"ssl_stapling" ignored, issuer certificate not found | OCSP Stapling 配置不全 | 只开了ssl_stapling,没配ssl_trusted_certificate |
有两类需要单独识别、别和真正的握手失败混在一起:
- 明文 HTTP 打到 HTTPS 端口:Nginx 会返回497这个非标准状态码,含义是 "The plain HTTP request was sent to HTTPS port"。这类请求是能进 access log 的(因为它已经被当作一个 HTTP 请求解析了),可以用
error_page 497 https://$host$request_uri;把用户优雅地跳到 HTTPS。 - 扫描器与探测流量:互联网上每秒都有大量机器人对着 443 端口发乱七八糟的字节。如果错误日志里
peer closed connection in SSL handshake集中来自少数几个 IP、且时间分布杂乱,基本就是探测,不用当成故障。用第三节的脚本按 IP 聚合一下就能分辨。
三、让日志把 SNI、协议版本、套件记下来
光看错误文本只能知道"失败在哪一层",要知道"是谁、用什么参数失败的",得让 access log 带上 TLS 相关变量。这些变量只在SSL server(即listen ... ssl所在的 server)里有效,普通 80 端口的 server 上会是空值。
# 放在 http 块里 log_format tls '$remote_addr [$time_local] "$request" $status ' 'proto=$ssl_protocol cipher=$ssl_cipher sni=$ssl_server_name ' 'reused=$ssl_session_reused bytes=$body_bytes_sent rt=$request_time';各变量的含义与用途:
| 变量 | 含义 | 排查价值 |
|---|---|---|
$ssl_protocol | 协商出的协议版本 | 看出有多少老客户端停在 TLSv1.0/1.1 |
$ssl_cipher | 协商出的加密套件 | 验证ssl_ciphers是否符合预期 |
$ssl_server_name | 客户端 SNI 发来的域名 | 定位"落错 server、用错证书"的关键字段;未发 SNI 时为空 |
$ssl_session_reused | 是否复用了会话 | 复用率低说明会话缓存或 ticket 配置有问题 |
$ssl_client_verify | 客户端证书校验结果 | 双向认证(mTLS)排查必备 |
注意$ssl_server_name在客户端没发 SNI 时是空的,这正是"老客户端或 IP 直连落到默认 server、拿到默认证书"的指纹。
一个完整可用的 HTTPS server 例子,包含默认 server(承接未知 SNI)和带 TLS 日志的主 server:
# 默认 server:没有 SNI 或 SNI 未匹配到任何 server_name 时落到这里 server { listen 443 ssl default_server; server_name _; # nginx 1.19.4 起支持:直接拒绝未知 SNI 的握手,不再暴露默认证书 # 开启后本 server 就不需要再配 ssl_certificate 了 ssl_reject_handshake on; # 若使用较老版本(1.19.4 之前),改成配一张"占位证书": # ssl_certificate /etc/nginx/ssl/default-chain.crt; # ssl_certificate_key /etc/nginx/ssl/default.key; } server { listen 443 ssl; server_name example.com; # 证书文件里应当是"服务器证书 + 中间证书"的完整链,叶子证书在最前面 ssl_certificate /etc/nginx/ssl/example.com-chain.crt; ssl_certificate_key /etc/nginx/ssl/example.com.key; # 只保留 TLSv1.2 与 TLSv1.3;更老的客户端请用兼容层解决,不要为它降级 ssl_protocols TLSv1.2 TLSv1.3; # ssl_ciphers 的可选值随 OpenSSL 版本不同,请以官方文档与本机 openssl ciphers 输出为准 ssl_ciphers HIGH:!aNULL:!MD5; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1h; # OCSP Stapling:三件套要一起配,否则会出现 "issuer certificate not found" 警告 ssl_stapling on; ssl_stapling_verify on; ssl_trusted_certificate /etc/nginx/ssl/example.com-chain.crt; access_log /var/log/nginx/example.tls.access.log tls; # 明文 HTTP 误连 443 时,优雅跳转到 HTTPS error_page 497 https://$host$request_uri; location / { root /var/www/site; try_files $uri $uri/ /index.html; } }改完照例验证再重载:
nginx -t && systemctl reload nginx四、从客户端视角复现:openssl s_client
日志只能告诉你"服务端看到了什么",要确认"客户端看到了什么",最直接的工具是openssl s_client。
# 1. 完整握手,打印证书链 openssl s_client -connect example.com:443 -servername example.com -showcerts # 2. 强制某个协议版本,验证 ssl_protocols 的实际效果 # (用不支持的老版本连接,若被拒会看到握手失败或协议不支持的错误) openssl s_client -connect example.com:443 -servername example.com -tls1_2 openssl s_client -connect example.com:443 -servername example.com -tls1_3 # 3. 观察协商结果,只看关键几行 echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \ | grep -E 'Protocol|Cipher|Verify return code|subject=|issuer='判读要点:
Verify return code: 0 (ok)表示证书链在这个环境下校验通过;非 0 表示链或信任有问题;subject=与issuer=对不上你预期的域名/CA,说明拿到了默认 server 的证书(SNI 没匹配上);- 想模拟"客户端不认证书"的场景,可以看
-showcerts输出的链里有没有中间 CA。只有叶子证书、没有中间证书,是最常见的"浏览器好了、某个 Java 客户端报错"的原因。
再补两条手工核对证书本身的操作:
# 看有效期、域名、签发者 openssl x509 -noout -dates -subject -issuer -ext subjectAltName -in /etc/nginx/ssl/example.com-chain.crt # 确认私钥和证书是同一对(RSA 用模数比对) openssl x509 -noout -modulus -in /etc/nginx/ssl/example.com-chain.crt | openssl md5 openssl rsa -noout -modulus -in /etc/nginx/ssl/example.com.key | openssl md5 # 两行输出必须一致。ECDSA / Ed25519 密钥请改用公钥比对: # openssl x509 -noout -pubkey -in 证书文件 # openssl pkey -pubout -in 私钥文件 # 校验证书链是否完整(把系统 CA 包当作信任锚) openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt /etc/nginx/ssl/example.com-chain.crt/etc/ssl/certs/ca-certificates.crt是 Debian/Ubuntu 的 CA 包路径;RHEL 9 上是/etc/pki/tls/certs/ca-bundle.crt。找不到就先用openssl version -d查看 OpenSSL 的配置目录再定位。
五、一个可复制的日志聚合脚本
#!/usr/bin/env bash # 用途:把 nginx error.log 里的 TLS 握手失败按"错误文本"和"客户端 IP"聚合。 # 适用:Debian 12 / Ubuntu 22.04 / RHEL 9,nginx 1.24+,日志为默认格式。 set -u LOG="${1:-/var/log/nginx/error.log}" echo "===== 1. 握手失败总量 =====" grep -c 'SSL_do_handshake() failed' "$LOG" 2>/dev/null || echo 0 echo echo "===== 2. 按错误文本聚合(已剥离十六进制码,跨 OpenSSL 版本可比)=====" grep -o 'SSL_do_handshake() failed (SSL: [^)]*)' "$LOG" 2>/dev/null \ | sed -E 's/error:[0-9A-Fa-f]+:[^:]*:?//g' \ | sed -E 's/[[:space:]]+/ /g' \ | sort | uniq -c | sort -rn | head -20 echo echo "===== 3. 按客户端 IP 聚合(判断是故障还是扫描)=====" grep 'SSL_do_handshake() failed' "$LOG" 2>/dev/null \ | grep -oE 'client: [0-9a-fA-F.:]+' | awk '{print $2}' \ | sort | uniq -c | sort -rn | head -20 echo echo "===== 4. 非握手类的 SSL 告警(证书链、Stapling、配置错误)=====" grep -iE 'ssl|openssl' "$LOG" 2>/dev/null \ | grep -v 'SSL_do_handshake() failed' \ | sed -E 's/^[0-9]{4}\/[0-9]{2}\/[0-9]{2} [0-9:]+ //' \ | sed -E 's/\[[a-z]+\] [0-9]+#[0-9]+: \*[0-9]+ //' \ | sort | uniq -c | sort -rn | head -20第 2 段是关键:把日期、进程号、连接号、十六进制码全部剥掉之后,剩下的就是一条条"语义相同"的错误,uniq -c一排序,问题的主次立刻清楚。第 3 段用来区分"某个客户端在打你"还是"所有客户端都连不上"——前者是探测,后者才是配置或证书问题。
常见坑点
1. 在 access.log 里找握手失败
❌grep 404 access.log找不到线索,就认为"日志里没记录" ✅grep -iE 'SSL_do_handshake' /var/log/nginx/error.log
握手失败没有 HTTP 请求可记,只在 error.log 里。
2. 拿十六进制错误码跨版本对号入座
❌ 看到error:0A00010B,去搜一篇 OpenSSL 1.1.1 时代的文章,按error:1408F10B的结论处理 ✅ 忽略十六进制码,按错误文本(wrong version number等)归类
OpenSSL 3.x 改了错误码编码格式,两代之间的码值无法对应。
3. 把 error_log 级别设成error以"减少噪音"
❌error_log /var/log/nginx/error.log error;✅ 排查期间至少info,或改用单独一份debug级别的日志文件
握手期的正常断开、client closed connection这类信息可能落在info/warn上。
4. 证书链只放了叶子证书
❌ssl_certificate指向的文件里只有服务器证书 ✅ 文件内容顺序是"服务器证书 → 中间证书",中间证书可以从 CA 的签发包中获得
浏览器往往能靠 AIA 自动补链,所以本地测试一切正常;但 Java、部分移动端和命令行客户端会直接报unknown ca,形成"只有某些用户报错"的诡异现象。
5. 保留 TLSv1.0 / TLSv1.1
❌ssl_protocols TLSv1 TLSv1.1 TLSv1.2;✅ssl_protocols TLSv1.2 TLSv1.3;
现在几乎没有必须依赖 TLSv1.0 的客户端,保留它们只会带来合规问题和更多unsupported protocol之外的攻击面。确实有老客户端时,用前置的兼容网关处理,而不是给主站降级。
6. 默认 server 没配证书,或没开ssl_reject_handshake
❌ 只给server_name example.com配了证书,其他 SNI 落到默认 server 上握手失败 ✅ 在默认 server 上使用ssl_reject_handshake on;(nginx 1.19.4+),或配一张占位证书
否则任何"用 IP 直连"或"用陌生域名解析过来"的请求都会在日志里刷握手失败,噪音很大。
7. 开了ssl_stapling却没配ssl_trusted_certificate
❌ssl_stapling on;就完事 ✅ 同时配ssl_stapling_verify on;与ssl_trusted_certificate(指向包含签发者证书的链文件)
否则 error.log 会持续出现"ssl_stapling" ignored, issuer certificate not found,而且 OCSP 状态实际上没生效。
8. 把扫描流量当成故障来排查
❌ 看到几千条peer closed connection in SSL handshake就去改 Nginx 配置 ✅ 先按 IP 聚合;集中在少数 IP、时间零散,就是探测,忽略即可
真正需要处理的信号是"错误集中在正常用户 IP 段、且在某个时间点突然开始"。
9. 用$ssl_protocol记日志却放在 80 端口的 server 上
❌ 在listen 80;的 server 里引用 TLS 变量做统计 ✅ 这些变量只在listen ... ssl的 server 里有效,普通 server 上为空
10. 改完配置直接restart
❌systemctl restart nginx✅nginx -t && systemctl reload nginx
证书写错、语法有误时,nginx -t能提前拦住;reload也不会中断已有连接。真到了必须重启才能生效的场景(例如更换监听地址),要提前确认有回滚路径。
总结
| 现象 | 去哪儿看 | 关键信息 |
|---|---|---|
| 完全看不到握手失败 | error.log(info及以上) | SSL_do_handshake() failed |
| 想知道失败在哪一层 | error.log 的错误文本 | 按文本归类,忽略十六进制码 |
| 想知道是谁、用的什么参数 | 带 TLS 变量的 access.log | $ssl_server_name、$ssl_protocol、$ssl_cipher |
| 用户报证书错误、你这边一切正常 | $ssl_server_name+ 默认 server 配置 | SNI 未匹配 → 落到默认 server 的证书 |
| 只有部分客户端报错 | openssl s_client -showcerts | 证书链是否含中间证书 |
| 大量握手失败但无规律 | 按client:后的 IP 聚合 | 区分扫描流量与真实故障 |
| 明文 HTTP 打到 443 | access.log 里的 497 状态码 | 用error_page 497重定向 |
TLS 握手失败排查的路线图可以压缩成四步:先在 error.log 里按错误文本归类定性,再给 access.log 加 TLS 变量找出是哪个 SNI/协议版本,然后用openssl s_client从客户端侧复现确认,最后才动配置。记住最关键的那条前提——握手失败不进 access log,凡是"打开 access log 什么都没发现"的,都不是日志有问题,而是找错了文件。