ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot项目Docker容器中TLS握手失败排查与修复

Spring Boot项目Docker容器中TLS握手失败排查与修复 如果你也遇到过这种情况Spring Boot项目在本地IDEA里跑得好好的接口调通功能正常打成可执行的jar包后丢进Docker容器里日志却突然抛出一句Received fatal alert: handshake_failure然后所有对外HTTPS请求全部瘫痪。别急这个问题我前后踩了三天最后把底层机制彻底摸透了。这篇文章就把我的排查思路、修复方案、以及如何避免再次踩坑的完整配置一次讲清楚代码和命令都可以直接抄。先说结论这个报错本质上不是网络通不通的问题也不是端口对不对的问题而是TLS握手阶段客户端和服务端没有谈拢。至于为什么本地一跑就通、一进Docker就挂原因往往藏在JDK版本、基础镜像、证书库、系统时钟这些平时根本不会多看一眼的角落里。1. 先把场景复现出来这个错误到底长什么样1.1 报错全貌和一句话定位顺手贴一个我当时的真实报错现场。Spring Boot 2.3.xJDK 8打成jar包丢进Docker容器启动后一切正常但一调用外部HTTPS接口比如某个支付平台的API日志里就出现javax.net.ssl.SSLHandshakeException: Received fatal alert: handshake_failure at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:133) at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:320) at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:263) at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:258) ...Received fatal alert: handshake_failure这句话翻译成人话就是客户端和服务端在进行TLS握手时没能就协议版本或加密套件达成一致服务端直接发了一个handshake_failure致命警报把连接掐了。这个错误最有迷惑性的地方在于它看起来像是网络层的问题甚至像是对方服务器故意拒绝你。我在最开始的两天里检查了防火墙、安全组、ACL、容器网络百思不得其解。直到我打开JVM的SSL调试日志才看到真正有价值的信息No appropriate protocol (protocol is disabled or cipher suites are inappropriate)。1.2 握手失败的三层原因图谱根据我这次踩坑的经验handshake_failure基本可以收敛到三个层面协议层客户端支持的TLS版本和服务端要求的TLS版本没有交集。比如服务端只开TLS 1.3你的JDK 8默认最高只支持TLS 1.2那就不可能握手成功。算法层加密套件Cipher Suite匹配失败。比如服务端要求使用ECDSA证书和对应套件客户端侧偏偏没有启用或者JDK把某些弱算法默认禁用了。证书层客户端不信任服务端的证书链。严格来说这通常报的是PKIX path building failed但某些网关和中间件会把证书校验失败统一包装成handshake_failure尤其是自签证书场景。1.3 为什么本地正常、容器里就挂这是最折磨人的地方。本地IDEA里能跑说明你的代码本身没有大问题一进Docker就挂说明运行环境变了。最常见的变量有三个JDK版本不同本地可能是JDK 11或者17容器里基础镜像自带的是JDK 8。JDK 8和JDK 11/17对TLS协议的支持有本质差异。基础镜像裁剪过度像alpine这种轻量镜像证书库、时区数据、glibc 都可能缺斤短两。容器系统时间不对Docker容器默认从宿主机继承时间但如果宿主机时间本身就有偏差或者容器内时区和证书有效期校验逻辑冲突TLS握手也会失败。所以排查方向必须从我的代码哪里错了切换到我的容器环境哪里不对。2. 逐层排查从日志到协议再到证书2.1 第一步看完整堆栈别只看第一行很多人看到Received fatal alert: handshake_failure就慌了到处搜这个错误但其实它只是结果不是原因。你要做的是找到真正触发握手的调用点以及目标服务器地址。在我这个场景里日志往上翻几行就能看到Caused by: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target看到没真正的原因藏在下面。如果是这种情况直接去搞证书信任问题而不是纠结TLS协议。但如果你翻完整个堆栈没有PKIX path building failed只有孤单的一句handshake_failure那大概率是协议或者算法匹配问题。2.2 用OpenSSL探一探服务端TLS底牌在宿主机上用openssl命令可以快速查看目标服务器的TLS协议支持情况。这一步能帮你确认服务端到底要求什么openssl s_client -connect api.example.com:443 -tls1_2 -servername api.example.com /dev/null这个命令的含义是用TLS 1.2去连接api.example.com:443看服务端是否能正常完成握手。如果这句话返回了证书链和Verify return code说明服务端至少支持TLS 1.2。再试TLS 1.3openssl s_client -connect api.example.com:443 -tls1_3 -servername api.example.com /dev/null如果TLS 1.3能通、而你的Java程序不通就要检查Java这边的协议支持如果TLS 1.2也不能通那就换一个思路对方服务器可能限制了客户端IP、要求SNI、或者有双向TLS认证Java这边缺了客户端证书。另外还有一个关键参数值得关注输出里的Cipher is字段它表明服务端最终选择了一个什么加密套件。记住这个套件名字后面排查Java侧算法时有大用。2.3 检查JDK支持的TLS协议和算法如果你能进入容器先在容器里确认JDK的真实版本docker exec -it 容器名 java -version输出类似openjdk version 1.8.0_292 OpenJDK Runtime Environment (build 1.8.0_292-b10) OpenJDK 64-Bit Server VM (build 25.292-b10, mixed mode)然后写一个一分钟就能跑完的小程序看看当前JDK支持的TLS协议import javax.net.ssl.SSLContext; import java.security.Security; public class ShowProtocols { public static void main(String[] args) throws Exception { SSLContext context SSLContext.getInstance(TLS); context.init(null, null, null); System.out.println(默认SSLContext协议: context.getProtocol()); for (String p : context.getSupportedSSLParameters().getProtocols()) { System.out.println(支持的协议: p); } System.out.println(--------------------------------------------------); for (String algo : Security.getAlgorithms(SSLContext)) { System.out.println(安全算法: algo); } } }在容器里用java ShowProtocols.java跑一下JDK 11可以直接java ShowProtocols.javaJDK 8需要先javac你很快就能看到JDK 8一般输出支持的协议: TLSv1.1 支持的协议: TLSv1.2注意JDK 8默认不支持TLS 1.3。如果你的服务端只开TLS 1.3那必然handshake_failure。JDK 11及以后才默认支持TLS 1.3。2.4 证书链和时间问题怎么验证如果你的报错信息里带了PKIX path building failed或者你怀疑是证书信任问题那就要检查容器里的cacerts证书库。Java的证书库默认路径是$JAVA_HOME/jre/lib/security/cacerts在容器里可以这样确认docker exec -it 容器名 keytool -list -keystore $JAVA_HOME/jre/lib/security/cacerts -storepass changeit | grep -i 你的目标证书颁发机构changeit是Java默认的cacerts密码如果你没改过就是这个。如果容器里压根找不到目标证书颁发机构的条目那Java就不会信任这个服务端证书握手自然失败。时间问题则可以直接在容器里执行docker exec -it 容器名 date如果当前时间和真实时间差了超过证书有效期容忍范围TLS握手也会失败尤其一些对时间敏感的老网关几分钟的偏差都可能导致握手被拒。3. 最可能的根因JDK版本与TLS版本不匹配3.1 一张表看懂JDK和TLS版本的对应关系这里必须把JDK版本和TLS协议支持的对应关系讲透。很多Spring Boot开发者用的还是JDK 8但外部服务方可能已经升级到TLS 1.3 Only这就成了最典型的冲突。JDK大版本默认支持的TLS版本是否默认启用TLS 1.3JDK 6及以下TLS 1.0不支持JDK 7TLS 1.0 / TLS 1.1不支持JDK 8TLS 1.0 / TLS 1.1 / TLS 1.2默认不支持8u261之后可手动开启JDK 11TLS 1.0 / TLS 1.1 / TLS 1.2 / TLS 1.3默认启用JDK 17TLS 1.0 / TLS 1.1 / TLS 1.2 / TLS 1.3默认启用所以如果你用的是openjdk:8-jdk-alpine这类基础镜像而对方服务端要求TLS 1.3那么无论你怎么调代码都会在协议协商阶段被拒绝。注意JDK 8其实从8u261版本开始在JVM参数层面支持了TLS 1.3需要额外指定启用java -Djdk.tls.client.protocolsTLSv1.3 -jar app.jar但我实测下来8u261以前的JDK 8就算加这个参数也没用因为JVM根本不认识这个协议。所以最稳妥的方案还是升级JDK版本。3.2 容器里的JDK到底哪来的怎么看Docker镜像里的JDK不是你本地的JDK。经常出现的情况是你本地用的JDK 17但Dockerfile里写的是FROM openjdk:8-jdk-alpine结果打出来的镜像里跑的就是JDK 8。看Dockerfile是最直接的判断方法FROM openjdk:8-jdk-alpine COPY target/demo-0.0.1-SNAPSHOT.jar /app.jar ENTRYPOINT [java, -jar, /app.jar]这种写法在2024年之后的维护视角里其实很糟糕。openjdk:8-jdk-alpine不仅JDK版本老而且Alpine用的musl libc和很多依赖不兼容容易引发各种奇怪问题。建议直接用eclipse-temurin或amazoncorretto这类现代发行版。查看容器内实际生效的JDK还有一个方式docker exec -it 容器名 sh -c echo $JAVA_HOME which java java -version3.3 如果服务端强制TLS 1.3正确的升级路径如果确认服务端只支持TLS 1.3那别折腾参数白费劲直接换JDK版本。把基础镜像从openjdk:8-jdk-alpine换成FROM eclipse-temurin:11-jre或者有上游依赖要求必须用JDK 8的至少换到兼容性更好的发行版FROM amazoncorretto:8u392注意eclipse-temurin:8-jre这类镜像虽然也是JDK 8但如果你需要TLS 1.3支持建议用amazoncorretto:8u392或更高版本8u392之后Corretto也支持TLS 1.3。不过最好的做法仍然是直接升级到Java 11或17Spring Boot 2.3以上版本在Java 11下运行完全没问题Spring Boot 3则强制要求JDK 17。4. 影响面更大的坑基础镜像缺依赖4.1 Alpine镜像和glibc的恩怨在排查handshake_failure时我遇到过一个非常隐蔽的坑openjdk:8-jdk-alpine镜像里的JDK在某些TLS流程中会因为缺少glibc内部的一些实现细节导致JSSE在握手时表现异常。Alpine默认使用musl而OpenJDK官方对musl的完整支持一直相对滞后。虽然大多数Java代码在Alpine上能跑但涉及网络、DNS、SSL这类底层操作时就可能出现各种匪夷所思的情况。我当时的处理办法是彻底放弃Alpine换成基于Ubuntu的镜像FROM eclipse-temurin:8-jre-jammy或者直接FROM eclipse-temurin:11-jre-jammy换完之后连之前偶尔出现的DNS解析慢的问题都一起消失了。所以我的建议是生产环境的Java镜像优先选eclipse-temurin、amazoncorretto这些基于glibc的镜像别为了省几十MB去用Alpine省那点空间。4.2 精简镜像缺证书库的排查方法有些极简裁剪的镜像或者你在Dockerfile里自作主张删过文件会把cacerts这个关键文件搞丢。Java的JSSE在初始化时会查找$JAVA_HOME/lib/security/cacerts不同发行版路径略有区别。如果找不到JVM不会直接报错而是默默使用一个空证书库然后所有HTTPS请求全部握手失败而且报错非常笼统。在容器里快速确认证书库是否完整docker exec -it 容器名 sh -c ls -l $JAVA_HOME/lib/security/cacerts $JAVA_HOME/jre/lib/security/cacerts 2/dev/null正常输出应该能看到文件大小比如217KB或770KB左右。如果ls是个全空结果那证书库就有问题。这种场景下的修复也比较直接从宿主机拷贝一个系统根证书库进容器docker cp /etc/ssl/certs/java/cacerts 容器名:/usr/lib/jvm/java-8-openjdk-amd64/jre/lib/security/cacerts但容器重新创建后又会丢所以正确做法是在Dockerfile里加一行COPY cacerts /usr/lib/jvm/java-8-openjdk-amd64/jre/lib/security/cacerts如果是在线联网环境更省事的做法是在镜像构建阶段用APT更新CA证书RUN apt-get update apt-get install -y ca-certificates update-ca-certificates4.3 时间偏差和时区问题这是最容易忽略的一个点。我遇到过一个现场容器时间比真实时间快了整整8个小时因为镜像默认时区是UTC而宿主机时区是东八区再加上某些云主机的硬件时钟漂移最终导致证书有效期校验直接短路。你可以做一个非常简单的实验。在容器里执行date如果输出时间和真实时间差了几个小时且服务的证书有效期边界卡得很紧握手失败就一点都不奇怪。解决方式是在启动容器时挂载宿主机的时区文件docker run -v /etc/localtime:/etc/localtime:ro -v /etc/timezone:/etc/timezone:ro -p 8080:8080 your-image如果用的是Docker Compose在volumes里加上volumes: - /etc/localtime:/etc/localtime:ro - /etc/timezone:/etc/timezone:ro注意挂载时区文件解决的是时区显示问题如果宿主机硬件时间本身就错了那还是得先校宿主机时间。容器内可以直接用date -s校准但重启会丢不如在宿主机上跑ntpdate或者启用chronyd。5. 实操完整修复步骤和参数详解5.1 先加JVM调试参数拿到全部线索动手修复之前必须拿到完整的问题线索。在启动命令里加上JSSE调试参数java -Djavax.net.debugssl:handshake -jar app.jar如果是通过Docker运行可以放在环境变量里注入docker run -e JAVA_TOOL_OPTIONS-Djavax.net.debugssl:handshake your-image跑起来后再次调用那个失败的HTTPS接口日志里会输出非常详细的TLS握手过程javax.net.ssl|HANDSHAKE|...|ClientHello javax.net.ssl|HANDSHAKE|...|ServerHello javax.net.ssl|ALERT|...|handshake_failure重点关注ClientHello后面的Supported Versions和Cipher Suites部分这一眼就能看出客户端提供了哪些协议和算法再去对照服务端的限制问题就一目了然了。调试完记得去掉这个参数因为ssl:handshake的日志量非常恐怖会严重拖慢生产环境的启动速度和高频请求下的吞吐量。5.2 升级JDK/换基础镜像的具体操作这里给出两个可直接落地的Dockerfile方案。方案一最终用JDK 11推荐用于Spring Boot 2.2及以上版本FROM eclipse-temurin:11-jre-jammy WORKDIR /app RUN useradd -r -u 1001 spring COPY target/demo-0.0.1-SNAPSHOT.jar /app/app.jar USER spring EXPOSE 8080 ENTRYPOINT [java, -jar, /app/app.jar]方案二暂时无法升级、必须用JDK 8时用Corretto替代AlpineFROM amazoncorretto:8u392 WORKDIR /app COPY target/demo-0.0.1-SNAPSHOT.jar /app/app.jar ENTRYPOINT [java, -jar, /app/app.jar]5.3 针对算法禁用的JVM参数组合如果你排查下来发现不是TLS版本问题、而是某个加密算法被JDK默认禁用那可以尝试调整jdk.tls.disabledAlgorithms这个安全属性。比如JDK 8u261之后默认禁用了很多弱算法在某些老旧的加密服务端上就会被拒。你可以在启动参数里覆盖这个安全属性java -Djdk.tls.disabledAlgorithmsSSLv3,RC4,DES,MD5withRSA,DH keySize 1024, EC keySize 160, RSA keySize 1024, 3DES_EDE_CBC, HMAC MD5, RC4_40, DES40_CBC -jar app.jar但我得提醒一句这个操作要非常慎重。禁用弱算法是JDK官方出于安全考虑的行为你强行放开等于把一个有漏洞的后门重新打开。我在生产环境里宁可通过升级对端服务的方式解决而不是去关安全开关。如果你确定只是协议版本问题更安全的JVM参数是显式指定客户端协议java -Djdk.tls.client.protocolsTLSv1.2 -jar app.jar如果是JDK 8u261以上且服务端支持TLS 1.3可以尝试java -Djdk.tls.client.protocolsTLSv1.3 -jar app.jar这里额外补充一下-Djdk.tls.client.protocols和-Dhttps.protocols的区别。前者是全局的TLS客户端协议配置影响所有基于JSSE的客户端后者只影响HttpsURLConnection和部分Apache HttpClient。如果你用的是Spring Boot默认的RestTemplate底层是JDK HttpClient或HttpURLConnection优先用jdk.tls.client.protocols。5.4 修改Java代码强制指定协议有些时候你不想改镜像也不想动全局参数那就只能在代码层做约束。如果你使用的是Apache HttpClient可以这样指定协议SSLContext sslContext SSLContext.getInstance(TLSv1.2); sslContext.init(null, null, new SecureRandom()); SSLConnectionSocketFactory socketFactory new SSLConnectionSocketFactory(sslContext, new String[]{TLSv1.2}, null, SSLConnectionSocketFactory.getDefaultHostnameVerifier()); CloseableHttpClient httpClient HttpClients.custom() .setSSLSocketFactory(socketFactory) .build();如果是Spring Boot Reactor Netty的WebClient可以在配置里指定spring: codec: max-in-memory-size: 16MB reactor: netty: ssl: protocol: TLSv1.2不过这些都是局部修补如果根因是JDK版本不支持早晚还会在其他调用点冒出来。所以我建议代码可以临时绕过根治必须升级环境。5.5 如何验证修复效果改完之后怎么确认问题真正解决了我一般按下面这个顺序操作第一步在宿主上确认服务端TLS协议支持openssl s_client -connect api.example.com:443 -tls1_2 -servername api.example.com /dev/null 21 | grep Protocol输出Protocol : TLSv1.2说明TLS 1.2握手成功。第二步在容器里写一个最简单的HTTPS调用测试docker exec -it 容器名 bash然后直接在容器里运行curl -v https://api.example.com/some/path如果容器里没有curl就用Java跑一个小类import java.net.URL; import javax.net.ssl.HttpsURLConnection; public class TestHttps { public static void main(String[] args) throws Exception { URL url new URL(args[0]); HttpsURLConnection conn (HttpsURLConnection) url.openConnection(); conn.setConnectTimeout(5000); conn.setReadTimeout(5000); System.out.println(ResponseCode conn.getResponseCode()); } }以上命令编译运行后输出ResponseCode 200才算真正通过。第三步把启动参数里的-Djavax.net.debugssl:handshake去掉恢复正常启动参数再回归一遍核心业务接口。6. 常见问题速查表整理一个速查表方便以后遇到类似问题直接对号入座。报错关键信息根因快速处理Received fatal alert: handshake_failure且没有其他伴随信息协议或加密套件不匹配加-Djdk.tls.client.protocolsTLSv1.2或升级JDKPKIX path building failed目标证书不受信任更新cacerts导入根证书No appropriate protocolJDK默认禁用了某些协议显式指定协议或放开jdk.tls.disabledAlgorithmsCertificate expired/CertPathValidatorException服务器证书过期或系统时间偏差挂载宿主机时间或校时client doesnt support TLS 1.3之类的调试日志JDK版本太低换eclipse-temurin:11-jre或更高unable to find valid certification path to requested target自签证书或私有CA把自签证书导入cacerts本地正常、容器失败且协议层排查无果基础镜像缺依赖或证书库不完整换成eclipse-temurin/amazoncorretto这张表我每次排查SSL问题时都会先过一遍。百分之八十的情况集中在第一行和第四行尤其是容器时间偏差这个坑特别隐蔽很多人根本想不到去查。7. 个人经验总结踩过几次坑之后我现在的做事原则变得非常简单Spring Boot项目的容器镜像不要一上来就选Alpine也不要因为省事就继续用OpenJDK老镜像。在我的项目里现在Dockerfile的基础镜像统一换成了eclipse-temurin:17-jre-jammySpring Boot 3项目或eclipse-temurin:11-jre-jammySpring Boot 2老项目并且在启动命令里显式加了-Dhttps.protocolsTLSv1.2。同时所有容器启动一律挂载宿主机的时区文件这样既统一了系统时间也大大减少了TLS握手相关的意外问题。最后分享一个小技巧如果你遇到某个HTTPS接口在本地能通、容器不能通的诡异情况第一件事不是去查代码而是把容器内和宿主机上的三样东西对比一遍——JDK版本、日期时间、cacerts证书库清单。这三个变量直接决定了TLS握手的成败也是Received fatal alert: handshake_failure背后最常见的根源。把这套排查流程存下来下次再有人问你这个问题你也能一眼看出症结在哪里。
返回列表