Как скомпилировать Spring Boot приложение в native image с помощью GraalVm и развернуть его с помощью Docker

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Предыстория

До недавнего времени мой опыт работы с бекенд-приложениями ограничивался созданием приложения на базе 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 уже содержит все необходимое, поэтому сравнить размеры файлов довольно трудно. Приблизительно получается, что native image на 7% меньше JAR.

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

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

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

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

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

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

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

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

Источник: https://habr.com/ru/articles/755550/


Интересные статьи

Интересные статьи

В преддверии обновления Shanghai мы изучили ончейн-показатели проектов, развивающих экосистему Ethereum, собрали информацию о планах развития ETH, разобрали и описали технологии, которые позволяют сил...
В предыдущей статье мы рассмотрели, как можно собрать базовую автоматизацию ревью для документации и как уже на этом этапе она облегчает жизнь аналитику и команде. Однако всегда появляются новые задач...
Привет, Хабр! Сегодня будет продолжение темы Кластеризация и классификация больших Текстовых данных с помощью машинного обучения на Java. Данная статья является продолжен...
Два года назад я начал работать разработчиком ПО. Иногда я рассказывал своим коллегам о студенческом проекте, которым занимался на третьем курсе университета, и они восприняли его н...
Доброго времени суток, Хабр. Сегодня я решил представить вам перевод цикла статей для подготовки к Spring Professional Certification. Это перевод только первой статьи, если он зайдет аудитории,...