
容器里跑jar还想着远程debug这篇把坑全给你趟平了先把话撂这儿如果你还在靠往代码里塞System.out.println来排查容器里的Java服务那你大概还没体会过远程debug的快乐。用Docker跑Spring Boot服务本地一把梭启动没问题一到容器里就原形毕露日志翻半天也定位不到问题最后只能硬着头皮猜。这玩意儿说白了就是没把JVM的调试端口暴露出来或者说压根不知道怎么让本地IDE跟容器里的JVM打通。这篇文章就专门讲清楚一件事怎么让部署在容器里的jar包支持从本地IDE远程打debug断点一行一行看到真实环境里的变量值。适合所有用Docker部署Java服务、被线上问题折腾过的人也适合刚接触容器部署想提前把调试能力配好的新手。1. 先理清思路容器里的jar怎么跑debug为什么难搞1.1 容器启动jar的常规姿势你选对了吗日常部署Java应用进容器绝大多数人写Dockerfile时都会用CMD或者ENTRYPOINT最常见的写法就是java -jar app.jar。但这里有个细节如果你直接写java -jar那Java进程就是容器里的一号进程信号转发、优雅停机这些事都好办。可如果你图省事写了个启动脚本脚本里再起java信号处理就会多一层后面调容器停机会多出很多莫名其妙的问题。另一个容易踩的坑是JVM参数没法灵活传。很多人的Dockerfile长这样FROM openjdk:8-jdk-alpine COPY app.jar /app.jar CMD [java, -jar, /app.jar]这就把启动命令写死了。你想要调个堆内存、换个GC策略、开个远程debug都得改镜像重新构建非常不灵活。我个人的习惯是启动命令尽量用ENTRYPOINT配合参数传递把JVM参数通过环境变量注入比如这样FROM eclipse-temurin:8-jdk ENV JAVA_OPTS ENTRYPOINT [sh, -c, java $JAVA_OPTS -jar /app.jar]这样启动容器的时候只要在docker run里加-e JAVA_OPTS-Xmx512m -agentlib:jdwp...就能在不改镜像的情况下灵活控制JVM行为。这个习惯也是远程debug能够灵活开、关的关键后面实操部分会细说。1.2 远程debug的链路一个请求怎么从IDE到容器很多人一听“远程debug”就觉得很玄乎其实拆开看就三环你的IDE是一个调试客户端容器里的JVM是一个调试服务端中间通过一条网络连接传递调试指令和数据。你写代码的时候IDEA里打断点本质上是在字节码层面设置了断点位置。当你启动远程调试时IDEA会通过JDWP协议连接上容器里JVM的调试端口。之后每次代码执行到断点位置JVM就会暂停当前线程把线程堆栈、局部变量这些信息通过连接发给IDEA。你在IDEA里点“下一步”、“查看变量”这些指令也是通过这条连接发回给JVM的。关键点在于IDEA需要一份和容器里一致的源码。JVM传递的信息是“哪个类、哪个方法、第几行”IDEA拿到之后对比自己本地的源码来展示。所以源码版本必须对得上否则会出现断点位置漂移、代码对不上的情况。除了源码还要有个能互通的网络。容器虽然在你本机跑但它是一个独立的网络命名空间你从宿主机访问它必须要么用localhost加映射好的端口要么直接走容器IP。这就是为什么要做端口映射。从IDEA来看它就是简单地连接一个TCP端口至于那个端口背后是容器还是物理机IDEA完全不关心。1.3 JDWP是个什么东西三个关键参数讲透JDWP是Java Debug Wire Protocol翻译过来就是Java调试线协议。它是JVM和调试器之间的通信桥梁。你启动远程debug时需要在JVM参数里加一段-agentlib:jdwp...。这段参数里有几个关键项我一个个拆开讲因为这里面的坑实在太多了。首先是transport。这个基本就固定是dt_socket意思是走TCP/IP网络通信。还有个dt_shmem是Windows共享内存的跨机器用不了基本见不着。然后是server。servery表示当前JVM作为调试服务端主动监听端口等待调试器连接。如果是servern那就是JVM作为客户端反向去连接一个调试服务端这个在特殊场景才用日常调试用不上。再就是address。这是监听端口。这里有一个非常关键的版本差异后面会重点说。还有个suspend参数。suspendy表示JVM在启动后、执行main方法之前先停在那里等待调试器连接连上之后才继续跑。suspendn则是不管有没有调试器连接JVM都直接启动。这个参数决定了你调试的是启动阶段的问题还是正常运行阶段的问题。调试启动类故障用y平时跑服务用n千万别记反了。2. 准备工作镜像、端口、参数一个都不能少2.1 基础镜像选型别随便拿来就用讲远程debug之前先把基础镜像这个地基打牢。很多人图省事直接FROM openjdk:8-jdk-alpine但Alpine的坑不少。它的默认C库是musl跟某些Java原生库、动态链接库会有兼容性问题。另一个问题是Alpine基础镜像里连glibc都没有一些依赖了JNI的库跑起来就报错你还查不出原因。我后来基本都用eclipse-temurin这个镜像不管是8、11还是17都有对应版本有完整的JRE/JDK体积虽然比Alpine大一些但胜在省心。而且官方镜像一直维护更新安全漏洞修得也及时。还有一个思路是用maven镜像来打包再用另一个镜像来运行做成多阶段构建。这样最终镜像里只有运行时的内容没有构建工具和源码体积更小安全性也更好。这个和远程debug没有直接冲突但推荐养成这个习惯。2.2 JDK版本差异JDK9前后写法完全不同这是我在踩坑之后才注意到的很多人被一个根本性问题卡住JDK9开始模块化之后-agentlib:jdwp参数的address写法变了。JDK8及以前调试端口直接写端口号就行-agentlib:jdwptransportdt_socket,servery,suspendn,address5005JDK9及以后必须加上主机名限定否则会报错绑定失败或者只在本地回环接口上监听-agentlib:jdwptransportdt_socket,servery,suspendn,address*:5005那个*:5005的意思是监听所有网络接口的5005端口。如果你写成address5005在老版本上没问题但在新版本上它可能只绑定到localhost那其他机器和容器网络就根本连不进来。你从外面看到端口映射是通的但JVM根本没在宿主机网卡上监听排查一圈回来发现是这儿的问题真的会气得拍桌子。还有JDK9之后老版本的-Xdebug和-Xrunjdwp参数已经被废弃了没必要再写。直接用-agentlib:jdwp就好写一堆冗余参数不仅没用还会让启动日志里出现弃用警告看着心烦。2.3 端口规划业务端口和调试端口为什么要分开一个Spring Boot应用可能本身就监听8080端口现在又要开一个5005的调试端口。这两个端口要分开原因很直白你要发布业务端口对外提供服务但调试端口绝不应该对外暴露它只应该在你需要的时候从开发和测试环境监听。从Docker的角度来说业务端口是给业务流量走的一般会映射到宿主机的某固定端口或者通过服务发现让网关路由过来。调试端口则是开发期的辅助手段很多时候只在联调环境、预发环境临时开启排完问题就关掉。在docker run命令里你可以只映射调试端口而不映射业务端口比如docker run -p 5005:5005 -p 8080:8080 myapp也可以只映射调试端口通过容器网络去访问业务端口。这里有个心得调试端口千万别暴露到公网环境不然后果非常严重。JDWP协议没有任何认证机制只要端口通任何人都能连上来attach你的JVM读取内存、修改数据简直是灾难。这个后面安全部分会重点展开。3. 实操过程从Dockerfile到IDEA断点命中3.1 编写一个支持远程debug的Dockerfile现在我们把上面说的思路落成代码。我直接给一个可以抄作业的Dockerfile重点不是背下来而是理解每个部分为什么这么写。# 构建阶段 FROM maven:3.8-openjdk-8 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn package -DskipTests -B # 运行阶段 FROM eclipse-temurin:8-jdk ENV TZAsia/Shanghai \ JAVA_OPTS \ DEBUG_PORT5005 \ DEBUG_ENABLEfalse WORKDIR /app COPY --frombuild /app/target/app.jar /app/app.jar EXPOSE 8080 5005 ENTRYPOINT [sh, -c, java $JAVA_OPTS -jar /app.jar]这里有几个关键设计第一用环境变量DEBUG_ENABLE控制是否开启远程调试。怎么在启动命令里动态拼参数这里我习惯用Shell脚本方便判断条件比如这样COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod x /docker-entrypoint.sh ENTRYPOINT [/docker-entrypoint.sh]对应docker-entrypoint.sh的内容#!/bin/sh if [ $DEBUG_ENABLE true ]; then JAVA_OPTS-agentlib:jdwptransportdt_socket,servery,suspendn,address*:${DEBUG_PORT} ${JAVA_OPTS} fi exec java $JAVA_OPTS -jar /app.jar第二用exec java而不是直接java。exec会替换当前Shell进程为Java进程保证信号能正确传递到JVM这样docker stop才能优雅停机。这个细节不写进去后面容器根本停不下来或者超时被强制杀掉数据可能丢失。第三EXPOSE写两个端口只是一个文档化声明真正生效的是docker run -p的映射。调试端口写不写EXPOSE都可以但写上可以让别人通过docker inspect知道你的镜像预留了哪些端口也算是一种约定。3.2 构建镜像并启动容器这一步的验证很关键构建镜像之前先确认你项目的打包结果路径。用多阶段构建的话target/app.jar要在构建阶段生成并且用COPY --frombuild拷贝到运行阶段。docker build -t myapp:debug .启动容器时如果当前只需要普通运行不开启调试docker run -d --name myapp-dev -p 8080:8080 myapp:debug如果希望开启远程调试就加上环境变量和调试端口映射docker run -d --name myapp-debug \ -p 8080:8080 \ -p 5005:5005 \ -e DEBUG_ENABLEtrue \ -e JAVA_OPTS-Xmx512m \ myapp:debug启动之后先看日志确认JVM是否正常起来docker logs -f myapp-debug如果调试参数正确加载了你会看到类似这样一行日志不同JDK版本可能略有差异Listening for transport dt_socket at address: 5005注意如果suspendn且没有调试器连接这行日志会被后续的业务日志顶上去翻翻日志找一下就好。如果suspendyJVM会一直卡在启动阶段日志停在“Listening for transport dt_socket at address”这一行就不再往下走了直到调试器连上来。看到这种日志不要慌这是正常的等待状态。3.3 在IDEA里配置Remote JVM DebugIDEA配置远程调试很简单但要选对方式。找到Run/Debug Configurations点左上角加号选择Remote JVM Debug。这里IDEA会弹出一个配置窗口最关键的是Host和Port。Host填你容器所在机器的IP本机的话就填127.0.0.1或者localhostPort填映射后的宿主机端口比如5005。IDEA还会自动生成一段命令行参数模板类似-agentlib:jdwptransportdt_socket,servery,suspendn,address*:5005这串东西你不需要复制到容器里因为容器里的参数我们已经通过环境变量配置好了。IDEA生成这段是为了告诉你目标机器上JVM要是按这个参数启动的你就能连上。你只要确认参数配置思想一致即可。还有一点容易忽略IDEA里的command line参数模板会有旧版和新版之分JDK版本不同连入的兼容性也不太一样。如果连接失败可以在IDEA里试试切换JRE版本或者直接用IDEA自动检测到的JDK版本去连。配置好之后点击Debug按钮那只小虫子连接。连接成功之后你可以在在代码里打断点。注意如果suspendn服务已经跑起来了遇到正在执行的逻辑有的断点不会立即命中需要等下一次触发到那一行如果suspendy服务会停在启动前IDEA连上后点继续它会停在你的断点上。3.4 验证调试生效别只会傻傻打断点验证调试是否生效最直接的办法是在某个接口上打断点然后用curl请求接口看是否命中。比如你的服务有一个/api/hello接口在Controller方法第一行打断点curl http://localhost:8080/api/hello如果IDEA的Debug窗口里出现调用栈并且线程状态变为“暂停”说明远程debug链路完全打通了。此时你可以看到方法的入参、局部变量、当前线程信息甚至能通过IDEA的“Evaluate Expression”功能在当前上下文里执行任意表达式。还有一个技巧是验证线程状态。在Debug窗口右侧能看到线程列表里面有RUNNABLE、WAITING等状态。如果你发现某个线程一直卡在运行状态可能就是被调试器挂住了需要停掉调试连接才能恢复。这个在排查线上偶发问题时很有用但也要小心调试暂停了线程相当于把整个服务的某个时刻冻结了会影响正常请求。4. 常见问题与排查技巧实录4.1 端口连不上第一优先排查这几处这可能是远程debug遇到最多的坑。IDEA报Connection refused或者超时一半以上的原因是端口没真正暴露或监听地址不对。按这个顺序排查第一步确认容器里JVM到底有没有监听5005端口。进容器看一眼docker exec -it myapp-debug sh netstat -tlnp | grep 5005如果netstat没装可以用ss -tlnp试试。如果这里看不到监听那问题出在JVM参数没生效回头看启动日志里的Listening for transport那行。第二步确认宿主机到容器的端口映射是正确的。在宿主机上执行ss -tlnp | grep 5005这里看到的是映射后的宿主机端口。如果这里都没有监听那就是docker run -p没写对或者在容器创建之后才想起来加映射只能重建容器docker run -p是不能动态添加的。第三步检查防火墙。Linux服务器上如果开了firewalld或者iptables即使Docker把端口映射到了宿主机外部机器也照样连不进来。调试阶段如果确定安全可以临时放行firewall-cmd --add-port5005/tcp --permanent firewall-cmd --reload不过更推荐的做法是只让内网访问。4.2 容器启动卡住不动十有八九是suspend在捣乱-agentlib:jdwp...,suspendy这个参数意思是“JVM一启动就停住等调试器连接”。如果你在容器启动的时候开了DEBUG_ENABLE但是忘了在IDEA里连接容器就会一直卡在启动阶段看起来像“启动后没有日志”。你去看docker logs大概率只有Listening for transport dt_socket at address: 5005然后什么动静都没有了。这个过程里业务代码还没开始执行Spring Boot的启动日志一条都没出来。如果你看到这种情况先确认是不是调试器还没连上。等IDEA连上来之后点一下Resume按钮项目才会继续启动。我的建议是日常部署用suspendn只有当你调试的是“服务启动阶段就会崩掉”的问题时才用suspendy。用y的时候记得在启动命令里加个超时告警别等半天才发现是自己忘了连调试器。4.3 断点没生效源码和字节码对不上的问题不能忽视断点不生效除了没触发到该行之外更常见的原因是源码和运行代码不对版。容器里跑的是老的构建产物本地IDE打开的是新代码自然对不上。每次改代码后一定要重新打包、重新构建镜像别用旧镜像调试。这个我记得踩过好几次尤其多人协作出包部署一个疏忽就拿着旧jar包调半天。还有一种情况是IDEA没把断点下到当前运行的类上。检查一下IDEA底部是否有类似“Class loaded from different location”的提示。如果类文件来自Maven仓库里依赖jar包而不是你的项目源码IDEA会提示该断点当前不可用。这时候你需要把调试范围缩小找到真正在运行的类版本。4.4 JDWP裸奔在公网这个安全隐患比想象严重得多必须专门说说安全问题。JDWP协议没有任何认证机制。只要目标JVM开了调试端口任何能连到该端口的人都可以直接attach上去读取JVM内存中的所有数据甚至可以修改运行中的类和数据。这到底是什么概念等于说如果有人能连上你暴露在公网的5005端口他就可以用本地工具连上来然后dump你的堆内存从里面扒出数据库密码、第三方密钥、用户会话。更严重的是通过调试协议攻击者可以执行任意表达式直接在JVM上下文里跑代码相当于获得了你应用进程的完整控制权。所以我的安全底线是第一生产环境绝对不开远程调试端口。就算临时排查也要选业务低峰期用最短时间打开排完立即关闭并重启容器。第二调试端口只能绑定内网IP或本地IP。address*:5005这种写法只在开发环境方便在公司内网或预发环境最好改成address192.168.x.x:5005绑定到固定内网IP上避免在多个网卡上暴露。第三加访问控制。如果K8s环境可以用NetworkPolicy限制来源IP或者干脆通过跳板机做端口转发。有条件的话调试连接走SSH隧道是最推荐的。我在实际项目里就见过一次被入侵的情况那个平台把JDWP暴露在公网结果对方连上来把堆内存翻了个底朝天拿数据库账号密码直接拖库。这种事故一次都嫌多。4.5 K8s环境下还想调试这种方式最靠谱在K8s集群里调试容器内Java服务有一个比直接映射端口更好的方式用kubectl port-forward把本地端口映射到Pod的调试端口。先在Deployment的环境变量里临时加上DEBUG_ENABLEtrue然后滚动升级kubectl set env deployment/myapp DEBUG_ENABLEtrue DEBUG_PORT5005 kubectl rollout status deployment/myapp然后通过端口转发把本地5005端口映射到Pod的5005端口kubectl port-forward pod/myapp-xxxxxxxx-yyyyy 5005:5005之后IDEA连接localhost:5005就行。这个方式的优势是你不需要把Pod的端口映射到宿主机的端口也不需要改动Service安全性和灵活性都高很多。注意kubectl port-forward默认绑定到127.0.0.1外部机器访问不了这其实是好事。还有一点K8s环境下Pod是有可能被调度到不同节点上的每次重启Pod的IP都会变。所以调试之前先确认Pod处于Running状态再执行port-forward别拿旧Pod的IP去试。4.6 热更新和调试怎么配合别再重启容器了调试时最烦的就是改一行代码要重启一次容器。配合远程debug其实有几种热更新思路。Spring Boot应用如果用spring-boot-devtools它默认支持JVM热更新但容器里的运行机制需要额外配置。实际上远程debug连接建立之后IDEA的“Build - Recompile”功能可以直接把改过的class文件推到运行中的JVM方法体内的代码变更即时生效。但注意这个只限于方法体内部改动如果改了方法签名、字段结构、加了类还是需要重启应用。所以我的实操习惯是小改动用IDEA的热编译大改动直接重启容器。重启之前检查一下JAVA_OPTS有没有处理好避免重启后调试端口没打开白等半天。写在最后的经验之谈折腾远程debug这么久我最想分享的经验就三条第一所有JVM级的参数包括调试开关都应该通过环境变量注入别把参数写死在Dockerfile里不然以后每次调试都要重新构建镜像。第二开发调试阶段用suspendn保持服务可用只有在排查启动崩溃时才用suspendy。第三调试端口绝不暴露到公网这是底线。还有个小技巧其实远程debug不仅能调试本地容器配合端口转发还可以调试K8s集群里的Pod。同一套JDWP参数换个网络连接方式从开发环境到预发环境都能调试这个能力在关键时刻能救你一回。希望这些实操经验能让你少踩几个坑赶紧去把容器里的服务接上调试器试试吧。