1. 问题现场:一次典型的SSL握手失败排查
那天下午,监控系统突然报警,一个核心业务接口的响应时间曲线拉成了一条直线,紧接着错误率飙升。登录服务器一看,Nginx错误日志里刷满了同一行刺眼的记录:peer closed connection in SSL handshake while SSL handshaking to upstream。这个错误对于使用Nginx作为反向代理,并且后端服务(upstream)启用了HTTPS的场景来说,堪称“经典款”故障。它直白地告诉你:Nginx代理服务器试图与后端服务器建立安全的SSL/TLS连接时,握手过程被对方(peer)无情地中断了。表面看是连接问题,背后却可能藏着证书、协议、配置乃至网络层面的各种“暗礁”。对于运维和开发来说,这不仅仅是一个错误日志,更是一道需要综合排查的调试题。
2. 核心原理:SSL/TLS握手与Nginx代理的角色
要解决这个问题,我们必须先理解Nginx在这个架构中扮演的角色以及SSL握手是如何进行的。在一个典型的HTTPS反向代理场景中,数据流经历了两次加密解密过程:
- 客户端到Nginx(第一层HTTPS):外部用户浏览器或客户端与Nginx服务器建立HTTPS连接。此时,Nginx出示自己的SSL证书,完成与客户端的握手。对于客户端而言,Nginx就是终点站。
- Nginx到后端服务器(第二层HTTPS):Nginx作为代理,需要将请求转发给真正的后端服务(如Tomcat、Node.js、另一个Nginx等)。如果后端服务也要求HTTPS(即
proxy_pass指向https://开头的地址),那么Nginx就需要扮演“客户端”的角色,与后端服务建立一个新的、独立的HTTPS连接。peer closed connection in SSL handshake这个错误,就发生在这第二次握手的过程中。
这个过程的简化时序如下:
- Nginx(作为客户端)向配置的后端服务器地址发起TCP连接。
- TCP连接建立后,Nginx发送
Client Hello消息,开始SSL/TLS握手。 - 后端服务器应回复
Server Hello,并发送其服务器证书。 - 随后进行密钥交换等步骤,最终完成握手,建立加密信道。
错误信息中的“peer closed connection”意味着后端服务器在收到Client Hello后,或者在发送Server Hello及证书的过程中,主动关闭了TCP连接,导致握手失败。这通常是因为后端服务器对Nginx发起的握手请求“不满意”,从而拒绝服务。
3. 根因分析与系统性排查清单
导致后端服务器拒绝握手的原因多种多样,我们可以按照从外到内、从简到繁的顺序进行系统性排查。以下是一个高效的排查路径:
3.1 第一步:基础网络与可达性检查
在怀疑复杂的SSL配置之前,先确保最基础的通信是正常的。
- 网络连通性:从Nginx服务器使用
telnet或nc命令,测试是否能连接到后端服务器的HTTPS端口(通常是443)。telnet <upstream_ip> 443,如果能连接成功(看到空白屏幕或光标闪烁),说明TCP层是通的。 - 后端服务状态:确认后端服务进程是否在正常运行,并且监听在正确的IP和端口上。使用
netstat -tlnp或ss -tlnp命令查看。 - 防火墙与安全组:检查Nginx服务器与后端服务器之间的防火墙(如iptables, firewalld)以及云服务商的安全组规则,确保443端口的入站和出站流量都是允许的。
注意:有时候,后端服务器可能只监听在
127.0.0.1或某个内网IP上,而Nginx配置中使用的却是域名或另一个IP,这会导致连接失败。确保Nginx的proxy_pass地址与后端服务实际监听的地址一致。
3.2 第二步:SSL证书与域名验证问题
这是最常见的一类原因。当Nginx作为客户端连接上游时,它会验证上游服务器的证书。
- 证书是否有效:后端服务器使用的SSL证书可能已过期、是自签名的、或者证书链不完整。Nginx默认会验证这些。
- 域名不匹配:Nginx通过
proxy_pass中配置的域名(或IP)去连接后端,但后端服务器证书中的Common Name (CN)或Subject Alternative Name (SAN)字段不包含这个域名或IP。例如,你用proxy_pass https://backend.internal.company.com;,但后端证书是为backend.service.com签发的。 - Nginx的SSL验证配置:查看Nginx配置中
proxy_ssl_verify和proxy_ssl_trusted_certificate等指令。
排查命令:我们可以模拟Nginx的行为,使用openssl命令来诊断:
openssl s_client -connect <upstream_host>:<upstream_port> -servername <upstream_host>例如:openssl s_client -connect 10.0.1.5:443 -servername backend.internal.company.com
仔细查看命令输出,重点关注:
Verify return code:是否为0(成功)。如果是其他数字(如21),说明验证失败。- 证书的颁发者和有效期。
- 证书的
Subject和Subject Alternative Name是否包含你连接时使用的主机名。
3.3 第三步:TLS协议版本与加密套件不匹配
Nginx(客户端)和后端服务器(服务端)支持的TLS协议版本(如TLSv1.2, TLSv1.3)和加密套件列表可能没有交集。
- Nginx配置:通过
proxy_ssl_protocols指令指定了过高的协议版本(如只允许TLSv1.3),而后端服务器只支持到TLSv1.2。 - 后端服务配置:后端服务(如旧版本的Java应用服务器)可能只支持老旧的协议(如SSLv3, TLSv1.0),而现代Nginx默认已禁用这些不安全的协议。
- 加密套件:通过
proxy_ssl_ciphers指令指定的加密套件,后端服务器都不支持。
排查命令:使用openssl指定协议版本来测试:
openssl s_client -connect <upstream_host>:<upstream_port> -tls1_2 # 测试TLS 1.2 openssl s_client -connect <upstream_host>:<upstream_port> -tls1_3 # 测试TLS 1.3如果某个版本能连接成功而另一个失败,就指明了问题方向。也可以使用nmap进行更详细的扫描:nmap --script ssl-enum-ciphers -p 443 <upstream_host>。
3.4 第四步:Nginx代理配置细节深挖
Nginx中与上游SSL连接相关的配置指令非常关键,一个参数不对就可能导致握手失败。
proxy_ssl_verify:如果设置为on(默认值),Nginx会验证上游服务器的证书。如果上游是自签名证书或内部证书,且未正确配置信任链,就会失败。临时排查时,可以将其设为off来快速定位是否是证书验证问题。(生产环境慎用,仅作调试)。proxy_ssl_trusted_certificate:当proxy_ssl_verify为on时,需要指定一个包含受信任CA证书的文件,用于验证上游证书。如果上游使用私有CA或自签名证书,必须将对应的CA证书或自签名证书本身放入此文件。proxy_ssl_name:这个指令至关重要!它指定了Nginx在SSL握手时发送的SNI (Server Name Indication)扩展字段。对于现代服务器,如果证书托管了多个域名(多域名证书或通配符证书),服务器需要根据SNI来决定返回哪个证书。如果proxy_ssl_name没有设置,或者设置的值与证书域名不匹配,服务器可能返回一个默认的、不匹配的证书,导致验证失败。通常,它应该设置为proxy_pass中域名对应的那个主机名。proxy_ssl_server_name:控制是否启用SNI,默认为off。对于绝大多数需要域名验证的现代HTTPS服务,必须将其设置为on。proxy_ssl_certificate和proxy_ssl_certificate_key:如果你的后端服务要求客户端(此处是Nginx)也提供证书(双向TLS/mTLS),那么你需要配置这两个指令。大多数内部服务不需要这个。
3.5 第五步:后端服务器自身日志与限制
不要只盯着Nginx看,后端服务器的日志往往包含拒绝连接的直接原因。
- 查看后端应用日志:登录后端服务器,查看其应用日志(如Tomcat的catalina.out, Spring Boot的日志文件)。里面可能会有更详细的错误信息,例如“收到不支持的协议版本”、“无法验证客户端证书”(如果是双向TLS)、“主机名不匹配”等。
- 后端服务器的SSL配置:检查后端服务器自身的SSL/TLS配置。例如,Tomcat的
server.xml中Connector的sslProtocol、ciphers等参数;Nginx作为后端时的ssl_protocols、ssl_ciphers。 - 连接数或速率限制:后端服务器可能设置了单IP连接数限制,而Nginx代理服务器的IP恰好触发了这个限制。
4. 实战配置修复与示例
假设我们有一个内部服务backend-app,运行在https://backend.internal:8443,使用内部私有CA签发的证书。Nginx配置最初可能是这样的:
upstream backend { server backend.internal:8443; } server { listen 443 ssl; server_name example.com; ssl_certificate /path/to/example.com.crt; ssl_certificate_key /path/to/example.com.key; location /api/ { proxy_pass https://backend; # 这里指向upstream,但upstream定义的是`backend.internal:8443` proxy_set_header Host $host; # 缺少关键的proxy_ssl_*配置 } }这个配置几乎必然导致peer closed connection in SSL handshake错误。
修复后的配置如下:
upstream backend { # 通常 upstream 块用于负载均衡,对于简单的单个后端,直接在 proxy_pass 中写全URL更方便。 # 但为了示例清晰,我们保留upstream。 server backend.internal:8443; } server { listen 443 ssl; server_name example.com; ssl_certificate /path/to/example.com.crt; ssl_certificate_key /path/to/example.com.key; location /api/ { proxy_pass https://backend; # 重要:设置上游SSL连接的主机名(用于SNI和证书验证) proxy_ssl_name backend.internal; # 启用SNI proxy_ssl_server_name on; # 验证上游服务器证书(安全做法) proxy_ssl_verify on; # 指定信任的CA证书文件,里面需要包含签发 backend.internal 证书的私有CA证书 proxy_ssl_trusted_certificate /path/to/internal-ca-bundle.crt; # 设置验证深度 proxy_ssl_verify_depth 2; # 指定支持的协议和加密套件(可选,通常用系统默认值即可,除非有兼容性问题) proxy_ssl_protocols TLSv1.2 TLSv1.3; # proxy_ssl_ciphers HIGH:!aNULL:!MD5; # 传递必要的头部 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }关键修复点解释:
proxy_ssl_name backend.internal;:明确告诉Nginx,在向上游握手时,声称自己是backend.internal。这决定了SNI字段和证书验证时预期的主机名。proxy_ssl_server_name on;:显式启用SNI扩展,这对于现代TLS服务是必须的。proxy_ssl_verify on;与proxy_ssl_trusted_certificate:开启了证书验证,并提供了正确的信任链。你需要将签发backend.internal证书的CA证书(或该证书本身,如果是自签名)添加到internal-ca-bundle.crt文件中。proxy_ssl_protocols:明确协议版本,避免因默认协议列表不一致导致的不兼容。
实操心得:在调试阶段,一个非常有效的“二分法”是,先将
proxy_ssl_verify设置为off,并注释掉proxy_ssl_trusted_certificate。如果错误消失,那么问题100%出在证书验证环节(证书无效、域名不匹配、缺少信任链)。如果错误依旧,那么问题更可能出在协议/套件不匹配或SNI未设置上。
5. 高级场景与疑难杂症
5.1 场景:后端服务监听在本地环回地址
有时,后端服务(如一个开发中的API)只监听在127.0.0.1:8443。Nginx在同一台机器上,但proxy_pass https://127.0.0.1:8443;依然报错。这可能是因为:
- 证书的SAN里没有
127.0.0.1或localhost。 - 某些应用对来自环回地址的HTTPS连接有特殊处理。
解决方案:
- 为本地开发生成包含
127.0.0.1和localhost的SAN证书。 - 或者,在测试时让后端服务监听在
0.0.0.0(所有接口),并使用主机名或局域网IP访问,并确保证书匹配。 - 更粗暴的测试方式:在Nginx配置中,对这个特定的后端关闭SSL验证(
proxy_ssl_verify off)并禁用SNI(proxy_ssl_server_name off),但这仅限临时调试。
5.2 场景:使用Docker容器或Kubernetes
在容器化环境中,服务名(Service Name)被用作主机名。例如,在K8s中,proxy_pass https://my-service.namespace.svc.cluster.local:443;。
- 问题:Pod内的证书通常是为Service名签发的(如
my-service.namespace.svc.cluster.local),但Nginx容器内可能没有配置对应的DNS解析,或者SNI设置不正确。 - 解决方案:
- 确保Nginx Pod的
/etc/resolv.conf正确,能解析K8s内部域名。 - 在Nginx配置中,
proxy_ssl_name必须设置为完整的Service域名。 - 将K8s内部CA的证书挂载到Nginx容器中,并配置
proxy_ssl_trusted_certificate指向它。
- 确保Nginx Pod的
5.3 场景:后端服务器要求严格的Cipher Suite
某些安全要求极高的后端服务,可能只允许少数几个强加密套件。如果Nginx默认的或配置的套件列表与之不匹配,握手也会失败。
排查与解决:
- 从后端服务器管理员那里获取其允许的加密套件列表。
- 在Nginx的
proxy_ssl_ciphers指令中,精确配置与之匹配的套件。例如:proxy_ssl_ciphers 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256'; - 使用
openssl s_client -cipher参数测试特定套件是否可行。
6. 问题排查速查表与命令总结
当peer closed connection in SSL handshake错误再次出现时,你可以按照下表快速定位:
| 排查方向 | 关键检查点 | 常用命令/方法 |
|---|---|---|
| 网络与可达性 | 端口是否开放,服务是否监听 | telnet/nc <host> <port>,netstat -tlnp,curl -v https://...(直连后端) |
| 证书验证 | 证书有效性、域名匹配、信任链 | openssl s_client -connect ... -servername ...查看返回码和证书信息 |
| 协议/套件兼容 | TLS版本、加密套件是否匹配 | openssl s_client -connect ... -tls1_2,nmap --script ssl-enum-ciphers |
| Nginx配置 | proxy_ssl_verify,proxy_ssl_name,proxy_ssl_server_name | 检查nginx.conf相关指令,特别是SNI和验证设置 |
| 后端日志 | 后端应用自身的错误信息 | 直接登录后端服务器,查看应用日志文件 |
| 临时调试 | 快速隔离证书验证问题 | 在Nginx配置中设置proxy_ssl_verify off;和proxy_ssl_server_name off;(调试完务必改回) |
一套组合诊断命令:
# 1. 测试基础TCP连接 nc -zv backend.internal 8443 # 2. 模拟Nginx进行完整的SSL握手和证书验证 openssl s_client -connect backend.internal:8443 \ -servername backend.internal \ -CAfile /path/to/internal-ca-bundle.crt \ -tls1_2 # 3. 如果openssl连接成功,但Nginx失败,检查Nginx配置细节 # 4. 如果openssl也失败,根据其输出错误信息(verify error, handshake failure等)深入排查处理这类问题的核心思路是“分而治之”:先确保物理连接畅通,然后模拟客户端(Nginx)的行为去测试SSL握手,对比成功和失败时配置的差异,最后结合两端日志进行精准定位。每一次对这类错误的成功排查,都会让你对HTTPS协议栈和Nginx的代理机制有更深一层的理解。