一起养成写作习惯!这是我参与「掘金日新计划 · 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_env、check_permission、attach_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-Class 和Agent-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大致做了如下几种事情 \
- 将Spy模块(sandbox的间谍模块,通过他对程序进行插桩) 交由启动类加载器进行加载
- 构造自定义的类加载器用以加载sandbox项目的类
- 通过反射获取sandbox配置类
CoreConfigure实例 - 获取
ProxyCoreServer实例, 这里真正调用的时JettyCoreServer获取一个jetty实现的http服务器实例(用于接受外部传入的操作的请求,例如list等) - 调用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服务流程中大致分为如下几步 \
- 设置日志配置
- 实例化JvmSandbox容器
- 初始化jetty server并启动
- 加载项目所有模块(sandbox/moudle目录下和用户根目录下.sandbox-moudle下的jar包 (我们自己开发的增强模块就是放到这两个目录下)
至此,agent基本环境设置完毕 http服务启动完成 模块加载完成。agent就附着到目标程序上了。agent启动完成。