jvm-sandbox源码笔记之项目启动

809 阅读7分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第2天。点击查看活动详情

一、前言

本文主要介绍jvm-sandbox的启动流程,从脚本到java代码。如有错误敬请指正。

二、启动脚本

在官方给我们的介绍中,我们下载啦sandbox的文件进入到bin目录下执行sandbox.sh脚本,如下

# 进入沙箱执行脚本
cd sandbox/bin

# 目标JVM进程33342
./sandbox.sh -p 33342

作为整个项目的入口,这个脚本都做了什么呢。从入口看起

# the sandbox main function
function main() {

  while getopts "hp:vFfRu:a:A:d:m:I:P:ClSn:X" ARG; do
    case ${ARG} in
    h)
      usage
      exit
      ;;
    p) TARGET_JVM_PID=${OPTARG} ;;
    v) OP_VERSION=1 ;;
    l) OP_MODULE_LIST=1 ;;
    R) OP_MODULE_RESET=1 ;;
    F) OP_MODULE_FORCE_FLUSH=1 ;;
    f) OP_MODULE_FLUSH=1 ;;
    u)
      OP_MODULE_UNLOAD=1
      ARG_MODULE_UNLOAD=${OPTARG}
      ;;
    a)
      OP_MODULE_ACTIVE=1
      ARG_MODULE_ACTIVE=${OPTARG}
      ;;
    A)
      OP_MODULE_FROZEN=1
      ARG_MODULE_FROZEN=${OPTARG}
      ;;
    d)
      OP_DEBUG=1
      ARG_DEBUG=${OPTARG}
      ;;
    m)
      OP_MODULE_DETAIL=1
      ARG_MODULE_DETAIL=${OPTARG}
      ;;
    I) TARGET_SERVER_IP=${OPTARG} ;;
    P) TARGET_SERVER_PORT=${OPTARG} ;;
    C) OP_CONNECT_ONLY=1 ;;
    S) OP_SHUTDOWN=1 ;;
    n)
      OP_NAMESPACE=1
      ARG_NAMESPACE=${OPTARG}
      ;;
    X) set -x ;;
    ?)
      usage
      exit_on_err 1
      ;;
    esac
  done

  reset_for_env
  check_permission

  # reset IP
  [ -z "${TARGET_SERVER_IP}" ] && TARGET_SERVER_IP="${DEFAULT_TARGET_SERVER_IP}"

  # reset PORT
  [ -z "${TARGET_SERVER_PORT}" ] && TARGET_SERVER_PORT=0

  # reset NAMESPACE
  [[ ${OP_NAMESPACE} ]] &&
    TARGET_NAMESPACE=${ARG_NAMESPACE}
  [[ -z ${TARGET_NAMESPACE} ]] &&
    TARGET_NAMESPACE=${DEFAULT_NAMESPACE}

  if [[ ${OP_CONNECT_ONLY} ]]; then
    [[ 0 -eq ${TARGET_SERVER_PORT} ]] &&
      exit_on_err 1 "server appoint PORT (-P) was missing"
    SANDBOX_SERVER_NETWORK="${TARGET_SERVER_IP};${TARGET_SERVER_PORT}"
  else
    # -p was missing
    [[ -z ${TARGET_JVM_PID} ]] &&
      exit_on_err 1 "PID (-p) was missing."
    attach_jvm
  fi

  # -v show version
  [[ -n ${OP_VERSION} ]] &&
    sandbox_curl_with_exit "sandbox-info/version"

  # -l list loaded modules
  [[ -n ${OP_MODULE_LIST} ]] &&
    sandbox_curl_with_exit "sandbox-module-mgr/list"

  # -F force flush module
  [[ -n ${OP_MODULE_FORCE_FLUSH} ]] &&
    sandbox_curl_with_exit "sandbox-module-mgr/flush" "&force=true"

  # -f flush module
  [[ -n ${OP_MODULE_FLUSH} ]] &&
    sandbox_curl_with_exit "sandbox-module-mgr/flush" "&force=false"

  # -R reset sandbox
  [[ -n ${OP_MODULE_RESET} ]] &&
    sandbox_curl_with_exit "sandbox-module-mgr/reset"

  # -u unload module
  [[ -n ${OP_MODULE_UNLOAD} ]] &&
    sandbox_curl_with_exit "sandbox-module-mgr/unload" "&action=unload&ids=${ARG_MODULE_UNLOAD}"

  # -a active module
  [[ -n ${OP_MODULE_ACTIVE} ]] &&
    sandbox_curl_with_exit "sandbox-module-mgr/active" "&ids=${ARG_MODULE_ACTIVE}"

  # -A frozen module
  [[ -n ${OP_MODULE_FROZEN} ]] &&
    sandbox_curl_with_exit "sandbox-module-mgr/frozen" "&ids=${ARG_MODULE_FROZEN}"

  # -m module detail
  [[ -n ${OP_MODULE_DETAIL} ]] &&
    sandbox_curl_with_exit "sandbox-module-mgr/detail" "&id=${ARG_MODULE_DETAIL}"

  # -S shutdown
  [[ -n ${OP_SHUTDOWN} ]] &&
    sandbox_curl_with_exit "sandbox-control/shutdown"

  # -d debug
  if [[ -n ${OP_DEBUG} ]]; then
    sandbox_debug_curl "module/http/${ARG_DEBUG}"
    exit
  fi

  # default
  sandbox_curl "sandbox-info/version"
  exit

}

main "${@}"

可以看到脚本在执行基本的解析命令之后执行了几个其他的函数 reset_for_envcheck_permissionattach_jvm

其中先执行reset_for_env方法,主要是设置一些基本的环境java_home,启动参数等

# reset sandbox work environment
# reset some options for env
reset_for_env() {

  # use the env JAVA_HOME for default
  [[ -n "${JAVA_HOME}" ]] &&
    SANDBOX_JAVA_HOME="${JAVA_HOME}"

  # use the target JVM for SANDBOX_JAVA_HOME
  [[ -z "${SANDBOX_JAVA_HOME}" ]] &&
    SANDBOX_JAVA_HOME="$(
      lsof -p "${TARGET_JVM_PID}" |
        grep "/bin/java" |
        awk '{print $9}' |
        xargs ls -l |
        awk '{if($1~/^l/){print $11}else{print $9}}' |
        xargs ls -l |
        awk '{if($1~/^l/){print $11}else{print $9}}' |
        sed 's//bin/java//g'
    )"

  # append toos.jar to JVM_OPT
  # 若${JAVA_HOME}/lib/tools.jar存在,则通过-Xbootclasspath/a这个配置,设置启动参数,在后面attach_jvm的时候会用到
  [[ -f "${SANDBOX_JAVA_HOME}"/lib/tools.jar ]] &&
    SANDBOX_JVM_OPS="${SANDBOX_JVM_OPS} -Xbootclasspath/a:${SANDBOX_JAVA_HOME}/lib/tools.jar"

  #fix for windows  shell $HOME diff with user.home
  test -n "${USERPROFILE}" -a -z "$(cat "${SANDBOX_TOKEN_FILE}")" && SANDBOX_TOKEN_FILE=${USERPROFILE}/.sandbox.token

}

然后再执行check_permission方法,主要是判断 进程是否存在、判断用户执行权限等 执行前的权限检查

# check sandbox permission
check_permission() {

  # check PID existed
  pgrep java | grep "${TARGET_JVM_PID}" > /dev/null ||
    exit_on_err 1 "permission denied, java process ${TARGET_JVM_PID} is not existed."

  # check attach
  pgrep -U "${SANDBOX_USER}" | grep "${TARGET_JVM_PID}" > /dev/null ||
    exit_on_err 1 "permission denied, ${SANDBOX_USER} is not allow attach to ${TARGET_JVM_PID}."

  # check $HOME is writeable
  [[ ! -w ${HOME} ]] &&
    exit_on_err 1 "permission denied, ${HOME} is not writable."

  # check SANDBOX-LIB is readable
  [[ ! -r ${SANDBOX_LIB_DIR} ]] &&
    exit_on_err 1 "permission denied, ${SANDBOX_LIB_DIR} is not readable."

  # touch attach token file is writable
  touch "${SANDBOX_TOKEN_FILE}" ||
    exit_on_err 1 "permission denied, ${SANDBOX_TOKEN_FILE} is not writable."

  # check JAVA_HOME is accessible
  [[ ! -x "${SANDBOX_JAVA_HOME}" ]] &&
    exit_on_err 1 "permission denied, ${SANDBOX_JAVA_HOME} is not accessible! please set JAVA_HOME"

  # check java command is executeable
  [[ ! -x "${SANDBOX_JAVA_HOME}/bin/java" ]] &&
    exit_on_err 1 "permission denied, ${SANDBOX_JAVA_HOME}/bin/java is not executable!"

  # check the jvm version, we need 6+
  "${SANDBOX_JAVA_HOME}"/bin/java -version 2>&1 | awk -F '"' '/version/&&$2<="1.5"{exit 1}' ||
    exit_on_err 1 "permission denied, please make sure target java process: ${TARGET_JVM_PID} run in JDK[6,11]"

}

attach_jvm 这几个方法中最重要的一个,是真正启动sandbox的方法

# attach sandbox to target JVM
# return : attach jvm local info
function attach_jvm() {

  # got an token
  local token
  token="$(date | head | cksum | sed 's/ //g')"

  # attach target jvm
  # 真正启动sandbox的地方,通过 java -Xms128M -Xmx128M -Xnoclassgc -ea -Xbootclasspath/a:{your java home}/lib/tools.jar -jar {sandbox的lib目录}/sandbox-core.jar 来启动, 后面的则是要传入的参数
  "${SANDBOX_JAVA_HOME}/bin/java" \
    ${SANDBOX_JVM_OPS} \
    -jar "${SANDBOX_LIB_DIR}/sandbox-core.jar" \
    "${TARGET_JVM_PID}" \
    "${SANDBOX_LIB_DIR}/sandbox-agent.jar" \
    "home=${SANDBOX_HOME_DIR};token=${token};server.ip=${TARGET_SERVER_IP};server.port=${TARGET_SERVER_PORT};namespace=${TARGET_NAMESPACE}" ||
    exit_on_err 1 "attach JVM ${TARGET_JVM_PID} fail."

  # get network from attach result
  SANDBOX_SERVER_NETWORK=$(grep "${token}" "${SANDBOX_TOKEN_FILE}" | grep "${TARGET_NAMESPACE}" | tail -1 | awk -F ";" '{print $3";"$4}')
  [[ -z ${SANDBOX_SERVER_NETWORK} ]] &&
    exit_on_err 1 "attach JVM ${TARGET_JVM_PID} fail, attach lose response."

}

通过启动命令来看 起时脚本最终执行的是sandbox-core包中的main方法, 而main方法我们通过源码sandbox-core模块的pom文件可以看到,main方法位于com.alibaba.jvm.sandbox.core.CoreLauncher之中。 其实就是像正常的使用java -jar运行了一个jar文件

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>attached</goal>
            </goals>
            <phase>package</phase>
            <configuration>
                <descriptorRefs>
                    <descriptorRef>jar-with-dependencies</descriptorRef>
                </descriptorRefs>
                <archive>
                    <manifest>
                        <mainClass>com.alibaba.jvm.sandbox.core.CoreLauncher</mainClass>
                    </manifest>
                </archive>
            </configuration>
        </execution>
    </executions>
</plugin>

三、CoreLauncher启动类

CoreLauncher启动类代码如下

public class CoreLauncher {

    public CoreLauncher(final String targetJvmPid,
                        final String agentJarPath,
                        final String token) throws Exception {

        // 加载agent
        attachAgent(targetJvmPid, agentJarPath, token);

    }

    /**
     * 内核启动程序
     *
     * @param args 参数
     *             [0] : PID
     *             [1] : agent.jar's value
     *             [2] : token
     */
    public static void main(String[] args) {
        try {

            // check args
            if (args.length != 3
                    || StringUtils.isBlank(args[0])
                    || StringUtils.isBlank(args[1])
                    || StringUtils.isBlank(args[2])) {
                throw new IllegalArgumentException("illegal args");
            }

            new CoreLauncher(args[0], args[1], args[2]);
        } catch (Throwable t) {
            t.printStackTrace(System.err);
            System.err.println("sandbox load jvm failed : " + getCauseMessage(t));
            System.exit(-1);
        }
    }

    // 加载Agent
    private void attachAgent(final String targetJvmPid,
                             final String agentJarPath,
                             final String cfg) throws Exception {

        VirtualMachine vmObj = null;
        try {
            // 附着到Java虚拟机并获取当前jvm  VirtualMachine.attach 此方法返回由成功附加的提供程序创建的VirtualMachine
            // 此方法通过调用AttachProvider.providers()方法获取附加提供程序列表。 然后迭代遍历列表并依次调用每个提供程序的attachVirtualMachine方法。 如果提供程序成功附加,则迭代终止,并且此方法返回由成功附加的提供程序创建的VirtualMachine。 (介绍来自VirtualMachine API)
            vmObj = VirtualMachine.attach(targetJvmPid);
            if (vmObj != null) {
                // 当前jvm 加载代理jar包,参数1是jar包路径地址,参数2是给jar包代理类传递的参数
                vmObj.loadAgent(agentJarPath, cfg);
            }

        } finally {
            if (null != vmObj) {
                vmObj.detach();
            }
        }

    }

}

在启动类中,通过VirtualMachine.attach附着到目标jvm进程上并返回一个jvm实例。 然后再通过loadAgent方法启动真正的java-agent(代理)。
而Detach则是将Agent从目标JVM卸载。

loadAgent方法官方介绍:

提供给此方法的代理程序是目标虚拟机的文件系统上的JAR文件的路径名。 此路径将传递到解释它的目标虚拟机。 目标虚拟机尝试按java.lang.instrument规范的指定启动代理程序 。 也就是说,将指定的JAR文件添加到(目标虚拟机的)系统类路径中,并调用由JAR清单中的Agent-Class属性指定的代理类的agentmain方法。 agentmain方法完成时, agentmain方法完成。

参数

  • agent - 包含代理的JAR文件的路径。
  • options - 提供给代理的 agentmain方法的选项(可以是 null` )。

也就是说core模块的main方法通过 VirtualMachine.attach获取一个jvm实例,然后将真正的agent路径和agent需要的参数传入进去,从而启动一个代理程序。 其中agent路径和配置都是我们在sandbox.sh启动脚本中传入的参数。 CoreLauncher类的真正作用就是接受参数进行验证,然后启动一个java-agent。

四、sandbox-agent

从上面的loadAgent方法官方介绍中得知,启动agent是通过AR清单中的Agent-Class属性指定的代理类的agentmain方法。 在sandbox-agent模块中可以找到agent启动类AgentLauncher

<configuration>
    <descriptorRefs>
        <descriptorRef>jar-with-dependencies</descriptorRef>
    </descriptorRefs>
    <archive>
        <manifestEntries>
            <Premain-Class>com.alibaba.jvm.sandbox.agent.AgentLauncher</Premain-Class>
            <Agent-Class>com.alibaba.jvm.sandbox.agent.AgentLauncher</Agent-Class>
            <Can-Redefine-Classes>true</Can-Redefine-Classes>
            <Can-Retransform-Classes>true</Can-Retransform-Classes>
        </manifestEntries>
    </archive>
</configuration>

在pom文件中我门可以看到有两个配置Premain-ClassAgent-Class都指向了AgentLauncher。其中Premain-Class代表了premain, 就是启动java程序的时候通过在启动脚本配置-javaagent 参数启动代理。 真正运行的是agen里的premain方法,例如skywalking。 Agent-Class代表了agentmain,则是在java程序运行之后热加载的方式启动。真正的启动函数为agentmain。 premain属于静态。 agentmain属于动态。 而在sandbox中通过启动类启动的agent属于后者。

4.1 agent 启动

/**
 * 动态加载
 * @param featureString 启动参数
 *                      [namespace,token,ip,port,prop]
 * @param inst          inst
 */
public static void agentmain(String featureString, Instrumentation inst) {
    // 启动模式为热启动模式
    LAUNCH_MODE = LAUNCH_MODE_ATTACH;
    //解析传过来的参数成k-v形式
    final Map<String, String> featureMap = toFeatureMap(featureString);
    writeAttachResult(
            getNamespace(featureMap),
            getToken(featureMap),
            install(featureMap, inst)
    );
}

在启动agent时会传入两个参数featureString, inst, 第一个时我门在core main方法传入的配置参数,第二个时agent启动默认传入的Instrumentation实例,后续的对子节码的修改都是通过这个参数。 writeAttachResult将结果输出,在输出时调用了install方法,agent启动则是在这里完成 就是将sandbox安装到目标jvm上
install方法如下

/**
 * 在当前JVM安装jvm-sandbox
 * @param featureMap 启动参数配置
 * @param inst       inst
 * @return 服务器IP:PORT
 */
private static synchronized InetSocketAddress install(final Map<String, String> featureMap,
                                                      final Instrumentation inst) {

    final String namespace = getNamespace(featureMap);
    final String propertiesFilePath = getPropertiesFilePath(featureMap);
    final String coreFeatureString = toFeatureString(featureMap);

    try {
        final String home = getSandboxHome(featureMap);
        // 将Spy注入到BootstrapClassLoader
        //将间谍模块spy交由引导类加载器进行加载
        inst.appendToBootstrapClassLoaderSearch(new JarFile(new File(
                getSandboxSpyJarPath(home)
                // SANDBOX_SPY_JAR_PATH
        )));

        // 构造自定义的类加载器,尽量减少Sandbox对现有工程的侵蚀
        final ClassLoader sandboxClassLoader = loadOrDefineClassLoader(
                namespace,
                getSandboxCoreJarPath(home)
                // SANDBOX_CORE_JAR_PATH
        );
        // CoreConfigure类定义
        final Class<?> classOfConfigure = sandboxClassLoader.loadClass(CLASS_OF_CORE_CONFIGURE);

        // 反序列化成CoreConfigure类实例
        final Object objectOfCoreConfigure = classOfConfigure.getMethod("toConfigure", String.class, String.class)
                .invoke(null, coreFeatureString, propertiesFilePath);

        // CoreServer类定义
        final Class<?> classOfProxyServer = sandboxClassLoader.loadClass(CLASS_OF_PROXY_CORE_SERVER);

        // 获取CoreServer单例
        final Object objectOfProxyServer = classOfProxyServer
                .getMethod("getInstance")
                .invoke(null);

        // CoreServer.isBind()
        final boolean isBind = (Boolean) classOfProxyServer.getMethod("isBind").invoke(objectOfProxyServer);


        // 如果未绑定,则需要绑定一个地址
        if (!isBind) {
            try {
                classOfProxyServer
                        .getMethod("bind", classOfConfigure, Instrumentation.class)
                        .invoke(objectOfProxyServer, objectOfCoreConfigure, inst);
            } catch (Throwable t) {
                classOfProxyServer.getMethod("destroy").invoke(objectOfProxyServer);
                throw t;
            }
        }

        // 返回服务器绑定的地址
        return (InetSocketAddress) classOfProxyServer
                .getMethod("getLocal")
                .invoke(objectOfProxyServer);
    } catch (Throwable cause) {
        throw new RuntimeException("sandbox attach failed.", cause);
    }

}

由install代码可知,install大致做了如下几种事情 \

  1. 将Spy模块(sandbox的间谍模块,通过他对程序进行插桩) 交由启动类加载器进行加载
  2. 构造自定义的类加载器用以加载sandbox项目的类
  3. 通过反射获取sandbox配置类CoreConfigure实例
  4. 获取ProxyCoreServer实例, 这里真正调用的时JettyCoreServer获取一个jetty实现的http服务器实例(用于接受外部传入的操作的请求,例如list等)
  5. 调用bind方法启动http服务器

4.2 http服务启动

上面的所有流程中第五步启动http服务是一个关键节点,启动代码如下

public synchronized void bind(final CoreConfigure cfg, final Instrumentation inst) throws IOException {
    this.cfg = cfg;
    try {
        initializer.initProcess(new Initializer.Processor() {
            @Override
            public void process() throws Throwable {
                //设置日志配置
                LogbackUtils.init(
                        cfg.getNamespace(),
                        cfg.getCfgLibPath() + File.separator + "sandbox-logback.xml"
                );
                logger.info("initializing server. cfg={}", cfg);
                // 实例化一个sandbox对象 
                jvmSandbox = new JvmSandbox(cfg, inst);
                // 初始化一个 jetty的server
                initHttpServer();
                initJettyContextHandler();
                // 启动jetty服务
                httpServer.start();
            }
        });

        // 初始化加载所有的模块
        try {
            jvmSandbox.getCoreModuleManager().reset();
        } catch (Throwable cause) {
            logger.warn("reset occur error when initializing.", cause);
        }

        final InetSocketAddress local = getLocal();
        logger.info("initialized server. actual bind to {}:{}",
                local.getHostName(),
                local.getPort()
        );

    } catch (Throwable cause) {

        // 这里会抛出到目标应用层,所以在这里留下错误信息
        logger.warn("initialize server failed.", cause);

        // 对外抛出到目标应用中
        throw new IOException("server bind failed.", cause);
    }

    logger.info("{} bind success.", this);
}

在启动http服务流程中大致分为如下几步 \

  1. 设置日志配置
  2. 实例化JvmSandbox容器
  3. 初始化jetty server并启动
  4. 加载项目所有模块(sandbox/moudle目录下和用户根目录下.sandbox-moudle下的jar包 (我们自己开发的增强模块就是放到这两个目录下)

至此,agent基本环境设置完毕 http服务启动完成 模块加载完成。agent就附着到目标程序上了。agent启动完成。

五、参考文档