如何对线上服务进行远程调试
问题
Java 服务支持本地调试和远程调试。对于解决问题非常方便。但线上环境我们一般用不了,因为线上环境因为安全问题网络都是独立的,一般只对外开放http接口,不允许其他 TCP 协议开放。Java Debug 的协议是属于 TCP ,且没任何鉴权。所以也不可能开放。那怎么可以安全方便的进行调试呢?
在研究怎么实现之前需要先了解 Java 是如何进行 Debug 的。
JPDA (Java Platform Debugger Architecture)
JPDA 是 Java Debug 的实现架构,核心分为三部分
JVM TI (Java Vm Tool Interface): 这部分是 JVM 提供的调试功能,是 JVM 打包在一起,所以和 JVM 一样是通过 C/C++ 编写的。这层是调试能力的实现,提供了常用的接口,如断点,获取对象信息等。
JDI (Java Debug Interface): 这部分提供了 Java 版本的接口来调用 JVM TI 的接口。这里调用 JVM TI 的接口并不是通过 JNI 来调用的,而是通过 TCP 协议和 JVM TI 的提供的服务进行通讯来间接调用 JVM TI 的接口。
JDWP (Java Debug Wire Protoco): 这部分只是基于 TCP 的通讯协议。这里有详细的协议介绍。 Java Debug Wire Protocol (oracle.com)
从 JPDA 架构来看,JVM TI 是最终提供 Debug 能力的地方。有3种方式调用这层的接口。
- 通过 JDI 来调用,对 Java 的开发者来说比较友好,IDE 里面的调试功能主要就是通过这种方式。
- 通过 JNI 直接调用 C++代码。
- 通过 JDWP ,自己实现一个JDI。如果是其他编程语言想调试 Java 代码可以这么做。
IDEA 里面的本地调式和远程调式分别是用哪种方式调用 JVM TI的? 都是通过 JDI 条用的,毕竟 IDEA 是用 Java 写的,直接用 JDI 是最方便的。用同一种方式调用也方便复用。只是参数上面会有小小的不同。
远程调试的配置
当你使用 IDEA 来配置远程调试的时候,就会提醒你需要在宿主机上的服务启动命令加上一行
这一行就是在 JVM TI 启动一个 5005 的端口,准备接收 JDWP 协议的内容。当在 IDEA 启动远程调式的时候 IDEA 就会通过 JDI 和宿主机 JVM TI 进行通讯。
在 IDEA 远程调试的界面,我们还能看到几个配置
传输方式
-agentlib:jdwp=transport=dt_socket/dt_shmem
这里可以选择传输方式,是通过 socket 还是共享内存的方式。有且只有这2种方式。共享内存只能在同一个主机里面才行。一般使用 socket。
连接方式
server=n (被动连接) / y (主动连接)
有两种连接方式,一种是主动连接,一种是被动连接(监听)。我们用的远程调式就是通过主动连接的,因为我们希望服务能随时被连接调试。而本地调试其实用的是被动连接。IDEA 会在项目前启动一个 Debugger 服务,并把本地 ip 和随机生成的端口作为 address 传给 JVM TI,JVM TI 在启动之后就会主动连接 Debugger 的服务。
是否连接后才启动
suspend=n/y
如果配置了y,项目启动后会卡住,只有等到有 debugger 进行连接,才会继续运行。一般我们会配置n,需要的时候才进行 debug,除非是部分逻辑是启动的时候触发的,在还没点远程调式的时候逻辑就已经跑完了,无法调式,才会使用配置 suspend=y。
手动调用 JDI
服务一样要采用和上面远程调试的配置来启动,而 debugger 我们可以直接引用 JDI 的类来访问。 JDI 的包在 jdk 的 lib/tools.jar 中,有时候需要单独引用。
fun main() {
// 初始化配置并连接
val socketAttachingConnector = SocketAttachingConnector()
val defaultArguments = socketAttachingConnector.defaultArguments()
defaultArguments["port"]!!.setValue("8098")
defaultArguments["hostname"]!!.setValue("localhost")
val virtualMachine = socketAttachingConnector.attach(defaultArguments)
println("连接成功")
// 监听 Debug 消息
Thread {
while (true) {
val eventSet = virtualMachine.eventQueue().remove()
eventSet.forEach {
// 触发断点,然后打印参数,并触发resume
if (it is BreakpointEvent) {
display(it)
virtualMachine.resume()
}
}
}
}.start()
// 触发 Debug 命令
val locationsOfLine = virtualMachine.classesByName("org.example.Main").get(0).locationsOfLine(12)
val eventRequestManager = virtualMachine.eventRequestManager()
// 创建断点
eventRequestManager.createBreakpointRequest(locationsOfLine[0])
eventRequestManager.breakpointRequests().forEach {
it.enable()
}
eventRequestManager.methodExitRequests()
Thread.sleep(100000)
}
fun display(event: LocatableEvent) {
val frame = event.thread().frame(0)
frame.getValues(frame.visibleVariables()).forEach {
println(it.key.name() + " " + JSON.toJSONString(it.value))
}
}
JNI 里面主要有3部分,连接,消息,命令。消息和命令种类很多,参考包里面定义的类。
连接前面也提到了有2种连接方式和传输方式
通过 JNI 直接调用 JVM TI
JVM TI 提供的接口比 JDI 要丰富,有些组件依赖到 JVM TI 有, 但 JDI 没有的功能, 因此需要通过这种方式调用。 JVM(TM) Tool Interface 1.2.3 (oracle.com)
例如生成火焰图的 async-profiler,arthas 里面的 vmTools vmtool。
需要先通过 C++ 调用 JVM TI 的接口,然后打包成 so 文件,再由 Java 加载并通过 native 的方法调用。具体可以看 vmTools 的实现逻辑。
arthas/arthas-vmtool/src/main/java/arthas/VmTool.java (github.com)
如何实现线上服务远程调试
最简单的方式是通过 IDEA 的远程调试来实现,但前面提到了因为安全问题,不能直接这么做。内网穿透也能实现,但风险比较高,也不熟悉。
参考内网穿透的方式,我们其实也可以在两个网络之间建立一个通讯通道,这个通道除了要认证之外,还需要基于 http 协议 (线上网络只支持 http 的外网访问)。因此可以开发一个 debug-client 和 debug-server,两者基于 websocket (基于http) 进行通讯,他们分别和 IDEA 和 JVM 进行 JDWP 进行通讯。
为了方便使用,debug-client-client 会封装在 IDEA 插件里面,而 debug-proxy-server 则部署到线上环境。debug-proxy-server 提供 websocket 服务。
- IDEA 触发插件 Debug 的时候会把要调试的服务的 ip 和 端口作为参数,携带密钥和 debug-proxy-server 进行握手并建立通道。
- debug-proxy-client 会开启一个 JDWP 的 TCP 服务。把 IDEA 原来和服务的 JVM 进行进行 JWDP 连接,改成和 debug-proxy-client 进行连接。
- debug-proxy-client 通过 websocket 把 JDWP 内容透传给 debug-proxy-server。
- debug-proxy-server 再把 JDWP 内容透传给 JVM 服务。
- 然后 IDEA 和 JVM 就可以通过 debug-proxy-client 和 debug-proxy-server 进行双向连接了。
最终效果
目前已经在使用中,因为网络问题会比本地调试会有些延迟,但不影响使用。因为 IDEA 的 Debug 功能支持简单的 Hot Swap,所以也支持修改某些方法逻辑 (不能修改方法定义),在预发环境对于验证结果非常方便。
源码如果评论区有需要再脱敏分享。
转载自:https://juejin.cn/post/7390340749579370548