Предыстория
До недавнего времени мой опыт работы с бекенд-приложениями ограничивался созданием приложения на базе Spring Boot различных версий с использованием реляционных баз данных, ликвибейза, брокеров сообщений и т.д. Приложения в большинстве своем были легковесными, быстро запускались и не требовали большого количества ресурсов. Пока на работе мы с командой не столкнулись с проектом, который мало того, что запускался мучительно долго, так ещё и работал с большим количеством сервисов, постоянно то отдавая, то обрабатывая различные данные. Всё это, конечно же, приводило к медленной работе в продакшене, частым зависанием или вообще поломкой сервиса.
Это стало одной из причин интереса к GraalVm - виртуальной машине, написанной на Java, помогающей делать программы быстрее с помощью JIT компилятора. GraalVm помогает скомпилировать java код в так называемый native image. Это исполняемый файл приложения, который мгновенно запускается без старта JVM.
Эта статья - туториал, как подружить между собой Spring Boot, GraalVm, Liquibase и Docker, какие могут возникнуть подводные камни и как их можно обойти. Начнем!
Конфигурация приложения
С создания Spring Boot приложения, конечно. В качестве сборщика приложения будет использоваться Maven. Для быстрого создания приложения можно воспользоваться https://start.spring.io/. Для своего проекта я использовала следующие версии: Spring Boot версии 3.0.8, GraalVm версии 23.0 и Java 17. Если вы никогда не работали с граалем (как и я, до недавнего времени) рекомендую использовать те же версии, что и я, и после того, как у вас всё заведется, эксперементировать с версионированием, проверяя, сказывается ли это на результате.
После того, как мы скачали архив приложения, самое время распаковать его и открыть.
Чтобы собрать native image, убедитесь, что в вашем pom.xml файле подключен spring-boot-starter-parent. Секция parent должна выглядеть следующим образом:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.8</version>
</parent>Так же нам понадобятся следующие зависимости:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
</dependencies>Если вы собираете проект с помощью spring starter, всё это можно выбрать там на этапе подключения доп.зависимостей, и они автоматически сгенерятся у вас в pom.xml
Для удобной работы с базой данных на своих рабочих проектах мы используем liquibase, поэтому для чистоты эксперимента решено было подключить его как зависимость в тестовый проект, а так же взять ту же базу, что используется и в рабочем проекте, а именно PostgreSql. Какие нюансы здесь стоит учесть, я расскажу ниже.
Далее подключаем плагины. Спустя много часов, проведенных в изучении различных гайдов по настройке проекта для native image, я пришла к выводу, что в стандартных проектах для плагинов каких-то специфичных настроек не нужно. Главное не забыть указать для Liquibase пути к проперти-файлу и главному changelog файлу.
Следовательно, секция билд в итоге у меня сталавыглядеть вот так:
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>process-aot</id>
<goals>
<goal>process-aot</goal>
</goals>
</execution>
</executions>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<configuration>
<changeLogFile>src/main/resources/db/master-changelog.xml</changeLogFile>
<propertyFile>src/main/resources/liquibase.properties</propertyFile>
</configuration>
</plugin>
</plugins>
</build>Больше про настройку native-maven-plugin и spring-boot-maven-plugin можно прочитать здесь и здесь соответственно.
Полный pom.xml файл:
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.8</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.test</groupId>
<artifactId>graalvm-project</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>graalvm-project</name>
<description>Test project for testing GraalVm</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>process-aot</id>
<goals>
<goal>process-aot</goal>
</goals>
</execution>
</executions>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<configuration>
<changeLogFile>src/main/resources/db/master-changelog.xml</changeLogFile>
<propertyFile>src/main/resources/liquibase.properties</propertyFile>
</configuration>
</plugin>
</plugins>
</build>
</project>
Пишем код
Здесь я подробно останавливаться не буду, так как это тестовое приложение и все ниже приведенные классы довольно тривиальны.
Код main класса приложения:
@SpringBootApplication
public class GraalvmProjectApplication {
public static void main(String[] args) {
SpringApplication.run(GraalvmProjectApplication.class, args);
}
}Теперь создадим тестовый контроллер, entity класс и репозиторий.
TestEntity.java
@AllArgsConstructor
@Entity
@Table(name = "test_table")
public class TestEntity {
@Id
@Column(name = "test_id")
private Long id;
@Column(name = "test_column")
private String testColumn;
}TestRepository.java
public interface TestRepository extends JpaRepository<TestEntity, Long> {
}TestController.java
@RestController
@RequestMapping("/api/tests")
@RequiredArgsConstructor
public class TestController {
private final TestRepository testRepository;
@GetMapping
public List<TestEntity> getAllTests(){
return testRepository.findAll();
}
}Liquibase и reflection
Для того, чтобы не создавать таблицы руками, раз уж мы используем Liquibase, создаем changeset с нашей тестовой таблицей, а так же main changelog файл.
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet logicalFilePath="/changelog/2023-07-25--01-test-table-changeset.xml"
id="1" author="Liquibase">
<createTable tableName="test_table">
<column name="test_id" type="int">
<constraints primaryKey="true"/>
</column>
<column name="test_column" type="varchar"/>
</createTable>
</changeSet>
</databaseChangeLog>Новый changeset мы положили в папку resources по пути /db/changelog, и чтобы Liquibase увидел его, наш главный файл должен выглядеть так:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<include file="db/changelog/2023-07-25--01-test-table-changeset.xml"/>
</databaseChangeLog>Но, конечно, просто указать все пути недостаточно.
Как пишут разработчики Spring Boot, GraalVm native image поддерживает настройку через статические файлы, которые автоматически обнаруживаются при нахождении в META-INF/native-image. Это могут быть файлы native-image.properties, Reflect-config.json, proxy-config.json или resource-config.json. Spring Native генерирует такие файлы конфигурации (которые будут находиться рядом с любыми файлами, предоставленными пользователем) с помощью плагина сборки Spring AOT. Однако бывают ситуации, когда требуется указать дополнительную нативную конфигурацию.
То есть, для того, чтобы наши файлы ченджсетов Liquibase были в итоговом executable файле, нам необходимо написать специальный класс, HintsRegistrar, который будет имплементировать RuntimeHintsRegistrar:
public class HintsRegistrar implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
hints.resources().registerPattern("db/master-changelog.xml");
hints.resources().registerPattern("db/changelog/*.xml");
}
}Чтобы наша конфигурация работала, её необходимо подключить с помощью аннотации @ImportRuntimeHints(HintsRegistrar.class) .
Теперь наш main класс выглядит так:
@SpringBootApplication
@ImportRuntimeHints(HintsRegistrar.class)
public class GraalvmProjectApplication {
public static void main(String[] args) {
SpringApplication.run(GraalvmProjectApplication.class, args);
}
}Подробнее про это можно прочитать здесь.
База данных, Docker и docker-compose
Начнем с сервиса базы данных. Создадим компоуз файл и положим его в папку environments. Структура нашего приложения теперь выглядит примерно так:
graalvm-project
|
+-environments
| +-docker-compose.yml
+-src
| +-main
| +-java
| +-com.test.graalvmproject
| +-<application classes>
| +-resources
| +-db
| +-master-changelog.xml
| +-application.yml
| +-liquibase.properties
+-pom.xml
Прежде чем наполнять docker-compose файл, я создаю файл с расширением .sql, где делаю базовую настройку моей будущей базы данных:
CREATE DATABASE testdb ENCODING 'UTF8';
CREATE USER test_user WITH PASSWORD 'password';
GRANT ALL PRIVILEGES ON DATABASE testdb TO test_user;И docker-compose:
version: '3'
services:
graalvm-project:
image: graalvm-project:latest
container_name: graalvm-project-container
build:
context: ../
dockerfile: ./environments/Dockerfile
ports:
- '8080:8080'
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/testdb?useSSL=false
- SPRING_DATASOURCE_USERNAME=test_user
- SPRING_DATASOURCE_PASSWORD=password
depends_on:
db:
condition: service_healthy
db:
image: postgres:14.1-alpine
container_name: db
restart: on-failure
environment:
- POSTGRES_USER=test_user
- POSTGRES_PASSWORD=password
ports:
- '5432:5432'
volumes:
- db:/var/lib/postgresql/data
- ./database.sql:/docker-entrypoint-initdb.d/database.sql
healthcheck:
test: pg_isready -Utest_user
interval: 1s
timeout: 1s
retries: 5
volumes:
db: { }Для того, чтобы наше приложение смогло подключиться к базе из докер контейнера, в SPRING_DATASOURCE_URL мы указываем имя контейнера в качестве хоста. И дополнительно в сервисе базы данных добавляем проверку на хелсчек - чтобы наше приложение дожидалось запуска самой базы, прежде чем стартовать и стучаться в неё.
Для билда приложения мы используем следующий Dockerfile:
FROM vegardit/graalvm-maven:latest-java17 as builder
WORKDIR /app
COPY pom.xml /app/
COPY src /app/src/
RUN mvn -Pnative native:compile
FROM mirekphd/jenkins-jdk17-on-ubuntu2204:latest
COPY --from=builder /app/target/graalvm-project /app/
CMD ["/app/graalvm-project"]
Для вызова команды mvn -Pnative native:compile необходимо окружение, которое содержит в себе грааль и maven, поэтому используется vegardit/graalvm-maven:latest-java17 образ.
Команда mvn -Pnative native:compile запустит компиляцию native image, после чего native image executable файл можно будет найти в папке target нашего проекта. Как видно из синтаксиса Dockerfile, мы копируем executable в рабочую папку докера и запускаем наше приложение.
Так же для создания native image с помощью maven может использоваться следующая команда:
mvn -Pnative spring-boot:build-imageПри таком подходе собирается контейнеризованная версия приложения. Запустить её можно, например, следующим образом:
docker run --rm -p 8080:8080 graalvm-project:0.0.1-SNAPSHOTА выводы?
Теперь давайте сравним приложение с GraalVm и Jvm‑Hotspot с точки зрения памяти и произодительности. Для этого сделаем для одного и того же приложения native image и JAR и посмотрим показатели:
Конечно, время сборки native image требует намного больше времени и системных ресурсов. У меня ушло около 12 минут на простое приложение.
Во время запуска можно почувствовать колоссальную разницу.
При запуске приложения обычным способом получаем вот такой результат:
c.t.g.GraalvmProjectApplication: Started GraalvmProjectApplication in 3.508 seconds (process running for 3.874)С native image мы получаем следующий результат:
c.t.g.GraalvmProjectApplication: Started GraalvmProjectApplication in 0.788 seconds (process running for 0.793)Конечно, на тестовом проекте особо нет разницы, меньше секунды или 3 поднимается приложение. Но если мы сравним приложение побольше, например, наше рабочее, там разница уже будет приятная - 0.356 секунд native image против 7.152 запуска jar. Разница почти в 20 раз! Получается действительно мгновенный запуск.
Так как JAR для своего запуска требует наличие JRE, а native image уже содержит все необходимое, поэтому сравнить размеры файлов довольно трудно. Приблизительно получается, что native image на 7% меньше JAR.
Так же с native image потребление RAM меньше примерно на 7%.
Если сравнивать производительность для конечно точки, то JAR работает стабильно лучше.
Также стоит выделить еще некоторые различия между GraalVm и Jvm‑Hotspot:
Важным моментом является то, что native image имеет определенные ограничения связанные с динамической загрузкой классов и использованием рефлексии. Придется потратить время, чтобы настроить такие вещи, как сериализация, использование ресурсов и так далее, потому что они требуют специальной конфигурации для native image.
Преимуществом GraalVM является поддержка нескольких языков программирования. Таким образом мы можем писать обработку данных на Python, а бизнес логику приложения на Java.
И теперь перейдем к самому интересному, где можно использовать GraalVM. Сравнив Jvm‑Hotspot и GraalVM можно сказать, что GraalVM отлично подойдет для приложений, которые требуют быстрого запуска и в которых нет большого количества сложной бизнес логики.
В целом, как GraalVM, так и JVM обладают своими сильными и слабыми сторонами, и выбор между ними зависит от конкретных потребностей проекта или приложения.
Исходный код изпользуемого в тексте тестового проекта можно найти здесь.
