Статьи

Как выстрелить себе в ногу при построении Java-агента

За годы строительства Plumbr мы столкнулись со многими сложными проблемами. Среди прочего, особенно важно обеспечить надежную работу Java-агента Plumbr без угрозы для приложений клиентов. Чтобы безопасно собрать всю необходимую телеметрию из действующей системы, возникает огромный набор проблем, которые необходимо решить. Некоторые из них довольно просты, в то время как некоторые из них совершенно неочевидны.

В этом сообщении мы хотели бы поделиться с вами несколькими примерами, демонстрирующими сложности, возникающие при создании поддержки некоторых, казалось бы, простых аспектов, с которыми наши Агенты должны справиться. Примеры немного упрощены, но взяты из реальных проблем, которые нам нужно было решить некоторое время назад. Фактически, это только верхушка айсберга, ожидающая тех, кто пытается использовать инструментарий байт-кода или JVMTI.

Пример 1: инструментарий простого веб-приложения

Давайте начнем с очень простого веб-приложения hello world :

1
2
3
4
5
6
7
8
9
@Controller
public class HelloWorldController {
 
   @RequestMapping("/hello")
   @ResponseBody
   String hello() {
       return "Hello, world!";
   }
}

Если мы запустим приложение и получим доступ к соответствующему контроллеру, мы увидим это:

1
2
$ curl localhost:8080/hello
Hello, world!

В качестве простого упражнения давайте изменим возвращаемое значение на «Привет, преобразованный мир». Естественно, наш настоящий java-агент не сделал бы ничего подобного вашему приложению: наша цель — отслеживать без изменения наблюдаемого поведения. Но потерпите нас ради краткости и краткости этой демонстрации. Чтобы изменить возвращенный ответ, мы будем использовать ByteBuddy :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
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-агентов, мы предоставляем предварительный метод. Это будет выполнено до запуска самого приложения. Если вам интересно узнать больше, у ZeroTurnaround есть отличная статья для получения дополнительной информации о том, как работают инструменты Java-агентов.
  2. Мы находим все классы, которые являются подклассами класса Servlet. Магия весны в конечном итоге разворачивается и в сервлет.
  3. Мы находим метод с именем «сервис»
  4. Мы перехватываем вызовы этого метода и делегируем их нашему пользовательскому перехватчику, который просто печатает «Привет, преобразованный мир!» в ServletOutputStream.
  5. Наконец, мы говорим ByteBuddy, чтобы инструменты классы, загруженные в JVM в соответствии с правилами

Увы, если мы попытаемся запустить это, приложение больше не запускается, выдав следующую ошибку:

1
2
3
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)

Что произошло? Мы только коснулись метода «service» в классе «Servlet», но теперь JVM не может найти другой метод в другом классе. Подозрительное. Давайте попробуем посмотреть, откуда этот класс загружается в обоих случаях. Для этого мы можем добавить аргумент -XX: + TraceClassLoading в сценарий запуска JVM. Без java-агента данный класс загружается из Tomcat:

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

Однако, если мы снова включим java-агент, он будет загружен из другого места:

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

Ага! Действительно, наш агент напрямую зависит от API сервлета, определенного в скрипте сборки Gradle:

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

К сожалению, эта версия не совпадает с той, которую ожидает Tomcat, следовательно, ошибка. Мы использовали эту зависимость, чтобы указать, какие классы использовать: isSubTypeOf (Servlet. Class ), но это также заставило нас загрузить несовместимую версию библиотеки сервлетов. На самом деле от этого не так просто избавиться: чтобы проверить, является ли класс, который мы пытаемся использовать, подтипом другого типа, мы должны знать все его родительские классы или интерфейсы.

В то время как информация о непосредственном родителе присутствует в байт-коде, транзитивное наследование — нет. На самом деле, соответствующие классы, возможно, даже не были загружены, когда мы работаем с инструментами. Чтобы обойти это, мы должны выяснить всю иерархию классов клиентского приложения во время выполнения. Эффективное создание иерархии классов — это сложное занятие, которое само по себе имеет много подводных камней, но урок здесь очевиден: инструментарий не должен загружать классы, которые клиентское приложение может также хотеть загрузить, особенно из несовместимых версий.

Это просто маленький дракончик, который отошел от легионов, ожидающих вас, когда вы пытаетесь использовать инструмент байт-код или пытаетесь связываться с загрузчиками классов. Мы видели еще много других проблем: взаимоблокировки загрузки классов, ошибки верификатора, конфликты между несколькими агентами, вздутие собственной структуры JVM, вы называете это!

Однако наш агент не ограничивается использованием Instrumentation API. Чтобы реализовать некоторые функции, мы должны пойти глубже.

Пример 2: Использование JVMTI для сбора информации о классах

Существует много разных способов выяснить иерархию типов, но в этом посте давайте сосредоточимся только на одном из них — JVMTI , интерфейсе инструментов JVM. Это позволяет нам написать некоторый собственный код, который может получить доступ к более низкоуровневым функциям телеметрии и инструментов JVM. Среди прочего, можно подписаться на обратные вызовы JVMTI для различных событий, происходящих в приложении или самой JVM. В настоящее время нас интересует обратный вызов ClassLoad . Вот пример того, как мы могли бы использовать его для подписки на события загрузки классов:

01
02
03
04
05
06
07
08
09
10
11
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 на ранней стадии загрузки класса. Затем мы можем написать эту функцию, чтобы она вызывала java-метод нашего агента через JNI следующим образом:

1
2
3
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-агенте мы просто напечатаем имя класса:

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

Закройте на минуту глаза и попытайтесь представить, что здесь может пойти не так.

Многие из вас, вероятно, думали, что это просто рухнет. В конце концов, каждая ошибка, которую вы совершаете в нативном коде, может привести к поломке всего приложения из-за ошибки. Однако в этом конкретном примере мы на самом деле получим некоторые ошибки JNI и исключение Java:

1
2
3
4
5
6
7
8
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. Это удивительно. Что здесь может быть нулевым? Вариантов не так много, поэтому давайте просто проверим их и запустим снова:

01
02
03
04
05
06
07
08
09
10
11
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);
}

Но, увы, мы все равно получаем то же исключение:

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

Давайте немного подождем и внесем еще одно простое изменение в код:

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

Это, казалось бы, незначительное изменение в формате вывода приводит к резкому изменению поведения:

1
2
3
4
5
6
7
8
9
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 на собственном производстве и не теряем из-за этого сон.

Ссылка: Как застрелиться в построении Java-агента от нашего партнера по JCG Глеба Смирнова в блоге Plumbr Blog .