Предыстория

До недавнего времени мой опыт работы с бекенд-приложениями ограничивался созданием приложения на базе 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 и посмотрим показатели:

  1. Конечно, время сборки native image требует намного больше времени и системных ресурсов. У меня ушло около 12 минут на простое приложение.

  1. Во время запуска можно почувствовать колоссальную разницу.

    При запуске приложения обычным способом получаем вот такой результат:

    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 раз! Получается действительно мгновенный запуск.

  1. Так как JAR для своего запуска требует наличие JRE, а native image уже содержит все необходимое, поэтому сравнить размеры файлов довольно трудно. Если мы посмотрим на executable файл, он будет значительно больше jar файла. Почему так? Разница в размере вызвана тем, что native build является автономным исполняемым файлом. Он не требует запуска каких-либо других зависимостей, таких как JRE. Это означает, что мы должны упаковать все, что нам может понадобиться от JRE, внутрь бинарного файла. JAR будет использовать JRE, поэтому он может содержать только исходный код нашего приложения.

  1. Так же с native image потребление RAM меньше примерно на 7%.

Также стоит выделить еще некоторые различия между GraalVm и Jvm‑Hotspot:

  1. Важным моментом является то, что native image имеет определенные ограничения связанные с динамической загрузкой классов и использованием рефлексии. Придется потратить время, чтобы настроить такие вещи, как сериализация, использование ресурсов и так далее, потому что они требуют специальной конфигурации для native image.

  1. Преимуществом GraalVM является поддержка нескольких языков программирования. Таким образом мы можем писать обработку данных на Python, а бизнес логику приложения на Java.

И теперь перейдем к самому интересному, где можно использовать GraalVM. Сравнив Jvm‑Hotspot и GraalVM можно сказать, что GraalVM отлично подойдет для приложений, которые требуют быстрого запуска и в которых нет большого количества сложной бизнес логики.

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

Исходный код изпользуемого в тексте тестового проекта можно найти здесь.

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


  1. typik89
    18.08.2023 15:34
    +4

    1. Если сравнивать производительность для конечно точки, то JAR работает стабильно лучше. - не очень понял это предложение

      Спасибо за статью.

      Сделал для себя вывод, что кажется 7 процентов того не стоят, сколько особенностей настройки стоит держать в голове. Да и если я правильно понимаю, то в итоге есть вероятность, что с jit оптимизациями приложение будет работать даже быстрей, чем native image


    1. aol-nnov
      18.08.2023 15:34

      Если сравнивать производительность для конечно точки

      вероятно, имелась ввиду api endpoint )


    1. headliner1985
      18.08.2023 15:34
      +1

      Пока как я понимаю единственное применение это в облаках для лямбд, функций и тд. ТК там платишь за время и ресурсы.


      1. GerrAlt
        18.08.2023 15:34
        +1

        Если у вас присутствует развертывание дополнительных экземпляров сервиса в ответ на повышение нагрузки на нем, вам скорее всего очень хочется чтобы старт новых экземпляров происходил максимально быстро - вот тут native image также может найти применение.


        1. dyadyaSerezha
          18.08.2023 15:34

          Если изменения нагрузки суточные, то вы мало что выиграете. Но в доках самого GraalVM написано, что и памяти такой екзешник меньше ест, что и понятно - не надо хранить кучу байт-кода, jit-компилятор, загрузчик классов и т.п.


  1. dyadyaSerezha
    18.08.2023 15:34

    Это стало одной из причин интереса к GraalVm - виртуальной машине, написанной на Java, помогающая делать программы быстрее с помощью JIT компилятора

    Я даже не знаю, есть ли сейчас какие-то jvm, у которых нет jit-компилятора. Так зачем на этом акцентировать?