Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
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. Замеры времени старта, размера бинарей и потребления ресурсов
Как видно на скринах, нативное приложение стартует почти в 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 минут компиляции не дадут большого буста относительно быстрого старта пода. Но, возможно, если у вас сотни или тысячи подов - то тут время сборки будет оправдано относительно времени старта всего кластера.
В общем нужно хорошо оценивать степень необходимости в нативной компиляции, иначе со временем может быть больше проблем чем профита.
Готов ответить на вопросы в комментариях.
Спасибо за внимание!