English version

В этой статье я хочу рассказать о практическом опыте нативной компиляции production приложения, написанного на Kotlin со Spring Boot, Gradle с использованием GraalVM . Начну сразу с минусов и плюсов самой возможности нативной компиляции и где она может быть полезна, и дальше перейду уже непосредственно к процессу сборки под MacOS и Windows.

В конце статьи я более подробно расскажу о проекте и почему возникла такая необходимость, учитывая довольно много ограничений и подводных камней поддержки нативной компиляции как со стороны Spring Boot, та и со стороны GraalVM.

1. О минусах и где не будет смысла использовать

  • Требуются большие ресурсы и довольно много времени на компиляцию.

  • Требуется рассказать GraalVM о всех прокси классах и вообще всей рефлексии в проекте, чтобы компилятор случайно не выкинул какой-нибудь класс или метод потому что не смог до него достучаться.

  • Из предыдущего пункта следует, что для интеграции с CI нужно очень большое покрытие тестами, чтобы не пропустить какой-нибудь код (врядли кто-то захочет все вручную описывать в конфиг файлах GraalVM).

  • В continuous delivery я не увидел особого смысла, т.к. для сборки требуются довольно большие ресурсы от которых сильно зависит время компиляции, которое может достигать 15-30 минут для не очень больших приложений, при этом может требоваться минимум 8-10 Гб оперативки и ядер побольше. Из плюсов только очень быстрое время старта - мое среднеее по размерам приложение стартует за 0.5 секунды, а с JVM - около 5-10 секунд. Это может позволить очень быстро разворачивать кластер, например, в k8s (ну и если под упал, то рестартанет он очень быстро).

  • Возможно еще в каких-то случаях не будет смысла использовать из-за предыдущих ограничений и требований.

2. Где может очень пригодиться?

Очень полезным я нашел применение для десктопа и там где нужна какая-либо защита кода, т.к. после компиляции мы получим уже совершенно другой бинарный код (я детально его не декомпилировал, но судя по разным обсуждениям это уже не простой JAVA код)

Плюсы для десктопа довольно очевидны в сравнении с JVM:

  • Маленький размер исходного бинаря важно при распространении приложения для клиентов - в моем случае стандартный *.jar вместе с JDK получался размером примерно в 300-400Мб, нативный бинарь - 190Мб (MacOS)

  • Не требуется JDK и добавлять всякие хитрости вроде скачивания самой JDK в фоне при установке, чтобы показать клиентам маленький размер исходного *.jar файла

  • Код защищен от копирования - по крайней мере гораздо сложнее воспроизвести по сравнению с обычным *.jar-ником даже после прогона через ProGuard

  • Очень быстрый старт почти как у низкоуровневых языков (C++ и подобных)

Последний пункт играет особенно большую роль, т.к. если пользователь может потратить 1 раз время на скачивание жирного приложения, то вот часто запускать его и ждать по 10-20 секунд такое себе.

Мне как раз сразу понадобились все эти пункты, поэтому я решил разобраться с технологией и адаптировать свой проект для нативной компиляции. В конце я расскажу как в общем он устроен.

3. Предисловие

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

Технология используется в моем реальном среднем по объему десктопном приложении для клиентов, код которого я, к сожалению, не могу показать, но создал специально очень упрощенный скелетон для демонстрации с наиболее важными зависимостями из него.

Исходники тестового приложения находятся в репозитории.

Что входит в скелетон приложения:

  • Websockets

  • Jackson (Object Mapper)

  • Caffeine Cache

  • SQLite

  • Mapstruct

  • Flyway

  • Kotlin Coroutines

  • Logstash Logback (для логирования в JSON для систем сбора и хранения логов)

  • Всякие Junit 5, Kotest и прочие тестовые библиотеки (хотя сами тесты я не добавлял)

4. Некоторые тонкости сборки нативного приложения

Немного хочу остановиться на том, что требуется для успешной компиляции и где сохранять конфиги для GraalVM.

Итак, для того чтобы GraalVM успешно скомпилировал приложение и не выкинул какие-то части кода по дороге, требуется описать специальные файлы конфигов с мета информацией обо всех прокси классах, да и вообще практически обо всех классах/методах/параметрах. Сделать это можно вручную, но для серьезного применения такой подход не годится. Для этого был создан нативный агент, который запускается вместе с приложением и собирают всю информации из рантайма, и генерирует необходимые конфиги.

Сами конфиги сохраняются в каталоге: /resources/META-INF/native-image.

Запустить агента можно следующей командой:

graalvm-jdk-17/Contents/Home/bin/./java -Dspring.aot.enabled=true -agentlib:native-image-agent=config-merge-dir=./src/main/resources/META-INF/native-image -jar build/libs/native-app-1.0.0.jar

Здесь важна конструкция:

-agentlib:native-image-agent=config-merge-dir

В нашем случае нужен именно режим: config-merge-dir, который добавляет новую метаинформацию в существующие конфиги, а не перезатирает имеющуюся. Особенно важно для тестов, там используется такой же режим. Соответственно этот режим позволяет хранить конфиги в гите и если код особо не поменялся, то можно пересобрать приложение без этапов запуска агента. Например, это может ускорить процесс CI/CD, если генерировать конфиги на машине разработчика.

5. Инструментарий - установка и настройка

Я собираю приложение сразу под MacOS и Windows, и опишу как это сделать, особенно есть нюансы под Windows.

Версия JDK 17 и GraalVM так же под эту версию. (Версию 20 я не пробовал).

Далее в Gradle нужно добавить зависимости для сборки native (приведу в примере только необходимые зависимости, весь файл можно посмотреть в примере на github):

Общие настройки build.gradle.kts для всех платформ:

build.gradle.kts
val nativeImageConfigPath = "$projectDir/src/main/resources/META-INF/native-image"
val nativeImageAccessFilterConfigPath = "./src/test/resources/native/access-filter.json"

plugins {
    val buildToolsNativeVersion = "0.9.21"

    id("org.graalvm.buildtools.native") version buildToolsNativeVersion
}

repositories {
    mavenCentral()
    maven { url = uri("https://repo.spring.io/milestone") }
    mavenLocal()
}

graalvmNative {
    binaries {
        named("main") {
            buildArgs(
                "-H:+ReportExceptionStackTraces",
                "-H:EnableURLProtocols=http,https",
            )
        }
    }
}

buildscript {
    repositories {
        maven {
            setUrl("https://plugins.gradle.org/m2/")
        }
    }
}

tasks.withType<Test> {
    jvmArgs = listOf(
        "-agentlib:native-image-agent=access-filter-file=$nativeImageAccessFilterConfigPath,config-merge-dir=$nativeImageConfigPath"
    )
}

Дополнительно нужно обновить урлы репозиториев pluginManagement в файле settings.gradle.kts:

settings.gradle.kts
pluginManagement {
    repositories {
        maven { url = uri("https://repo.spring.io/milestone") }
        maven { url = uri("https://repo.spring.io/snapshot") }
        gradlePluginPortal()
    }
}

И в каталоге с тестами: /test/resources/native/access-filter.json добавить файл фильтра лишних классов из кофнига метаинформации после запуска тестов.

/test/resources/native/access-filter.json
{ "rules": [
  {"excludeClasses": "com.gradle.**"},
  {"excludeClasses": "sun.instrument.**"},
  {"excludeClasses": "com.sun.tools.**"},
  {"excludeClasses": "worker.org.gradle.**"},
  {"excludeClasses": "org.gradle.**"},
  {"excludeClasses": "com.ninjasquad.**"},
  {"excludeClasses": "org.springframework.test.**"},
  {"excludeClasses": "org.springframework.boot.test.**"},
  {"excludeClasses": "org.junit.**"},
  {"excludeClasses": "org.mockito.**"},
  {"excludeClasses": "org.opentest4j.**"},
  {"excludeClasses": "io.kotest.**"},
  {"excludeClasses": "io.mockk.**"},
  {"excludeClasses": "net.bytebuddy.**"},
  {"excludeClasses": "jdk.internal.**"},
],
  "regexRules": [

  ]
}

Особенно из запуска агента из тестов мне доставлял проблем при компиляции пакет: jdk.internal.**. После добавления в фильтр проблемы пропали.

При необходимости можно добавлять сюда свои пакеты/классы, которые необходимо исключить из компиляции. Подробнее про это можно найти в документации GraalVM.

5.1 MacOS

Скачиваем для начала архив с GraalVM для MacOS и версии Java 17 с оф. сайта: GraalVM Downloads

Распаковываем в удобное место и прописываем env переменную:

export JAVA_HOME=<path-to-graalvm-jdk-17/Contents/Home>

source ~/.zshrc

На этом настройка завершена, можно в Intellij Idea выбрать GraalVM в настройках проекта и запускать сборку.

5.2 Windows

Сборку я производил на виртуалке VMware с Windows 10.

5.2.1 Сначала о проблемах и ограничениях

Здесь все оказалось чуть сложнее. Сложности возникли из-за ограничений Windows на длину пути к файлу для зависимостей maven'a (issue). Решением оказалось перенести каталог с зависимостями в самый короткий путь, для меня в C:\m2.

5.2.2 Инструментарий

Скачиваем для начала архив с GraalVM для Windows и версии Java 17 с оф. сайта: GraalVM Downloads

Вообще на сайте GraalVM есть подробный туториал для установки под Windows: Install GraalVM on Windows. Рекомендую следовать ему, т.к. там требуется еще установка Visual Studio Build Tools and Windows SDK.

Устанавливаем Gradle для Windows по туториалу с оф. сайта: Gradle on Windows в разделе Installing manually. Не буду здесь повторяться.

В итоге должны быть правильно прописаны env переменные с путями до gradle и java (можно проверить версии через консоль) и правильно установлен Visual Studio Build Tools.

Важно сборку производить в правильной консоли: x64 Native Tools Command Prompt for VS 20**!

6. Сборка тестового приложения

Исходники приложения можно скачать с репозитория или склонировать командой:

git clone https://github.com/devslm/kotlin-spring-boot-native-skeleton.git

Для чистоты эксперимента рекомендую удалить все файлы из каталога: /resources/META-INF/native-image, чтобы самостоятельно понаблюдать весь процесс сборки. Если что-то не получится, то можно использовать уже собранные мною файлы и проверить, что сборка проходит успешно.

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

6.1 MacOS

# Собираем приложение
gradle clean build

# Запускаем нативного агента
<path-to-graalvm-jdk-17>/java -Dspring.aot.enabled=true -agentlib:native-image-agent=config-merge-dir=./src/main/resources/META-INF/native-image -jar build/libs/kotlin-spring-boot-native-skeleton-1.0.0.jar

После запуска приложения стоит подождать некоторое время, чтобы все проинициализировалось и запустились различные скедулеры. Еще лучше если есть возможность повызывать endpoint'ы и пр.

Далее просто останавливаем приложение и увидим либо новые конфиг файлы в каталоге /resources/META-INF/native-image если это первый запуск, либо изменения в существующих.

Теперь остается только запустить непосредственно компиляцию:

gradle nativeCompile

После успешной сборки в каталоге: build/native/nativeCompile увидим бинарь приложения, который можем запустить из консоли.

Для оптимизации этой рутины я написал простой скрипт для сборки на Python в каталоге: ci/build.py. При его запуске он пройдется по всем этапам. С помощью параметра --aot-wait можно задать желаемое время ожидания в минутах работы приложения.

Только для его работы нужно в нем поменять путь до GraalVM Java в переменной GRAALM_HOME_PATH на свой путь.

Запуск скрипта из каталога приложения с временем работы приложения 1 минута в AOT режиме:

python3 ci/build.py --aot-wait 1

Скрипт можно запустить так же и под Windows, но я обычно просто копирую результат сборки на Windows и сразу запускаю нативную компиляцию, поэтому в моем случае надобности в нем под Windows нет.

6.2 Windows

Необходимо создать каталог для сохранения зависимостей maven'a с максимально коротким путем. В моем случае это был: C:\m2.

Т.к. я обычно для сборки в Windows просто копирую каталог скомпиленного приложения с MacOS в котором уже был собран *.jar и собраны конфиги нативным агентом, то остается только выполнить команду компиляции:

gradle "-Dmaven.repo.local=C:\m2" --gradle-user-home=C:\m2 nativeCompile

Но если проект был склонирован непосредственно на Windows машину с репозитория, то необходимо выполнить все шаги как для MacOS, только в этот раз добавляем пути до нашего кастомного каталога .m2:

# Собираем приложение
gradle "-Dmaven.repo.local=C:\m2" --gradle-user-home=C:\m2 clean build

# Запускаем нативного агента
java "-Dspring.aot.enabled=true" -agentlib:native-image-agent=config-merge-dir=./src/main/resources/META-INF/native-image -jar build/libs/kotlin-spring-boot-native-skeleton-1.0.0.jar

Теперь остается только запустить непосредственно компиляцию:

gradle "-Dmaven.repo.local=C:\m2" --gradle-user-home=C:\m2 nativeCompile

После успешной сборки в каталоге: build/native/nativeCompile увидим бинарь приложения, который можем запустить просто двойным кликом.

7. Замеры времени старта, размера бинарей и потребления ресурсов

Время запуска обычного *.jar
Время запуска обычного *.jar
Время запуска нативного приложения
Время запуска нативного приложения

Как видно на скринах, нативное приложение стартует почти в 14 раз быстрее обычного *.jar. По мере обрастания функционалом время запуска будет расти у обычного приложения на секунды, а у нативного так и не будет больше 1 секунды!

Оперативки после старта нативное приложение занимает 100Mb, а обычное 350Mb (замеры только на момент старта).

Размер обычно *.jar файла получился 98.2Mb (учитываем что он пустой без логики) + размер архива обычной JDK 17 ~220Mb итого суммарный размер приложения ~320Mb.

Размер нативного приложения ~149Mb - и все. Причем этот стартовый размер будет всегда примерно одинаковым, т.к. нативное приложение не совсем нативный код, там содержится дополнительная среда чтобы все это работало (насколько помню Substrate VM). Но с ростом кодовой базы размер не будет уже сильно расти, размер моего приложения ~190Mb.

8. Послесловие

Как и обещал, расскажу немного об истории создания приложения и из чего оно стало состоять в итоге.

Изначально я разрабатывал его полностью на JavaFX и Spring Boot, но довольно быстро понял основные минусы - большой размер файла, долгий старт, большое потребление ресурсов и огромные затраты времени, чтобы поддержать приемлемый дизайн и UI для 2023-го года. Но именно долгий старт стал скорей всего решающим, т.к. его довольно часто может приходиться запускать. Защита от копирования и реверс инжиниринга так же была важна.

Далее мне пришла мысль нативно скомпилировать приложение, чтобы избавиться от основной проблемы долгого старта, к тому же я следил за проектом Spring Boot Native с первых дней его упоминания (я сразу увидел большие возможности). После попыток компиляции именно связки JavaFX + Spring Boot, у меня ничего так и не получилось. Скорей всего из-за того, что саму JavaFX нужно компилировать отдельно и есть специальный инструментарий.

И вот дальше мне пришла внезапно отличная идея - Electron + Backend на Kotlin и Spring Boot. Для web есть куча готовых красивых шаблонов, а backend станет простым REST сервисом и сам Electron соберет все это удобно в исполняемое приложение под каждую OS. Web часть написана на React.

После создания первого прототипа с нативной компиляцией результат превзошел все ожидания - все красиво, теперь разрабатывать UI проще простого под любую фантазию, а Backend быстро стартует, защищен от изменений и копирования, занимает мало места и ресурсов.

Итоговый размер всего собранного приложения стал 212 Mb под MacOS (речь о финальном билде с Electron), а под Windows еще меньше. Стартует все приложение меньше секунды, почти не отличить от других нативных приложений.

Часто вижу мнения, что нативная компиляция чуть ли не революция в мире spring для контейнеризации, но после довольно большого опыта сборки - я, честно говоря, так особо не считаю. Да, это огромный шаг для Spring и Java/Kotlin за последние годы, открываются новые возможности о которых раньше только мечтали (создавали всякие костыли).
Но с другой стороны сборка очень трудо- и ресурсо- затратная, все это не так-то просто автоматизировать через CI и занимает очень много времени сборки, прям очень много. Как мне кажется, это просто решение в плане контейнеризации для каких-то специфических задач (каждый сам за себя).

Если к примеру, у вас 3-4 пода запускается в k8s, то смысла нет никакого, 15 минут компиляции не дадут большого буста относительно быстрого старта пода. Но, возможно, если у вас сотни или тысячи подов - то тут время сборки будет оправдано относительно времени старта всего кластера.

В общем нужно хорошо оценивать степень необходимости в нативной компиляции, иначе со временем может быть больше проблем чем профита.

Готов ответить на вопросы в комментариях.

Спасибо за внимание!

Комментарии (11)


  1. schernolyas
    12.09.2023 07:19

    Спасибо! С удовольствием прочитал.


    1. ris58h
      12.09.2023 07:19

      Для этого есть "стрелочка вверх" под статьёй.


      1. schernolyas
        12.09.2023 07:19

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


  1. konsoletyper
    12.09.2023 07:19
    +2

    Есть ещё один юзкейс для graal native image: компиляция приложений под iOS. Иногда в этом есть смысл, если хочется на Java/Kotlin ваять кроссплатформенно, но нативный look&feel вообще не нужен (например, игры).

    Что касается неудобств с reflection, то тут есть одно решение: не использовать. Вы не поверите, на что способны annotation processor в умелых руках, дополненных compile time инструментацией байт-кода. Хотя полагаю, что, для spring boot это может быть невозможно.


    1. devslm Автор
      12.09.2023 07:19
      +1

      Спасибо за комментарий!
      К сожалению, с IOS не приходилось работать, но интересно звучит. Читал, что есть нативные возможности самого котлина для кроссплатформенной сборки под Android и IOS.


      1. konsoletyper
        12.09.2023 07:19

        Да, такие возможности есть, но это работает только в случае, если приложение написано целиком на Kotlin. Если же это Kotlin + Java, то спасает только native image или другие подобные AOT-компиляторы. Честно, я не в курсе, что сейчас творится на этом рынке, когда-то ещё были MOE, RoboVM и Avian, здравствуют ли они поныне - я не проверял.


  1. roman901
    12.09.2023 07:19
    +3

    Я часто вижу статьи про

    нативное приложение стартует почти в 14 раз быстрее обычного *.jar

    но почему-то никто не пишет, насколько медленнее такое приложение работает :)

    По моим тестам, приложение на micronaut, с методом, который разжимает-сжимает json-ы через jackson, держит примерно в два раза меньше RPS на четырёх ядрах, чем нетюненая JVM с G1GC.


    1. devslm Автор
      12.09.2023 07:19
      +1

      Я, к сожалению, про RPS не могу сказать, т.к. профиль нагрузки это десктопное приложение с не большими вычислениями (сейчас под активной нагрузкой CPU от Java приложения не выходит за 70% от ядра) и я не измерял, но проверить под нагрузкой это хороший поинт. Будет время - проверю.


    1. sandersru
      12.09.2023 07:19
      +1

      Наблюдал совершенно обратную ситуацию с RPS. Только не на синтетическом кейсе, а готовом приложении. Натив это не только старт, но и меньше cpu/mem. Точных цифр не помню, но условно 15-30% там было. Вместо micronaut был quarkus


  1. Wan-Derer
    12.09.2023 07:19

    Electron, React... Выглядит как-то чересчур. Неужели нет способа попроще и если надо сделать банальное десктоп-приложение, без всяких красивостей?

    Я смотрю на Dart/Flutter, вроде позволяет собрать десктоп и есть встроенные библы для создания UI. Но это другой язык, хоть и похож на Java. И некоторый бардак с библиотеками.

    Есть ли что-то для экосистемы JVM? Ну, кроме JavaFX? Что позволило бы просто собрать "экзешник"? Я бы даже поступился идеей полной кроссплатформенности в том смысле что если придётся "фронт" пилить под каждую ОС, то ладно, далеко не всегда требуется приложение под все платформы.


    1. devslm Автор
      12.09.2023 07:19
      +2

      Как раз цель была в красивости и удобстве (учитывая что за это платят деньги). Второе ограничение - это некоторые специальные библиотеки для проекта, доступные для JAVA.
      Банальное десктопное приложение можно писать на чем угодно - согласен, но обычно это не production-ready система, которую захотят покупать пользователи.
      Да и мне как JAVA разработчику гораздо удобней использовать язык который я хорошо знаю, а REACT я тоже немного знал и с ним все было просто. Опять же, почему REACT - я купил готовый HTML + REACT шаблон и получил красивый дизайн и набор всех компонентов из коробки, которые просто блоками копирую и вставляю какие нужны. Сэкономил месяцы времени только на UI и дизайне.
      Поэтому тут каждый выбирает инструменты под задачи. Достойных альтернатив нативной компиляции я пока не встречал.