Привет! Меня зовут Антон Богомазов, я backend-разработчик в продуктовой команде Домклик. Наш проект представляет собой более десяти Kotlin/Spring-микросервисов, развернутых в Kubernetes, и постоянно растет, поэтому мы неизбежно сталкиваемся с растущим потреблением ресурсов кластера. Это обстоятельство и подтолкнуло меня к поиску технологий, позволяющих оптимизировать расходы на содержание наших сервисов.
В этой статье я хочу исследовать возможности технологии Java Native Image, поделиться опытом взаимодействия с ней и со средствами Spring для генерации нативных образов.
Native image — технология, позволяющая скомпилировать Java-код в исполняемый файл. Для поддержки этой функциональности существует Spring Native, использующий GraalVM для генерации образов. Главное преимущество такого подхода в том, что можно мгновенно запустить приложение без старта JVM, тратить меньше памяти и иметь меньший размер файла. Еще одним плюсом является отсутствие прогрева приложения, так как компиляция выполнялась до запуска.
Но есть и недостатки: создание native image требует значительно больше времени, чем сборка Java-приложения; отсутствие runtime-оптимизаций снижает пиковую производительность. Также не всякое приложение может быть представлено в виде native image, а использование некоторых фич потребует дополнительной конфигурации. С полным списком ограничений можно ознакомиться здесь.
Чтобы опробовать возможности технологии в деле, создадим простое приложение: контроллер с методом, возвращающим случайное число в ответ на GET-запрос.
Прежде всего установим GraalVM, который требуется Spring Native; нас интересуют его возможности AOT-компиляции. Скачайте по ссылке https://github.com/graalvm/graalvm-ce-builds/releases, а содержимое положите в JavaVirtualMachines.
Теперь можно приступить к созданию приложения; я воспользуюсь Spring Initializr. Добавим зависимости Spring Native [Experimental] и Spring Reactive Web. Последняя не является обязательной, но сделает код проще за счет Reactive Kotlin DSL:
plugins {
id("org.springframework.boot") version "2.6.1"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
kotlin("jvm") version "1.6.0"
kotlin("plugin.spring") version "1.6.0"
id("org.springframework.experimental.aot") version "0.11.0-RC1"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
}
Создадим класс контроллера с одним методом:
@Configuration
class Controller {
@Bean
fun getRandomNumber() = router {
GET("/getRandomNumber") {
ServerResponse.ok()
.body(Mono.just(Random.nextInt()), Int::class.java)
}
}
}
Приложение готово!
Подготовительный этап закончен и можно приступить к сравнению. С помощью nativeCompile в Gradle я создал native image и JAR одного и того же приложения и сравнил различные их показатели:
Разница во времени создания колоссальная, компиляция нативного образа требует большого количества системных ресурсов, что отражается на длительности создания. У меня ушло около 4 минут даже на такое простое приложение.
С native image потребление RAM удалось сократить на 7 %.
Сравнить размеры файлов оказалось довольно сложно. JAR для своего запуска требует наличие JRE в окружении, а native image уже содержит все необходимые компоненты, поэтому я прибавил к размеру JAR 46 мб — размер среднего JRE. Поэтому размер образа оказался также на 7 % меньше.
Длительность запуска составила 5,84 и 0,72 секунды для JAR и native image соответственно, обещания мгновенного старта оказались не пустыми словами.
Так как измерения во многом приблизительные, вместо цифр я приведу диаграмму, которая, тем не менее, красноречиво описывает свойства каждого из подходов:
Попробуем ответить на главный вопрос: где это можно применить? Я вижу несколько сфер:
Прежде всего, веб-приложения и FaaS. Очень быстрый старт позволяет поднимать и гасить реплики значительно быстрее, чем если бы это были обычные Java-приложения. С другой стороны, это сработает не так хорошо с приложениями, зависимыми от инфраструктуры: подключение к БД и брокерам сообщений отнимает ценное время.
Десктопные приложения. Компиляция исполняемого файла позволяет запускать на машине без JVM, что снижает требования к среде выполнения и расширяет области применения Java.
Итоги
Уменьшенный размер образа и, как следствие, пода позволит оптимизировать ресурсы K8s-кластера, потенциально позволяет держать большую нагрузку за счет большего количества реплик. Для еще более радикального уменьшения размера можно сочетать native image с distroless.
Но чаще дефицитным ресурсом является RAM. Остается без ответа вопрос, будет ли полученная оптимизация масштабирована соответственно размеру приложения, и можно ли её увеличить тонкими настройками при создании образа? Это требует отдельного исследования.
Кроме того, функциональность Spring Native является экспериментальной, что может стать аргументом против её использования в вашем проекте.
Для себя я решил, что проект недостаточно зрелый, но, безусловно, интересный, буду наблюдать за его развитием.