JHipster によるエンタープライズアプリケーションの構築(2) Spring Nativeで高速起動化しAWS Lambdaで動かす

カバー

[!] この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

前回JHipster とは何かについて紹介しました。今回は JHipster で自動生成した Gradle プロジェクトから AWS Lambda の関数を作成する方法を紹介します。
「使い慣れた Java 言語で Lambda 関数を作りたい」と考えており、以下のような課題を解決したい方の一助となれば幸いです。

  • AWS Lambda の関数を Java で作成したいが、JVM の起動に時間がかかるため Java を採用しにくい
  • AWS Lambda のアーキテクチャーに関わる実装や DB アクセス部品の実装、Gradle プロジェクトの作成などの定型的な作業を効率化したい

上述の課題を以下の方針で解決していきます。

Native イメージ化による高速起動

JVM の起動時間を短縮したり、コールドスタートを抑制する方法はいくつかありますが、この記事では JVM を使用せずに Native イメージ化することによって起動時間を高速化する方法を採用しています。
Lambda SnapStart の発表により JVM の起動時間の問題は解決しそうですが、Native イメージ化する方法には AWS Lambda 以外の環境でも使えるというメリットがあります。

Gradle プロジェクトのボイラープレート化

JHipster では JHipster Domain Language (JDL) という形式でデータモデルを定義することで、CRUD 操作用のソースコードや Liquibase の ChangeLog ファイルを自動生成することができます。
また、Blueprint を作成することで、自動生成する成果物をカスタマイズすることができます。
この記事では Blueprint の作成までは行っていませんが、自動生成可能なレベルまで成果物を明確にしています。
この記事の内容をもとに Blueprint を作成することで、CRUD 操作用の Lambda 関数をサンプルとしてビジネスロジックの実装に集中することができるようになります。

使用している主要なプロダクト

GraalVM

Javaコードを実行可能な Native バイナリファイルとして出力する機能を持った JVMです。
製品ページ

Spring Native

Spring Boot アプリケーションを Native イメージ化するためのプロジェクト1です。
GitHub

Spring Cloud Function と AWS Adapter

Spring Boot で Lambda 関数を作成するためのプロジェクトです。
Spring Cloud FunctionAWS Adapter

Spring Native for JHipster

JHipster で Spring Native を使用するための Blueprintです。 (ブログ記事GitHub

この Blueprint は JHipster が自動生成するプロジェクトを Spring Native で動かすためのものです。プロジェクトを自動生成する際に、必要となる Maven モジュールの依存関係、ソースコード、設定ファイル等を出力してくれます。
これを使用することで Spring Native の知識がほぼ無くても Entity を CRUD 操作する Web アプリケーションを Native イメージで動かすことができます2
加えて JHipster の機能により、Lambda 関数を作るための部品として以下のコードを自動生成することができます。

  • Spring Data JPA を使用した Entity クラス、Repository クラス
  • JPA Criteria API を使用した検索用の Query Service クラス
  • hibernate-jpamodelgen を使用して Entity クラスに対する Metamodel クラスを生成するための Gradle プロジェクト構成

アーキテクチャー

この記事で作るアーキテクチャーのゴールのイメージは以下のとおりです。

アーキテクチャー

改修の概要

改修の概要

Spring Native for JHipster で自動生成したプロジェクトに対し、以下の改修をします。

  • Lambda 関数では不要となる Web(REST API)系の機能(ソースコードや依存関係)を除外する
  • Spring Cloud Function と AWS Adapter を組み込む
  • Spring Cloud Function で公開するための関数型 Bean(Java のソースコード)を作成する

その他、Native イメージを動かすために必要となる改修があります。
詳細はこの後記載していきますが、この記事で採用した方式のポイントを 2 点説明します。

Native イメージは static にリンクにする

AWS Lambda のカスタムランタイムで使用されている Amazon Linux 2 の glibc のバージョンが古かったため、static にリンクすることにしました。
実行環境の OS やライブラリのバージョンに依存したくないという意図もあります。
glibc を static にリンクしてしまうと LGPL の制約が気になりますので、libc には GraalVM の公式ドキュメントでも紹介されている musl libc を使っています。

リフレクション情報を GraalVM に渡す方法

Native イメージを作成する場合、ビルド時に動作に必要となる全てのクラス・リソースファイルを特定する必要がありますが、リフレクションを使用する場合はクラスやリソースファイルの情報を GraalVM に渡す必要があります。
Spring Native を使用した場合、GraalVM にリクレクションの情報を渡す方法が 2 つあります。

  • GraalVM の JSON ファイル
  • Spring Native の Native Hints アノテーション

どちらの方法でも同じことができますが、後に Blueprint を作成することを考慮して以下の方針としています。

  • プロジェクトに変わらず共通のもの : Native Hints アノテーション
  • プロジェクトごとに可変のもの(具体的には JDL によって変わるもの) : GraalVM の JSON ファイル

開発環境

開発用OS : Ubuntu 22.04 LTS

apt で upgrade / update できる範囲で最新化し、build-essential パッケージを追加します。
必須ではありませんが、日本語化しました。

SDKMAN : 構築時の最新(バージョン不問)

GraalVM のインストール用です。(リンク

nodebrew : 構築時の最新(バージョン不問)

Node.js のインストール用です。(リンク

GraalVM Community : 22.3(Java17)

SDKMAN でインストールします。
Spring Native のドキュメント3には「GraalVM version 22.1.0 is supported」と書かれています。ですが動作に問題がないかぎり LTS の最新版を使うのが良いと思いますで、構築時の最新である 22.3 を使いました。

musl.cc : 10.2.1

構築時の最新である 11 系ではビルド時のエラーを解決できなかったため、10 系を使いました。
追加で zlib をインストールする必要があります。 GraalVM のドキュメント を参照。

Node.js : v16.18.1

nodebrew でインストールします。
構築時の最新 LTS である v18 系では JHipster が動作しないため、1 つ前の LTS である v16 系を使いました。

Spring Boot Native blueprint for JHipster : v1.3.2

先に記載しているため、ここでは説明を省きます。(リンク

AWS Lambda Runtime Interface Emulator (RIE): 構築時の最新(バージョン不問)

Native イメージの動作確認に使用します。Docker は使用せず、開発環境に直接インストールして使っています。(リンク


Java 関連の主な OSS バージョンは以下です。

Spring Native : 0.12.1

Spring Boot : 2.7.3

Spring Native のドキュメント3には「Spring Native 0.12.1 only supports Spring Boot 2.7.1, so change the version if necessary.」と書かれていますが、JHipster が使っているバージョンを使いました。

Spring Cloud Function (+ AWS Adapter) :3.2.8

Spring Native のドキュメント3には「Spring Native 0.12.1 has been tested against Spring Cloud 2021.0.3.」と書かれており、対応する Spring Cloud Function のバージョンは 3.2.5 なのですが、構築時の最新を使いました。


ビルドツールは Gradle です。
JHipster が生成した Gradle プロジェクトに Gradle Wrapper として含まれており、バージョンは 7.4.2 です。

データベースは PostgreSQL です。
バージョンは気にしませんが、AWS では Amazon RDS(PostgreSQL)を使うことを想定していますので、近いバージョンのものを使いました。

改修の手順

それでは以降、以下の手順に従って改修内容を説明していきます。

JHipster でプロジェクトを作成する

Spring Native for JHipster で プロジェクトを自動生成します。
適当なディレクトリ(ここでは jhipster-native-lambda )を作成し、 JHipster でプロジェクトを作成します。

$ mkdir jhipster-native-lambda
$ cd jhipster-native-lambda/
$ jhipster-native 
自動生成時のオプション(クリックして開く/閉じる
? Which *type* of application would you like to create? Monolithic application (recommended for simple projects)
? What is the base name of your application? jhipster
     info Using blueprint generator-jhipster-native for server subgenerator
? Do you want to make it reactive with Spring WebFlux? No
? What is your default Java package name? com.mycompany.myapp
? Which *type* of authentication would you like to use? HTTP Session Authentication (stateful, default Spring Security mechanism)
? Which *type* of database would you like to use? SQL (H2, PostgreSQL, MySQL, MariaDB, Oracle, MSSQL)
? Which *production* database would you like to use? PostgreSQL
? Which *development* database would you like to use? PostgreSQL
? Which cache do you want to use? (Spring cache abstraction) No cache - Warning, when using an SQL database, this will disable the Hibernate 2nd level cache!
? Would you like to use Maven or Gradle for building the backend? Gradle
? Do you want to use the JHipster Registry to configure, monitor and scale your application? No
? Which other technologies would you like to use? 
? Which *Framework* would you like to use for the client? No client
? Would you like to enable internationalization support? No
? Please choose the native language of the application Japanese
? Besides JUnit and Jest, which testing frameworks would you like to use? 
? Would you like to install other generators from the JHipster Marketplace? No

これで開発のベースとなる Gradle プロジェクトが生成されました。

Entity と Spring Data JPA Repository の生成

今回はサンプルとして以下のような JDL を作成します。
ファイルはプロジェクト直下に jhipster.jdl として保存します。

enum Gender {
        MASCULINE ("男性"),
        FEMININE ("女性"),
        NEUTER ("中性")
}

entity Organization {
        name String required minlength(1),
        description String
}

entity Employee {
        name String required minlength(1),
        gender Gender,
        dateOfBirth LocalDate
}

paginate Employee with pagination
filter Employee

relationship ManyToOne {
    Employee{organization} to Organization{employee}
}

自動生成を実行します。

$ jhipster-native jdl ./jhipster.jdl

途中でファイルがコンフリクトしているというメッセージが出ますが上書きで問題ありません。

conflict src/main/resources/config/liquibase/master.xml

これでプロジェクトに Entity を操作する以下の CRUD 機能が追加されました。

  • データベースのテーブルやシーケンスを作成するための Liquibase ChangeLog
  • Spring Data JPA を使用した Entity、Repository、及び、検索機能を実装した Service クラス

Native イメージのビルドと起動の確認

確認のために、この段階で Native イメージを作成します。
ビルドではテスト系が動かないところがあるのでいくつかのタスクをスキップします。

$ ./gradlew clean build -xgenerateTestAot -xintegrationTest

ビルドが正常終了したところで Native イメージを作るのですが、デフォルトでは dynamic リンクになっているため static リンクに修正します。

build.gradle の修正

自動生成されたものでは複数のパラメータを受け取れるようになっていなかったので、以下のように修正しました。

graalvmNative {
  binaries {
    main {
(略)
      buildArgs.add("${findProperty('nativeBuildArgs') ?: ''}")
 ↓
      "${findProperty('nativeBuildArgs') ?: ''}".split(" ").each {arg ->
        buildArgs.add(arg)
      }

gradle.properties の修正

static にリンクするようにプロパティを追加します。

nativeBuildArgs = --static --libc=musl

以上で static にリンクするための修正は完了です。

ビルドと実行

Native イメージをビルドします。

$ ./gradlew nativeCompile

ビルド時に以下のような Stack Trace が出力されますが無視して問題ありません。

ERROR io.netty.handler.ssl.BouncyCastleAlpnSslUtils - Unable to initialize BouncyCastleAlpnSslUtils.
java.lang.ClassNotFoundException: org.bouncycastle.jsse.BCSSLEngine
      at java.base/java.net.URLClassLoader.findClass(URLClassLoader.java:445)

ビルドが成功したら以下のコマンドで Native イメージを実行できます。

$ ./build/native/nativeCompile/native-executable

ですが、この状態では実行時にいくつかエラーが発生しますので対応します。

Type cannot be found エラーの対応

実行時に以下のようなエラーが発生します。

Caused by: org.springframework.expression.spel.SpelEvaluationException: EL1005E: Type cannot be found 'org.springdoc.core.Constants'

SpringDoc の Native 用依存関係を追加する必要があります。build.gradle の dependencies に以下を追加します。

dependencies {
    implementation 'org.springdoc:springdoc-openapi-native'
(略)

Resource not found エラーの対応

実行時に以下のようなエラーが発生します。

java.io.IOException: Resource not found: "org/joda/time/tz/data/ZoneInfoMap" ClassLoader: jdk.internal.loader.ClassLoaders$AppClassLoader@f5f2bb7

Joda-Time の リソースを Native イメージに含める必要があります。
com.mycompany.myapp.JhipsterApp4 クラスに、クラスのアノテーションとして以下を追記します。

@ResourceHint(patterns = {"org/joda/time/tz/data/.*"})

再度 Gradle の nativeCompile タスクを実行し、 native-executable 実行すると正常に起動するはずです。
ここまでで、JHipster の CRUD アプリケーションを Spring Native で起動することができました。

Spring Cloud Function の組み込み

プロジェクトに Spring Cloud Function を組み込んでいきます。

Maven モジュールのバージョン定義

gradle.properties に以下を追記し、各種バージョン情報を定義します。

springDependencyManagementVersion = 1.0.15.RELEASE
springCloudFunctionVersion = 3.2.8
awsLambdaEventsVersion = 3.11.0
awsLambdaCoreVersion = 1.2.2
liquibaseCoreVersion = 4.15.0

DependencyManagement プラグインの定義

settings.gradle のpluginManagement → plugins に以下を追加し、DependencyManagement プラグインを定義します。

pluginManagement {
    plugins {
        id 'io.spring.dependency-management' version "${springDependencyManagementVersion}"
(略)

build.gradle の修正

DependencyManagement プラグインを有効化するために、plugins に以下を追加します。

plugins {
    id 'io.spring.dependency-management'
(略)

AWS Lambda では HTTP のポートを使用しないため、Web系の依存関係を dependencies から削除(コメントアウト)します。

dependencies {
//    implementation "org.springdoc:springdoc-openapi-webmvc-core"
//    implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
//    implementation "org.springframework.boot:spring-boot-starter-web"
//    implementation "org.springframework.boot:spring-boot-starter-tomcat"
(略)

dependencies に必要な Maven モジュールを追加します。

dependencies {
    implementation 'org.springframework.cloud:spring-cloud-function-adapter-aws'
    implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8'
    compileOnly 'com.amazonaws:aws-lambda-java-events'
    compileOnly 'com.amazonaws:aws-lambda-java-core'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
(略)

dependencyManagement で、バージョンを固定するものを指定します。

dependencyManagement {
    dependencies {
        dependency "org.liquibase:liquibase-core:${liquibaseCoreVersion}"
        dependency "com.amazonaws:aws-lambda-java-events:${awsLambdaEventsVersion}"
        dependency "com.amazonaws:aws-lambda-java-core:${awsLambdaCoreVersion}"
        imports {
            mavenBom "org.springframework.cloud:spring-cloud-function-dependencies:${springCloudFunctionVersion}"
        }
    }
}

依存関係の削除でコンパイルが通らなくなったソースコードをビルド対象外にします。

sourceSets {
    main {
        java {
            exclude 'com/mycompany/myapp/config/DateTimeFormatConfiguration.java'
            exclude 'com/mycompany/myapp/config/LocaleConfiguration.java'
            exclude 'com/mycompany/myapp/config/SecurityConfiguration.java'
            exclude 'com/mycompany/myapp/config/WebConfigurer.java'
            exclude 'com/mycompany/myapp/security/PersistentTokenRememberMeServices.java'
            exclude 'com/mycompany/myapp/service/MailService.java'
            exclude 'com/mycompany/myapp/service/UserService.java'
            exclude 'com/mycompany/myapp/web/rest/**'
            exclude 'com/mycompany/myapp/ApplicationWebXml.java'
       }
   }
   test {
        java {
            exclude 'com/mycompany/myapp/config/WebConfigurerTest.java'
            exclude 'com/mycompany/myapp/service/UserServiceIT.java'
            exclude 'com/mycompany/myapp/web/rest/**'
        }
   }
}

以上で Spring Cloud Function の組み込みは完了です。

Spring Cloud Function で公開する関数を作成する

ここまでで、JHipster により、Service クラスとして、検索(ページネーションあり/なし)・更新・削除が作成されています。
ここからは、Service クラスを関数化していきます。一例として「ページネーションありの検索」の実装例を紹介します。

Spring Cloud Function で関数を公開する方法は大きく 2 種類あります。

  • Bean Factory 形式で Function (主に Java の Lambda 式)をリターンするようにする
  • Function 型クラスを Bean として定義する

前者のほうが記載量が少なく書くのは楽なのですが、今回は Lambda 関数 1 つに対して 1 トランザクションとするために後者を採用しています5

返却用のコンテナクラスを作成

ページネーションの処理は org.springframework.data.domain.Page というインターフェースを使用しています。
このインターフェースは java.util.Iterable を継承しています。
Spring Cloud Function の中に返却用のオブジェクトが Iterable の場合に Collection にキャストする処理があり、ClassCastException を回避できなかったので返却用にラッパークラスを作ることにしました。
これは今後のバージョンアップ等で不要になるかもしれません。

com.mycompany.myapp.data.domain.PagedContents インターフェース(クリックして開く/閉じる
package com.mycompany.myapp.data.domain;

import java.util.List;

public interface PagedContents<T> {
    int getNumber();
    int getSize();
    int getTotalPages();
    long getTotalElements();
    List<T> getContent();
}
com.mycompany.myapp.data.domain.PageWrappedPagedContents クラス(クリックして開く/閉じる
package com.mycompany.myapp.data.domain;

import java.util.List;
import org.springframework.data.domain.Page;

public class PageWrappedPagedContents<T> implements PagedContents<T> {
    private Page<T> wrapTargetPage;

    protected PageWrappedPagedContents(Page<T> wrapTargetPage) {
        this.wrapTargetPage = wrapTargetPage;
    }

    public static <T> PageWrappedPagedContents<T> wrap(Page<T> wrapTargetPage) {
        return new PageWrappedPagedContents<T>(wrapTargetPage);
    }

    @Override
    public int getTotalPages() {
        return this.wrapTargetPage.getTotalPages();
    }

    @Override
    public long getTotalElements() {
        return this.wrapTargetPage.getTotalElements();
    }

    @Override
    public List<T> getContent() {
        return this.wrapTargetPage.getContent();
    }

    @Override
    public int getNumber() {
        return this.wrapTargetPage.getNumber();
    }

    @Override
    public int getSize() {
        return this.wrapTargetPage.getSize();
    }
}

検索用の関数クラスを作成

Spring Cloud Function(AWS Adapter)では、AWS Lambda の「ハンドラー名」と一致する名前の関数 Bean が実行されます(以下では searchEmployees)。

com.mycompany.myapp.function.SearchEmployees クラス(クリックして開く/閉じる
package com.mycompany.myapp.function;

import java.io.Serializable;
import java.util.function.Function;

import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import com.mycompany.myapp.data.domain.PageWrappedPagedContents;
import com.mycompany.myapp.data.domain.PagedContents;
import com.mycompany.myapp.domain.Employee;
import com.mycompany.myapp.function.SearchEmployees.SearchParam;
import com.mycompany.myapp.service.EmployeeQueryService;
import com.mycompany.myapp.service.criteria.EmployeeCriteria;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Component("searchEmployees")
@Transactional
@Slf4j
@RequiredArgsConstructor
public class SearchEmployees implements Function<SearchParam, PagedContents<Employee>> {

    private final EmployeeQueryService employeeQueryService;

    @Override
    public PagedContents<Employee> apply(SearchParam param) {
        log.debug("Lambda request to get Employees by criteria: {}", param);
        return PageWrappedPagedContents.wrap(
                employeeQueryService.findByCriteria(param.getCriteria(),
                        param.getPageable() != null ? param.getPageable() : Pageable.unpaged()));
    }

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class SearchParam implements Serializable {
        private static final long serialVersionUID = 1L;

        private EmployeeCriteria criteria;
        private Pageable pageable;
    }
}

Service クラスを Spring Cloud Function の関数で公開するための修正は以上です。

Spring Cloud Function / Spring Native で動かすための修正

追加で JSON のデシリアライザーの作成や Native イメージで動かすための修正が必要になります。

JhipsterApp クラスの修正

JSON のデシリアライズ時にリフレクションでアクセスされるクラスに @TypeHint が必要になります。
JhipsterApp4 クラスにクラスアノテーションとして以下を追加します。

JHipster には、下記の 4 つ以外にも Filter クラスが用意されていますが、今回は使うものだけを記載しています。

@TypeHint(types = {
                DateFormatter.class,
                LongFilter.class,
                StringFilter.class,
                LocalDateFilter.class
}, access = {
                TypeAccess.PUBLIC_METHODS,
                TypeAccess.PUBLIC_CONSTRUCTORS
})

PageableDeserializer クラスの作成

Spring Data JPA の Pageable インターフェースはデフォルトでは JSON にデシリアライズができなかったため、簡単なデシリアライザーを作成しました。

com.mycompany.myapp.jackson.databind.deser.PageableDeserializer クラス(クリックして開く/閉じる
package com.mycompany.myapp.jackson.databind.deser;

import java.io.IOException;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.data.domain.Sort.NullHandling;
import org.springframework.data.domain.Sort.Order;

import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class PageableDeserializer extends StdDeserializer<Pageable> {

    private int defaultPageSize = 20;

    public PageableDeserializer() {
        this(Pageable.class);
    }

    protected PageableDeserializer(Class<?> vc) {
        super(vc);
    }

    public void setDefaultPageSize(int defaultPageSize) {
        this.defaultPageSize = defaultPageSize;
    }

    @Override
    public Pageable deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException {
        log.debug("default-pagesize:" + defaultPageSize);
        try {
            JsonNode node = p.getCodec().readTree(p);
            int pageSize = defaultPageSize;
            if (node.has("pageSize")) {
                pageSize = node.get("pageSize").asInt();
            }
            int pageNumber = 0;
            if (node.has("pageNumber")) {
                pageNumber = node.get("pageNumber").asInt();
            }

            Sort sort = null;
            if (node.has("sort")) {
                JsonNode sortNode = node.get("sort");
                if (sortNode.has("orders")) {
                    JsonNode ordersNode = sortNode.get("orders");
                    sort = Sort.by(
                            StreamSupport
                                    .stream(ordersNode.spliterator(), false)
                                    .map(e -> {
                                        Order o = null;
                                        if (e.has("property")) {
                                            o = Order.by(e.get("property").asText());
                                            o = e.has("direction")
                                                    ? o.with(Direction.valueOf(e.get("direction").asText()))
                                                    : o;
                                            o = e.has("ignoreCase") && e.get("ignoreCase").asBoolean() ? o.ignoreCase()
                                                    : o;
                                            o = e.has("nullHandling")
                                                    ? o.with(NullHandling.valueOf(e.get("nullHandling").asText()))
                                                    : o;
                                        }
                                        return o;
                                    })
                                    .filter(Objects::nonNull)
                                    .collect(Collectors.toList()));
                }
            }

            Pageable ret = sort != null ? PageRequest.of(pageNumber, pageSize, sort)
                    : PageRequest.of(pageNumber, pageSize);
            return ret;
        } catch (Exception e) {
            log.error("Pageable deserialize error.", e);
        }
        return Pageable.unpaged();
    }
}

FunctionConfiguration クラスの作成

作成したデシリアライザーを Spring で管理している Jackson に追加する設定です。

com.mycompany.myapp.config.FunctionConfiguration クラス(クリックして開く/閉じる
package com.mycompany.myapp.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.Pageable;

import com.fasterxml.jackson.databind.module.SimpleModule;
import com.mycompany.myapp.jackson.databind.deser.PageableDeserializer;

@Configuration
public class FunctionConfiguration {

    @Value("${spring.data.web.pageable.default-page-size:20}")
    private int defaultPageSize;

    @Bean
    public SimpleModule pageableDeserializerModule() {
        SimpleModule module = new SimpleModule() {
            {
                PageableDeserializer pageableDeserializer = new PageableDeserializer();
                pageableDeserializer.setDefaultPageSize(defaultPageSize);
                this.addDeserializer(Pageable.class, pageableDeserializer);
            }

            @Override
            public void setupModule(SetupContext context) {
                super.setupModule(context);
            }
        };
        return module;
    }
}

reflect-config.json の作成

JPA の Criteria API で使用するクラスはリフレクションでアクセスされるため、コンパイラへ情報を渡す必要があります。
ここでは

  • src/main/resources/META-INF/native-image/com.mycompany.myapp/reflect-config.json

を作成し、リフレクションの情報を記載します。
下記でクラス名の最後に「_」が付与されるものは hibernate-jpamodelgen によって自動生成されるクラスです。

[
    {
        "name": "com.mycompany.myapp.domain.Employee_",
        "allPublicFields": true
    },
    {
        "name": "com.mycompany.myapp.domain.Organization_",
        "allPublicFields": true
    },
    {
        "name": "com.mycompany.myapp.service.criteria.EmployeeCriteria",
        "allPublicMethods": true,
        "methods": [
            {
                "name": "<init>",
                "parameterTypes": []
            }
        ]
    },
    {
        "name": "com.mycompany.myapp.service.criteria.EmployeeCriteria$GenderFilter",
        "allPublicMethods": true,
        "methods": [
            {
                "name": "<init>",
                "parameterTypes": []
            }
        ]
    }
]

Spring Native のプロファイルに関する注意点とソースコードの修正

Native イメージを作成する場合、プロファイルはコンパル時に固定になってしまいます。
Spring Native のドキュメント3には「使うのは避けるべき」とありますが、JHipster はプロファイルを使用しているため、ビルド時に「-Pprod」「-Pdev」等のオプションを付けることで対応します。

1 つだけ修正が必要なクラスがあります。
Spring Boot 起動時に実行される Liquibase のデータベース更新処理です。

  • com.mycompany.myapp.config.LiquibaseConfiguration

というクラスが JHipster によって自動生成されますが、その中に

        // If you don't want Liquibase to start asynchronously, substitute by this:
        // SpringLiquibase liquibase = SpringLiquibaseUtil.createSpringLiquibase(liquibaseDataSource.getIfAvailable(), liquibaseProperties, dataSource.getIfUnique(), dataSourceProperties);
        SpringLiquibase liquibase = SpringLiquibaseUtil.createAsyncSpringLiquibase(
            this.env,
            executor,
            liquibaseDataSource.getIfAvailable(),
            liquibaseProperties,
            dataSource.getIfUnique(),
            dataSourceProperties
        );

という箇所があります。
この先にプロファイルによって Liquiabse の処理を同期・非同期で実行する分岐があり、非同期で実行されると DDL の更新と AWS Lambda のイベント処理が並行で動いてしまうことがあります。
プロダクションでも開発でも Liquibase の処理を非同期で動かす必要はないと思いますので、LiquibaseConfiguration のソースコードをコメントに従って以下のように修正します。

        // If you don't want Liquibase to start asynchronously, substitute by this:
        SpringLiquibase liquibase = SpringLiquibaseUtil.createSpringLiquibase(liquibaseDataSource.getIfAvailable(), liquibaseProperties, dataSource.getIfUnique(), dataSourceProperties);
        /* SpringLiquibase liquibase = SpringLiquibaseUtil.createAsyncSpringLiquibase(
            this.env,
            executor,
            liquibaseDataSource.getIfAvailable(),
            liquibaseProperties,
            dataSource.getIfUnique(),
            dataSourceProperties
        ); */

Spring Native で動かすための修正は以上になります。

Native イメージのビルドは例えば dev プロファイルにしたい場合は以下のようにします。

$ ./gradlew -Pdev nativeCompile

ローカルでの動作確認

Native イメージのビルドが完了したら、RIE を使用して動作確認します。

以下のコマンドで RIE を起動します。

$ export SPRING_DATASOURCE_URL={Spring Data Source の JDBC URL}
$ export SPRING_DATASOURCE_USERNAME={データベースのユーザID}
$ export SPRING_DATASOURCE_PASSWORD={データベースのパスワード}
$ export _HANDLER=searchEmployees
$ $HOME/.aws-lambda-rie/aws-lambda-rie {プロジェクトの絶対パス}/build/native/nativeCompile/native-executable

別のターミナルから curl 等で Lambda 関数へのメッセージを送信します。

$ curl -XPOST "http://localhost:8080/2015-03-31/functions/function/invocations" -d @- <<EOF
{
  "criteria": {
      "id": {
          "greaterThan": 0
      }
  },
  "pageable": {
      "pageNumber": 0,
      "sort": {
          "orders": [
              {
                  "property": "id",
                  "direction": "DESC"
              }
          ]
      }
  }
}
EOF

正常にビルドできていれば検索結果の JSON が返信されます。

AWS Lambda へのデプロイ

AWS Lambda へのデプロイ方法はこの記事の本題ではないので詳細は省きますが、ポイントを記載します。

AWS Lambda ランタイムの選択

生成された Native イメージは AWS Lambda サービスへのイベントループを自身で行うアプリケーションです。
そのため、カスタムランタイムを想定していますが、コンテナイメージでも実行可能です。
JHipster で生成した Gradle プロジェクトには Jib でコンテナイメージを作成する機能が含まれていますので、軽微な修正でコンテナイメージを Amazon Elastic Container Registry(ECR)に登録することができます。

データベース接続

Spring Boot の環境変数は Native イメージでも利用可能です。
データベースにあわせて接続先の環境変数を設定します。

SPRING_DATASOURCE_URL
SPRING_DATASOURCE_USERNAME
SPRING_DATASOURCE_PASSWORD

自動生成された設定では、データソースに HikariDataSource が設定されています。
ユースケースによって異なりますが、プールサイズを 1 にするか、コネクションプールを使用せずに DriverManagerDataSource 等を使用するかのどちらかになると思います。

ハンドラー名

Spring Cloud Function(AWS Adapter)では、AWS Lambda の「ハンドラー名」と一致する名前の関数 Bean を実行します。
今回のサンプルの場合はハンドラー名を「searchEmployees」とします。

メモリ

メモリが少ないと動作が重くなり、場合によってはエラー(リトライ)になります。検証時はメモリを 512 MB に設定しました。

JVM で動かした場合と Native イメージで動かした場合の比較

手作業で数回実施の平均値ですが、以下の結果となりました。

( JVM → Native )

  • 初期化 : 8 秒 → 2 秒
  • 実行(コールド) : 1180 ミリ秒 → 80 ミリ秒
  • 実行(ウォーム) : 70 ミリ秒 → 20 ミリ秒
  • メモリ使用量 : 295 MB → 275 MB

メモリ使用量は大差ありませんでしたが、実行時間は全体的に改善されました。
Lambda 関数の実行時間は最大 15 分までの間で指定できますが、初期化時間は最大 10 秒固定であり、初期化処理で 10 秒を超えるとリトライされてしまいます。
今回は特にチューニングをしていない状態ではありますが、JVM の初期化にかかる 8 秒という時間は制限の 10 秒を考慮すると特に厳しい値ですので、Native イメージ化しない場合はなんらかの改善が必要になると思われます。

最後に

GraalVM が登場してからも Java の Native 化は(主に精神的に)ハードルが高かったと思うのですが、Spring Framework が正式に取り込んだことでハードルは下がったと思います。
Spring Framework 6 / Spring Boot 3 での Native 化対応は Spring Native からの変更点もあるはずですので、新しいバージョンでも試していきたいと思います。

JHipster 関連記事

Footnotes

  1. Spring Native は Spring Projects Experimental として実験的に開発されてきましたが、2022/11 に正式公開された Spring Framework 6 / Spring Boot 3 に統合されました。
    その後 Spring Native プロジェクトは停止されましたが、Spring Framework の Native イメージ化が継続して公式にサポートされる見通しとなりました。JHipster はまだ Spring Framework 6 / Spring Boot 3 には対応していないため、この記事で使用している Spring Framework のバージョンは若干古いものになります。

  2. Spring Native for JHipster では自動生成された画面機能を Native イメージで動かすことができますが、使う機能によっては不足しているものもあります。そのため、この記事で紹介するような修正が必要になることがあります。

  3. Spring Native のドキュメントは現在 HTML ページが Spring Boot のページにリダイレクトされてしまっており、PDFのみが参照可能です。 2 3 4

  4. com.mycompany.myapp.JhipsterApp クラスは自動生成された Spring Boot のメインクラスです。JHipster のプロジェクト名からパッケージ名とクラス名が決まります。 2

  5. 今回作成する関数にはビジネスロジックが無い(自動生成された Query Service クラスのメソッドを 1 つ実行するだけ)ため、関数のアノテーションとして @Transactional を付与したいのですが、Java の Lambda 式にはアノテーションを付与できないためです。
    ビジネスロジックが入る関数を作成する場合は Service 層のメソッドでトランザクション制御を行うように作成し、それを Java の Lambda 式で公開するほうが楽になると思います。


TOP
アルファロゴ 株式会社アルファシステムズは、ITサービス事業を展開しています。このブログでは、技術的な取り組みを紹介しています。X(旧Twitter)で更新通知をしています。