转载

使用Go语言实现Attach到目标JVM进程

0x00 Java Attach API的基本使用

在JVM运行时加载一个Agent的jar包是Java agent的一种更加灵活的实现方式,因为动态Attach时不需要停止目标JVM进程,这个特性给Java Agent 的部署带来极大的便利。通常我们使用如下API将Agent的jar包Attach到目标JVM上。

import com.sun.tools.attach.*;
import java.io.IOException;
        
public class Main {
    public static void main(String[] args) {
        VirtualMachine vm = null;
        try {
            // 50447是目标JVM进程的PID
            vm = VirtualMachine.attach("50447");
            // 指定Java Agent的jar包路径
            String agentPath = "/path/to/your/simple-agent.jar";
            vm.loadAgent(agentPath, "agent init"); 
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                vm.detach();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

0x01 attach 机制中的进程间通信

Attach的本质是外部进程与Java 进程的建立通信和传输命令的过程,典型的 Attach 过程如下图所示:

使用Go语言实现Attach到目标JVM进程 在JVM中,SIGQUIT的默认行为是 dump 线程堆栈的信息,在类 Unix 系统中使用  kill -3 pid 来发送 SIGQUIT 信号。在执行 attach 时,外部进程发送 SIGQUIT给目标 JVM,目标 JVM 收到 SIGQUIT后检查临时文件是否有.attach_pid 文件,如果存在则响应Attach 事件创建 socket 文件,如果不存在.attach_pid文件,仅作 dump 线程堆栈。(详见下面的attach 流程图)

Unix domain socket(简称 UDS)

  1. Unix domain socket又叫 IPC(inter-process communication 进程间通信) socket,用于实现同一主机上的进程间通信;

  2. 虽然网络 socket 也可用于同一台主机的进程间通讯(通过 loopback 地址 127.0.0.1),但是 UNIX domain socket 用于 IPC 更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。

  3. UNIX domain socket 是全双工的,API 接口语义丰富,相比其它 IPC 机制有明显的优越性,目前已成为使用最广泛的 IPC 机制,Unix domain socket 是 POSIX 标准中的一个组件,linux 系统也是支持它的。使用 UNIX domain socket 的过程和网络 socket 十分相似。

在 UNIX世界中,一切皆文件,UNIX 域套接字也是一个文件,下面是在某个文件夹下执行 ls -al 命令的输出结果:

使用Go语言实现Attach到目标JVM进程

rasp.sock 文件描述符的第一个字母“s” 表示是一个 UNIX 域套接字。两个进程通过读写这个文件就实现了进程间的信息传递。UNIX 套接字文件的大小始终为0。

信号(Signal)

  1. 信号被某个进程产生,并设置此信号传递的对象(一般为对应进程的pid),然后传递给操作系统;

  2. 操作系统根据接收进程的设置(是否阻塞)而选择性的发送给接收者,如果接收者阻塞该信号(且该信号是可以阻塞的),操作系统将暂时保留该信号,而不传递,直到该进程解除了对此信号的阻塞(如果对应进程已经退出,则丢弃此信号),如果对应进程没有阻塞,操作系统将传递此信号;

  3. 目标进程接收到此信号后,将根据当前进程对此信号设置的预处理方式,暂时终止当前代码的执行,保护上下文(主要包括临时寄存器数据,当前程序位置以及当前CPU的状态)、转而执行中断服务程序,执行完成后在回复到中断的位置。当然,对于抢占式内核,在中断返回时还将引发新的调度;

信号名称 编号 描述
SIGINT 2 终端中断信号(Ctrl+C)
SIGQUIT 3 终端退出信号(Ctrl+),产生进程core dump文件
SIGKILL 9 强制杀死信号,应用程序无法忽略或者捕获
SIGTERM 15 结束进程信号,shell下执行kill 进程pid发送该信号

0x03 attach过程源码解析

attach通信流程图

使用Go语言实现Attach到目标JVM进程 以上流程图中有两个文件 .attach_pid 和 .java_pid 分别由发起 attach 的进程和目标Java进程创建,.attach_pid文件用于通知目标Java进程创建 .java_pid 文件, .java_pid 文件则用于进程间socket 通信。

源码解析

与 attach相关的代码在 /src/jdk.attach 下,  share/classes 目录下代码是主要实现,其他目录下是平台相关代码。在 Linux 系统下,我们看 share/classes + linux 目录下代码。

attach方法入口:

// attach方法
public static VirtualMachine attach(String id)
        throws AttachNotSupportedException, IOException {
    // 方法入参非空检查(省略...)
    List<AttachProvider> providers = AttachProvider.providers();
    // providers非空检查(省略...)
    AttachNotSupportedException lastExc = null;
    // attach功能的实现完全依赖于 provider
    // 在不同的操作系统中都提供了AttachProvider的实现,
    // 实现类是VirtualMachineImpl.java
    for (AttachProvider provider : providers) {
        // 用户自己实现的 provider使用 SPI 机制
        // 加载并注册到 providers链表中
        try {
            // 实际功能的实现依赖于provider的attachVirtualMachine方法
            return provider.attachVirtualMachine(id);
        } catch (AttachNotSupportedException x) {
            lastExc = x;
        }
    }
    throw lastExc;
}

attachVirtualMachine根据操作系统的不同new 一个VirtualMachineImpl实现类:

// AttachProviderImpl.java 类中
public VirtualMachine attachVirtualMachine(String vmid)
        throws AttachNotSupportedException, IOException{
        // 安全检查等代码(省略...)
        // 创建一个 VirtualMachineImpl 对象
        return new VirtualMachineImpl(this, vmid);
}

再来看看VirtualMachineImpl的构造器中实现逻辑

public class VirtualMachineImpl extends HotSpotVirtualMachine {
    // UDS文件创建的目录
    private static final String tmpdir = "/tmp";
    String socket_path;
    // attach 逻辑的实现
    VirtualMachineImpl(AttachProvider provider, String vmid)
        throws AttachNotSupportedException, IOException{
        super(provider, vmid);
        // This provider only understands pids
        int pid = Integer.parseInt(vmid);
        // 字符串转int的异常(省略...)
        // 尝试获取容器中的 pid
        int ns_pid = getNamespacePid(pid);
        // 查找 .java_pid 文件是否创建
        File socket_file = findSocketFile(pid, ns_pid);
        socket_path = socket_file.getPath();
        if (!socket_file.exists()) {
            // .java_pid文件不存在,则创建
            File f = createAttachFile(pid, ns_pid);
            try {
                // 发送 SIGQUIT信号给目标JVM,native方法
                sendQuitTo(pid);
                final int delay_step = 100;
                final long timeout = attachTimeout();// 5000ms
                long time_spend = 0;
                long delay = 0;
                do {
                    // 发送SIGQUIT后等待 socket文件创建
                    delay += delay_step;
                    try {
                        Thread.sleep(delay);
                    } catch (InterruptedException x) { }
                    time_spend += delay;
                    if (time_spend > timeout/2 && !socket_file.exists()) {
                        // 最后再发送一次SIGQUIT信号给目标JVM
                        sendQuitTo(pid);
                    }
                } while (time_spend <= timeout && !socket_file.exists());
                // 超时之后,.java_pid 文件还是没有创建,抛出异常
                // 详见attach连接踩坑
                if (!socket_file.exists()) {
                    throw new Exception("attach 失败");
                }
            } finally {
		// attach文件用完就删
                f.delete();
            }
        }
        // 检查 .java_pid 文件的权限(详见下面的“attach踩坑记录”)
        checkPermissions(socket_path);
        
        //尝试下socket是否创建成功,连接成功后什么也不干,立即关闭
        int s = socket();
        try {
            connect(s, socket_path);
        } finally {
            close(s);
        }
    }
    
    // socket_path 路径置为 null,
    // 往socket中发送消息之前会检查这个路径的值
    public void detach() throws IOException {
        synchronized (this) {
            if (socket_path != null) {
                socket_path = null;
            }
        }
    }   
    // native 方法(省略...)
}    

.java_pid文件查找实现(创建文件过程类似,省略…)

// 查找 /tmp 目录下的 .java_pid 文件
private File findSocketFile(int pid, int ns_pid) {
  String root = "/proc/" + pid + "/root/" + tmpdir;
  return new File(root, ".java_pid" + ns_pid);
}

0x04 在建立attach通信中的踩坑记录

  • .java_pid 文件被删除后无法使用 jstack 和 jmap 等工具

JVM进程已经创建过.java_pid 文件,如果删除了这个文件,在/tmp/.java_pidXXX文件不存在的情况下会抛出jstack执行失败异常,解决办法是重启 Java 应用程序。非常关键的一点是别去删除.java_pid文件,由于我们使用的时centos7, 者默认一段时间之后会自动删除该文件,需要系统配置禁止删除该文件。

  • .java_pid 文件权限问题

在建立连接之前,主动发起 attach 的进程会检查 socket 文件的权限,如果目标 JVM 创建 这个socket的权限与attach 进程权限不相同,抛出异常。

0x05 使用 Go 语言实现 Attach 的功能

参考attach通信流程图。下面分四个步骤实现Go版本的 Attach。

Step1 创建 VirtualMachine结构

保存 socket 文件目录和socket连接。

type VirtualMachine struct {
	Pid          int32        // 目标进程 PID
	SocketFile   string       // .java_pidXXX 文件
	AttachFile   string       // .attach_pid 文件
	Socket       *Socket      // Socket 连接
	SockFileLock sync.RWMutex // socketFile 的锁,线程安全的将sockPath置为空字符串
}

func NewVirtualMachine(pid int32) *VirtualMachine {
	socketFile := filepath.Join(os.TempDir(), fmt.Sprintf(".java_pid%d", pid))
	attachPath := filepath.Join(os.TempDir(), fmt.Sprintf(".attach_pid%d", pid))
	return &VirtualMachine{
		Pid:        pid,
		SocketFile: socketFile,
		AttachFile: attachPath,
	}
}

Step2 发起Attach,获取socket连接

如果 .java_pidXXX 文件不存在,则Attach 进程在/tmp目录下创建 .attach_pidXXX 文件(XXX表示目标 JVM 进程 PID)。

// 与目标 JVM 建立连接
func (this *VirtualMachine) Attach() error {
	if !this.existsSocketFile() {
		err := this.createAttachFile()
		if err != nil {
			return err
		}
		err = syscall.Kill(int(this.Pid), syscall.SIGQUIT)
		if err != nil {
			return fmt.Errorf("canot send sigquit to java[%d],%v", this.Pid, err)
		}
		timeSpend := 0
		delay := 0
		// 循环条件:socket文件不存在并且未超时
		// 参考代码open Jdk13下的src/jdk.attach/linux/classes/sun/tools/attach/VirtualMachineImpl.java
		for ; !this.existsSocketFile() && timeSpend <= timeOut; {
			delay += delayStep
			time.Sleep(time.Duration(delay) * time.Millisecond)
			timeSpend += delay
			if timeSpend > timeOut/2 && !this.existsSocketFile() {
				// 最后一次尝试发送SIGQUIT信号给目标JVM
				err = syscall.Kill(int(this.Pid), syscall.SIGQUIT)
				if err != nil {
					return fmt.Errorf("canot send sigquit to java[%d],%v", this.Pid, err)
				}
			}
		}
		if !this.existsSocketFile() {
			return fmt.Errorf("unable to open socket file %s: "+
				"target process %d doesn't respond within %dms "+
				"or HotSpot VM not loaded", this.SocketFile, this.Pid,
				timeSpend)
		}
		// 用完attach_pidXXX就可以删除了,避免占用空间
		this.deleteAttachFile()
	}

	// 监听 connet
	addr, err := net.ResolveUnixAddr("unix", this.SocketFile)
	if err != nil {
		return err
	}

	c, err := net.DialUnix("unix", nil, addr)
	if err != nil {
		return err
	}
	this.Socket = &Socket{c}
	return nil
}

Step3 发送 load 指令,加载 Agent jar 包到目标 JVM 中


func (this *VirtualMachine) LoadAgent(agentJarPath string) error {
	this.SockFileLock.Lock()
	if this.SocketFile == "" {
		return fmt.Errorf("detach function has run")
	}
	this.SockFileLock.Unlock()

	err := this.Socket.Execute("load", "instrument", "false", agentJarPath)
	if err != nil {
		return fmt.Errorf("execute load %s,  %v", agentJarPath, err)
	}
	s, err := this.Socket.ReadString()
	if err != nil {
		return err
	}
	//如果发送 agent jar 包成功,
	if s != "0" {
		return fmt.Errorf("load agent jar err")
	}
	defer func() {
		_ = this.Socket.Close()
	}()
	return nil
}

Step4 代码运行

package main

import (
  "Attach/app"
  "fmt"
)

func main() {
  vm := app.NewVirtualMachine(25112)
  err := vm.Attach()
  if err != nil {
    fmt.Printf("attach err,%v", err)
  }
  err = vm.LoadAgent("/Users/xule05/Downloads/SimpleAgent/target/simple-agent-1.0-SNAPSHOT-jar-with-dependencies.jar=xxxx")
  if err != nil {
    fmt.Printf("loadAgent err,%v", err)
  }
   vm.Detach()
}

打印出 “(V2)args: xxxx” 说明 Agent 已经被加载并初始化了。

总结

参考了Java API和对应的native方法,使用Go语言实现了与JVM 进程通信,并发送loadAgent指令。使用Go语言来实现Attach,发起attach 的进程不再局限于Java进程,相比于Java进程占用内存非常少。 完整代码详参考github

原文  https://club.perfma.com/article/1596501
正文到此结束
Loading...