Рубрики
Без рубрики

Создание микросервиса объемом 22 мегабайта с использованием Java, Javalin и GraalVM

Виртуальная машина Graal от Oracle позволяет выполнять компиляцию приложений JVM с опережением времени (AOT). Это означает, вместо этого… Помечен graalvm, javalin, java, микросервисами.

Виртуальная машина Graal от Oracle позволяет выполнять компиляцию приложений JVM с опережением времени (AOT). Это означает, что вместо запуска процесса JVM для выполнения вашего приложения компилятор создает собственный двоичный файл. Как это работает? На очень высоком уровне базовая среда выполнения (называемая Substrate VM) компилируется в двоичный файл, а также в само приложение. Звучит немного похоже на Go, который также включает в себя небольшую среду выполнения, например, для сборки мусора. В этой статье я покажу, как создать небольшой образец веб-сервиса restful с помощью встроенной компиляции GraalVM. Пример приложения написан на Java. Зачем кому-то вообще интересоваться собственной компиляцией приложения JVM? На моей дневной работе в E.ON мне, к сожалению, все еще приходится много работать с Java-приложениями. Наш технический стек полностью облачный – мы запускаем почти все в Kubernetes. Наши приложения являются “типичными” приложениями с 12 факторами. В такой докеризованной среде, я думаю, есть три основные причины, по которым нативная компиляция была бы интересной.

  • Время запуска приложения

Возможно, это не совсем вина Java. Мы используем Spring Boot, и запуск происходит очень медленно. Обычно мне приходится настраивать тест готовности Kubernetes, чтобы он не проверялся ранее, чем через 20 секунд после запуска модуля. И это для очень маленького приложения – 500 строк кода.

  • Объем памяти

По моему опыту, вы не хотите предоставлять приложениям Spring Boot меньше 512 МБ памяти. В противном случае запуск может занять несколько минут. В то время как Java и особенно накладные расходы JVM виноваты здесь, это также проблема фреймворка. Ни для кого не секрет, что весна очень раздута и требует много размышлений.

  • Размер приложения

Размер контейнеров приложений Java – еще одна проблема. Это очень раздражает, но не критично. Самая маленькая банка, которую я могу найти, имеет размер ~ 65 МБ (OpenJDK на базе alpine). Если вы используете Spring Boot, у вас будет по крайней мере 40 МБ большой емкости для вашего приложения. Если ваше приложение больше, оно, очевидно, будет больше. Это минимум 100 МБ на контейнер. Обратите внимание, что слой JRE изображения Docker может быть повторно использован несколькими изображениями Docker, поэтому на самом деле это не 100 МБ + для каждого изображения вашего приложения. Хотя я думаю, что в 2018 году, безусловно, допустимо иметь приложения hello world объемом более 100 МБ, это просто слабо, если у меня будет двоичный файл Go объемом 6 МБ.

Компиляция Graal VM AOT может улучшить эту ситуацию. Я ожидаю, что время запуска будет практически мгновенным без использования JVM, а размер приложения будет значительно меньше. Виртуальная машина Graal имеет некоторые серьезные ограничения, поскольку некоторые функции JVM плохо сочетаются со статической компиляцией. Полный список можно найти здесь . Документация здесь предельно ясна: динамическая загрузка классов поддерживается и не будет поддерживаться. Вместо этого компилятор анализирует код и компилирует все необходимые классы в двоичный файл. В сочетании с рефлексией это становится кошмаром для нынешней экосистемы Java. Многие библиотеки и фреймворки используют отражение для динамического создания экземпляров классов. Виртуальная машина Graal не очень хорошо справляется с этим, во многих случаях требуется дополнительная конфигурация компилятора. Одна из причин заключается в том, что вызов, например, Class.forName() может основываться на информации о времени выполнения. Очень простой пример:

if (someVariable) {
    Class.forName("SomeClazz")
    ...
}

Поскольку значение someVariable неизвестно во время компиляции, компилятор не может знать, следует ли включать “SomeClazz”. Не говоря уже о том, что это всего лишь строка, и компилятор должен искать этот класс в пути к классу во время компиляции. Если компилятор решит включить этот класс, он просто сделает это и выдаст ошибку, если класс не найден. Это мило. Однако это всего лишь наилучшие усилия. Нет гарантии, что все необходимые классы будут включены во время компиляции, а это означает, что классы могут отсутствовать, и при их создании возникают ошибки во время выполнения. Существует еще много ограничений, для получения полной справки перейдите к документации GraalVM . В качестве доказательства концепции я искал библиотеку rest без избыточного использования reflect. Очевидно, что это не весенний ботинок – я выбрал javelin.io . Это просто библиотека отдыха на вершине Причала, вот и все.

Хотя я рекомендую выполнять сборки в Docker, очень полезно установить виртуальную машину Graal локально. Я использую sdkman , это облегчает управление JDKS. Если у вас еще не установлен sdkman:

завиток -с “https://get.sdkman.io ” | удар

Установить Graal VM JDK:

sdk установите java 1.0.0-rc6-graal && sdk используйте java 1.0.0-rc6-graal

Давайте начнем с очень простого Привет, Мир:

Кроме того, мы не должны забывать объявлять необходимые зависимости. Мы должны включить Джексона, так как он будет загружен во время выполнения (d’uh). В том же случае для привязки SLF4J Javelin рекомендует использовать slf4j-simple.

Кроме того, нам нужно будет создать fat jar, который включает в себя все классы и банки нашего приложения.

Пока ничего особенного. Для создания исполняемого файла собственного приложения GraalVM предоставляет инструмент native-image . Давайте попробуем это сделать:

j0e@thinkpad  ~/projects/graal-javalin  master ● ? ⍟1  native-image -jar ./build/libs/graal-javalin-all-1.0-SNAPSHOT.jar 
Build on Server(pid: 28578, port: 34643)*
[graal-javalin-all-1.0-SNAPSHOT:28578]    classlist:   2,977.05 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]        (cap):     963.06 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]        setup:   1,663.57 ms
[ForkJoinPool-3-worker-3] INFO org.eclipse.jetty.util.log - Logging initialized @5682ms to org.eclipse.jetty.util.log.Slf4jLog
[graal-javalin-all-1.0-SNAPSHOT:28578]   (typeflow):  10,510.28 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]    (objects):   6,598.95 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]   (features):     110.60 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]     analysis:  17,612.10 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]     universe:     859.27 ms
error: unsupported features in 8 methods
Detailed message:
Error: Unsupported method sun.nio.ch.InheritedChannel.soType0(int) is reachable: Native method. If you intend to use the Java Native Interface (JNI), specify -H:+JNI and see also -H:JNIConfigurationFiles= (use -H:+PrintFlags for details)
To diagnose the issue, you can add the option --report-unsupported-elements-at-runtime. The unsupported element is then reported at run time when it is accessed the first time.
...
...

Хорошо, нам нужен флаг -H:+JNI. Это довольно просто, просто добавьте флаг в команду, и эта проблема будет решена:

 j0e@thinkpad  ~/projects/graal-javalin  master ● ? ⍟1  native-image -jar ./build/libs/graal-javalin-all-1.0-SNAPSHOT.jar -H:+JNI 
Build on Server(pid: 28578, port: 34643)
[graal-javalin-all-1.0-SNAPSHOT:28578]    classlist:     753.67 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]        (cap):     528.63 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]        setup:     776.76 ms
[ForkJoinPool-15-worker-0] INFO org.eclipse.jetty.util.log - Logging initialized @616692ms to org.eclipse.jetty.util.log.Slf4jLog
[graal-javalin-all-1.0-SNAPSHOT:28578]   (typeflow):   5,934.19 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]    (objects):   6,646.13 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]   (features):      83.06 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]     analysis:  13,491.56 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]     universe:     519.25 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]      (parse):   2,360.81 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]     (inline):   3,674.24 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]    (compile):  15,925.13 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]      compile:  22,729.43 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]        image:   1,426.49 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]        write:     280.71 ms
[graal-javalin-all-1.0-SNAPSHOT:28578]      [total]:  40,064.13 ms

Так что компиляция, по-видимому, прошла успешно. Уродство начинается, когда мы его запускаем:

 j0e@thinkpad  ~/projects/graal-javalin  master ● ? ⍟1  ./graal-javalin-all-1.0-SNAPSHOT                          ✔  33695  00:53:54 
[main] INFO io.javalin.Javalin - 
 _________________________________________
|        _                  _ _           |
|       | | __ ___   ____ _| (_)_ __      |
|    _  | |/ _` \ \ / / _` | | | '_ \     |
|   | |_| | (_| |\ V / (_| | | | | | |    |
|    \___/ \__,_| \_/ \__,_|_|_|_| |_|    |
|_________________________________________|
|                                         |
|    https://javalin.io/documentation     |
|_________________________________________|
------------------------------------------------------------------------
Missing dependency 'Slf4j simple'. Add the dependency.

pom.xml:

    org.slf4j
    slf4j-simple
    1.7.25


build.gradle:
compile "org.slf4j:slf4j-simple:1.7.25"
------------------------------------------------------------------------
Visit https://javalin.io/documentation#logging if you need more help
[main] INFO io.javalin.Javalin - Starting Javalin ...
[main] ERROR io.javalin.Javalin - Failed to start Javalin
java.lang.IllegalArgumentException: Class org.eclipse.jetty.servlet.ServletMapping[] is instantiated reflectively but was never registered. Register the class by using org.graalvm.nativeimage.RuntimeReflection
        at java.lang.Throwable.(Throwable.java:265)
        at java.lang.Exception.(Exception.java:66)
        at java.lang.RuntimeException.(RuntimeException.java:62)
        at java.lang.IllegalArgumentException.(IllegalArgumentException.java:52)
        at com.oracle.svm.core.genscavenge.graal.AllocationSnippets.checkDynamicHub(AllocationSnippets.java:162)
        at org.eclipse.jetty.util.ArrayUtil.addToArray(ArrayUtil.java:91)
        at org.eclipse.jetty.servlet.ServletHandler.addServletWithMapping(ServletHandler.java:907)
        at org.eclipse.jetty.servlet.ServletContextHandler.addServlet(ServletContextHandler.java:462)
        at io.javalin.core.util.JettyServerUtil.initialize(JettyServerUtil.kt:71)
        at io.javalin.Javalin.start(Javalin.java:136)
        at io.javalin.Javalin.start(Javalin.java:103)
        at de.nerden.samples.graal.Main.main(Main.java:10)
        at com.oracle.svm.core.JavaMainWrapper.run(JavaMainWrapper.java:163)

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

Чтобы устранить эту проблему, мы должны сообщить GraalVM, что сопоставление сервлетов классов должно быть включено в двоичный файл. Поскольку это отражение, а не “нормальная” часть кода, он его не обнаружил. Для этого есть две возможности: на основе кода и на основе конфигурации JSON. Я протестировал и то, и другое, думаю, я предпочитаю подход json, но, в конце концов, это не имеет большого значения. Добавьте в свой проект файл со следующим содержимым:

Обратите внимание на специальное обозначение [[Org.eclipse.пристань.сервлет. Сопоставление сервлетов; . Это необходимо, потому что в этом случае рефлексивно создается массив объектов отображения сервлетов. Кроме того, я добавил классы slf4j и Jackson, чтобы они были найдены во время выполнения. В обоих случаях возникают ошибки во время выполнения, потому что отражение не сработало. Кроме того, мы должны добавить ваши собственные классы в список отражений. Если мы этого не сделаем, при выполнении запроса будет выдано следующее загадочное исключение:

[qtp1024494636-165] WARN io.javalin.core.ExceptionMapper - Uncaught exception
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class de.nerden.samples.graal.Test and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)
        at java.lang.Throwable.(Throwable.java:265)
        at java.lang.Exception.(Exception.java:66)
        at java.io.IOException.(IOException.java:58)
        at com.fasterxml.jackson.core.JsonProcessingException.(JsonProcessingException.java:33)
        at com.fasterxml.jackson.databind.JsonMappingException.(JsonMappingException.java:237)
        at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.(InvalidDefinitionException.java:38)
        at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77)
        at com.fasterxml.jackson.databind.SerializerProvider.reportBadDefinition(SerializerProvider.java:1191)
        at com.fasterxml.jackson.databind.DatabindContext.reportBadDefinition(DatabindContext.java:312)
        at com.fasterxml.jackson.databind.ser.impl.UnknownSerializer.failForEmpty(UnknownSerializer.java:71)
        at com.fasterxml.jackson.databind.ser.impl.UnknownSerializer.serialize(UnknownSerializer.java:33)
        at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider._serialize(DefaultSerializerProvider.java:480)
        at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.serializeValue(DefaultSerializerProvider.java:319)
        at com.fasterxml.jackson.databind.ObjectMapper._configAndWriteValue(ObjectMapper.java:3905)
        at com.fasterxml.jackson.databind.ObjectMapper.writeValueAsString(ObjectMapper.java:3219)
        at io.javalin.json.JavalinJackson.toJson(JavalinJackson.kt:26)
        at io.javalin.json.JavalinJson$toJsonMapper$1.map(JavalinJson.kt:28)
        at io.javalin.json.JavalinJson.toJson(JavalinJson.kt:32)
        at io.javalin.Context.json(Context.kt:510)
        at de.nerden.samples.graal.Main.lambda$main$0(Main.java:11)
        at de.nerden.samples.graal.Main$$Lambda$925/1179449634.handle(Unknown Source)
        at io.javalin.security.SecurityUtil.noopAccessManager(SecurityUtil.kt:22)
        at io.javalin.Javalin$$Lambda$928/1713301975.manage(Unknown Source)
        at io.javalin.Javalin.lambda$addHandler$0(Javalin.java:485)
        at io.javalin.Javalin$$Lambda$931/1107122283.handle(Unknown Source)
        at io.javalin.core.JavalinServlet$service$2$1.invoke(JavalinServlet.kt:48)
        at io.javalin.core.JavalinServlet$service$2$1.invoke(JavalinServlet.kt:20)
        at io.javalin.core.JavalinServlet$service$1.invoke(JavalinServlet.kt:145)
        at io.javalin.core.JavalinServlet$service$2.invoke(JavalinServlet.kt:43)
        at io.javalin.core.JavalinServlet.service(JavalinServlet.kt:109)
        at io.javalin.core.util.JettyServerUtil$initialize$httpHandler$1.doHandle(JettyServerUtil.kt:59)
        at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:203)
        at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:473)
        at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:1564)
        at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:201)
        at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1242)
        at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:144)
        at org.eclipse.jetty.server.handler.HandlerList.handle(HandlerList.java:61)
        at org.eclipse.jetty.server.handler.StatisticsHandler.handle(StatisticsHandler.java:174)
        at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:132)
        at org.eclipse.jetty.server.Server.handle(Server.java:503)
        at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:364)
        at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:260)
        at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:305)
        at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:103)
        at org.eclipse.jetty.io.ChannelEndPoint$2.run(ChannelEndPoint.java:118)
        at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:765)
        at org.eclipse.jetty.util.thread.QueuedThreadPool$2.run(QueuedThreadPool.java:683)
        at java.lang.Thread.run(Thread.java:748)
        at com.oracle.svm.core.posix.thread.PosixJavaThreads.pthreadStartRoutine(PosixJavaThreads.java:238)

Причина в том, что Джексон использует отражение для маршалирования/отмены json. После правильной настройки он работает.

Вы можете попробовать это самостоятельно! докер запускает птичку/грааль-копье

Выполните примерный вызов: локальный хост curl:7000

 j0e@thinkpad  ~/projects/graal-javalin  master ? ⍟2  curl localhost:7000                                         ✔  33707  01:15:12 
{"abc":"LOL"}%

Ура. И время запуска происходит мгновенно. Нет JVM!

Итак, как это получилось, учитывая мои 3 критических замечания?

Время запуска приложения

Приложение запускается мгновенно. Хотя Javalin запускается очень быстро даже на JVM (~ 1-2 секунды), это будет ОЧЕНЬ привлекательно для инструментов CLI.

Объем памяти

Измерение использования памяти процессом не очень прямолинейно. Существует несколько показателей – в соответствии с этим, некоторые публикации в Stackoverflow RSS являются хорошей метрикой: Существует несколько показателей – в соответствии с этим, некоторые публикации в Stackoverflow RSS являются хорошей метрикой:

cat /proc/7812/status
VmRSS:     18260 kB

18 МБ, это выглядит довольно неплохо. Обратите внимание, что я не выполнял никаких нагрузочных тестов, это всего лишь несколько ручных тестов без нагрузки. С еще несколькими запросами с помощью curl он увеличился до 25 МБ. Хорошая вещь: у нас есть роскошь, что мы можем напрямую сравнить это с тем же приложением, но на JVM.

VmRSS:    183580 kB

В данном конкретном случае это примерно 1/10 использования памяти.

Размер приложения

Объем большой банки приложения составляет 5,7 МБ, а самая маленькая – 57 МБ: https://hub.docker.com/r/library/openjdk/tags/ . Для простоты, допустим, всего 60 МБ. Размер собственного двоичного файла составляет около 22 МБ:

-rwxr-xr-x 1 j0e users  22M Sep 24 01:38 graal-javalin

Это ~ 1/3 размера. Это абсолютно приемлемо и почти соответствует размеру двоичных файлов Go. Пожалуйста, обратите внимание, что с JDK9 размеры могут быть меньше. Поэтому я думаю, что преимущество здесь существует, но может быть не очень большим.

В общем, Graal VM – классная штука. Это просто похоже на грязный взлом. Мне действительно не нравится, что всегда могут быть ошибки во время выполнения, которые GraalVM не может предсказать во время компиляции (поправьте меня, если я ошибаюсь). Я не уверен, что это будущее Java, но есть ли оно в любом случае? 😉 Стоит отметить, что некоторые авторы библиотек/фреймворков активно вкладывают время в поддержку GraalVM. На самом деле, микронавт.ввод-вывод теперь совместим: https://github.com/graemerocher/micronaut-graal-experiments .

Полный код, включая файл Dockerfile, доступен на GitHub: https://github.com/birdayz/graal-javalin .

Кроме того, я создал изображение Docker, которое вы можете использовать в качестве базового изображения для создания контейнера только со статическим исполняемым файлом, аналогично тому, как это делается для приложений Go.

В этом файле Docker используются многоступенчатые сборки Docker. Есть два контейнера, один из которых используется только для сборки, и конечный выходной контейнер, который содержит только приложение.

Оригинал: “https://dev.to/birdayz/building-a-22-megabytes-microservice-with-java-javalin-and-graalvm-4afn”