如何脚踏实地构建Java Agent

在构建Plumbr的多年中,我们遇到了许多具有挑战性的问题。 在其他方面,使Plumbr Java Agent可靠地执行而又不危害客户的应用程序,是一个特别棘手的任务。 从实时系统中安全地收集所有需要的遥测会带来很多问题。 其中一些非常简单,而另一些则非常不明显。

在此博客文章中,我们想与您分享一些示例,这些示例演示了在为我们的探员需要处理的一些看似简单的方面提供支持时遇到的复杂性。 这些示例进行了一些简化,但摘录自我们前一段时间需要解决的现实问题。 实际上,这些只是等待尝试使用字节码工具或JVMTI的人的冰山一角。

示例1:检测一个简单的Web应用程序

让我们从一个非常简单的hello world网络应用开始 :

@Controller
public class HelloWorldController {

   @RequestMapping("/hello")
   @ResponseBody
   String hello() {
       return "Hello, world!";
   }
}

如果启动应用程序并访问相关的控制器,则会看到以下内容:

$ curl localhost:8080/hello
Hello, world!

作为简单的练习,让我们将返回值更改为“ Hello,transformed world”。 自然,我们真正的Java代理不会对您的应用程序执行此类操作:我们的目标是在不更改观察到的行为的情况下进行监视。 但是为了使这个演示简短而简洁,请耐心等待。 要更改返回的响应,我们将使用ByteBuddy :

public class ServletAgent {

 public static void premain(String arguments, Instrumentation instrumentation) { // (1)
   new AgentBuilder.Default()
         .type(isSubTypeOf(Servlet.class)) // (2)
         .transform((/* … */) ->
           builder.method(named("service")) // (3)
                  .intercept(
                    MethodDelegation.to(Interceptor.class) // (4)
                  )
         ).installOn(instrumentation); // (5)
 }

}

这里发生了什么事:

  1. 正如典型的Java代理一样,我们提供了pre-main方法。 这将在实际应用程序启动之前执行。 如果您想了解更多信息,ZeroTurnaround上有一篇很好的文章,提供了有关检测Java代理如何工作的更多信息。
  2. 我们发现所有类都是Servlet类的子类。 Spring的魔力最终也将出现在Servlet中。
  3. 我们找到一种名为“服务”的方法
  4. 我们拦截对该方法的调用,并将其委托给我们的自定义拦截器,该拦截器仅显示“ Hello,transformed world!”。 到ServletOutputStream。
  5. 最后,我们告诉ByteBuddy根据上述规则对装入JVM的类进行检测

las,如果我们尝试运行此命令,则应用程序将不再启动,并引发以下错误:

java.lang.NoSuchMethodError: javax.servlet.ServletContext.getVirtualServerName()Ljava/lang/String;
    at org.apache.catalina.authenticator.AuthenticatorBase.startInternal(AuthenticatorBase.java:1137)
    at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:150)

发生了什么? 我们只触摸了“ Servlet”类上的“ service”方法,但是现在JVM无法在另一个类上找到另一个方法。 腥。 让我们尝试看看在这两种情况下该类的加载位置。 为此,我们可以将-XX:+ TraceClassLoading参数添加到JVM启动脚本中。 如果没有Java代理,则从Tomcat加载有问题的类:

[Loaded javax.servlet.ServletContext from jar:file:app.jar!/BOOT-INF/lib/tomcat-embed-core-8.5.11.jar!/]

但是,如果再次启用Java代理,则会从其他位置加载它:

[Loaded javax.servlet.ServletContext from file:agent.jar]

啊哈! 实际上,我们的代理直接依赖于Gradle构建脚本中定义的servlet API:

agentCompile "javax.servlet:servlet-api:2.5"

可悲的是,该版本与Tomcat期望的版本不匹配,因此出现错误。 我们用这种依赖性指定哪些类仪器:isSubTypeOf(Servlet ),但是这也造成了我们加载的servlet库的不兼容版本。 要摆脱这种情况实际上并不那么容易:要检查我们尝试检测的类是否是另一种类型的子类型,我们必须知道其所有父类或接口。

尽管有关直接父代的信息存在于字节码中,但传递继承却不存在。 实际上,在进行检测时,相关的类甚至可能尚未加载。 要解决此问题,我们必须在运行时找出客户端应用程序的整个类层次结构。 有效地收集类层次结构是一项艰巨的任务,它本身就有很多陷阱,但是这里的教训很明显:规范不应加载客户端应用程序可能也要加载的类,尤其是来自不兼容版本的类。

这只是一条小小的龙,当您尝试使用字节码或尝试与类加载器混为一谈时,它已远离军团等待着您。 我们已经看到了许多其他问题:类加载死锁,验证程序错误,多个代理之间的冲突,本机JVM结构膨胀,您好!

但是,我们的代理并不限于使用Instrumentation API。 要实现某些功能,我们必须更深入。

示例2:使用JVMTI收集有关类的信息

有多种方法可以弄清类型层次结构,但在本文中,我们仅关注其中一种-JVMTI (JVM工具接口)。 它使我们能够编写一些本机代码,以访问JVM的更底层的遥测和工具功能。 除其他外,可以为应用程序或JVM本身中发生的各种事件订阅JVMTI回调。 我们当前感兴趣的是ClassLoad回调。 这是一个如何使用它来订阅类加载事件的示例 :

static void register_class_loading_callback(jvmtiEnv* jvmti) {
   jvmtiEventCallbacks callbacks;
   jvmtiError error;

   memset(&callbacks, 0, sizeof(jvmtiEventCallbacks));

   callbacks.ClassLoad = on_class_loaded;

   (*jvmti)->SetEventCallbacks(jvmti, &callbacks, sizeof(callbacks));
   (*jvmti)->SetEventNotificationMode(jvmti, JVMTI_ENABLE, JVMTI_EVENT_CLASS_LOAD, (jthread)NULL);
}

这将使JVM在类加载的早期阶段执行我们定义的on_class_loaded函数。 然后,我们可以编写此函数,以便它通过JNI调用代理的java方法,如下所示:

void JNICALL on_class_loaded(jvmtiEnv *jvmti, JNIEnv* jni, jthread thread, jclass klass) {
   (*jni)->CallVoidMethod(jni, agent_in_java, on_class_loaded_method, klass);
}

为了简单起见,在Java Agent中,我们将只打印类的名称:

public static void onClassLoaded(Class clazz) {
   System.out.println("Hello, " + clazz);
}

闭上你的眼睛一分钟,尝试想象这里可能出什么问题。

你们中许多人可能以为这将崩溃。 毕竟,您在本机代码中犯的每个错误都有可能通过段错误使整个应用程序崩溃。 但是,在这个特定示例中,我们实际上将获得一些JNI错误和一个Java异常:

Error: A JNI error has occurred, please check your installation and try again
Error: A JNI error has occurred, please check your installation and try again
Hello, class java.lang.Throwable$PrintStreamOrWriter
Hello, class java.lang.Throwable$WrappedPrintStream
Hello, class java.util.IdentityHashMap
Hello, class java.util.IdentityHashMap$KeySet
Exception in thread "main" java.lang.NullPointerException
  At JvmtiAgent.onClassLoaded(JvmtiAgent.java:23)

让我们暂时将JNI错误放在一边,然后集中讨论Java异常。 真令人惊讶 在这里什么可以为空? 选项不多,所以让我们检查一下并再次运行:

public static void onClassLoaded(Class clazz) {
   if(System.out == null) {
       throw new AssertionError("System.out is null");
   }

   if(clazz == null) {
       throw new AssertionError("clazz is null");
   }

   System.out.println("Hello, " + clazz);
}

但是,a,我们仍然会遇到相同的异常:

Exception in thread "main" java.lang.NullPointerException
  At JvmtiAgent.onClassLoaded(JvmtiAgent.java:31)

让我们稍等一下,然后对代码进行另一个简单的更改:

public static void onClassLoaded(Class clazz) {
   System.out.println("Hello, " + clazz.getSimpleName());
}

输出格式的这种看似微不足道的变化导致了行为上的巨大变化:

Error: A JNI error has occurred, please check your installation and try again
Error: A JNI error has occurred, please check your installation and try again
Hello, WrappedPrintWriter
Hello, ClassCircularityError
#
# A fatal error has been detected by the Java Runtime Environment:
#
#  Internal Error (systemDictionary.cpp:806), pid=82384, tid=0x0000000000001c03
#  guarantee((!class_loader.is_null())) failed: dup definition for bootstrap loader?

啊,终于崩溃了! 真高兴! 实际上,这为我们提供了很多信息,有助于查明根本原因。 具体来说,现在明显的ClassCircularityError和内部错误消息非常明显。 如果要查看JVM源代码的相关部分,您会发现一种用于解析类的极其复杂且混杂的算法。 它确实可以单独工作,但仍然很脆弱,但是很容易因做一些不寻常的事情而被破坏,例如重写ClassLoader.loadClass或抛出一些JVMTI回调。

我们在这里所做的是将类加载潜入加载类的中间,这似乎是一项冒险的业务。 跳过故障排除过程,而该故障排除过程将自己撰写一篇博客文章,涉及很多本机挖掘工作,让我们仅概述第一个示例中发生的事情:

  1. 我们尝试加载一个类,例如launcher.LauncherHelper
  2. 为了打印出来,我们尝试加载io.PrintStream类,递归到相同的方法。 由于递归是通过JVM内部以及JVMTI和JNI进行的,因此在任何堆栈跟踪中都看不到它。
  3. 现在也必须打印出PrintStream。 但是还没有完全加载,所以我们收到一个JNI错误
  4. 现在,我们继续尝试继续打印。 要连接字符串,我们需要加载lang.StringBuilder。 重复同样的故事。
  5. 最后,由于类加载不多,我们得到了一个空指针异常。

好吧,那很复杂。 但是毕竟,JVMTI文档非常明确地说我们应该格外小心:

“此事件是在加载课程的早期阶段发送的。 因此,该类应谨慎使用。 请注意,例如,方法和字段尚未加载,因此对方法,字段,子类等的查询不会给出正确的结果。 请参见Java语言规范中的“类和接口的加载”。 对于大多数目的, ClassPrepare 事件将更加有用。”

确实,如果我们使用此回调,那么就不会有这样的困难。 但是,在设计用于监视目的的Java代理时,有时我们不得不被迫进入JVM的非常暗的区域以支持我们所需的产品功能,而其开销却足以用于生产部署。

带走

这些示例说明了一些看似无辜的设置和天真的方法来构建Java代理如何以令人惊讶的方式让您大吃一惊。 实际上,以上内容几乎不涉及我们多年来发现的内容。

再加上数量众多的不同平台,此类代理将需要完美运行(不同的JVM供应商,不同的Java版本,不同的操作系统),并且本来就很复杂的任务变得更具挑战性。

但是,通过尽职调查和适当的监视,构建可靠的Java代理是一项可以由一组敬业工程师解决的任务。 我们在自己的产品中自信地运行Plumbr Agent,并且不会因此而睡不着。

翻译自: https://www.javacodegeeks.com/2017/06/shoot-foot-building-java-agent.html

你可能感兴趣的:(java,python,spring,jvm,编程语言)