Linux Nginx 怎么通过日志分析 TLS 握手失败的错误原因

发布时间:2026/10/4 19:59:00
Linux Nginx 怎么通过日志分析 TLS 握手失败的错误原因
前言排查 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.25Debian 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 21 | 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://但连的是 443no 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 9nginx 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.loginfo及以上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 什么都没发现的都不是日志有问题而是找错了文件。