☰
Linux Nginx 怎么通过日志分析 TLS 握手失败的错误原因
2026/10/4 19:58:29 网站建设 项目流程

前言

排查 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 shareTLS 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 foundOCSP 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 打到 443access.log 里的 497 状态码用error_page 497重定向


TLS 握手失败排查的路线图可以压缩成四步:先在 error.log 里按错误文本归类定性,再给 access.log 加 TLS 变量找出是哪个 SNI/协议版本,然后用openssl s_client从客户端侧复现确认,最后才动配置。记住最关键的那条前提——握手失败不进 access log,凡是"打开 access log 什么都没发现"的,都不是日志有问题,而是找错了文件。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询