Quarkus (+GraalVM) でネイティブコンパイルしたJavaアプリをAWS Lambdaにデプロイした

Quarkus 1.0のリリースが見えてきた!🎉

quarkus.io

 

AWS Lambdaのカスタムランタイム用にnative-imageでバイナリを生成するための拡張機能の使い方がようやくわかったのでまとめておく。

 

 

[Quarkusとは]

Quarkusは、高速で軽量なJavaであると公式サイトの説明にある。つまり、Kubernetesやサーバレス環境において、Javaで書かれたアプリケーションの起動を高速化することが目的の一つだ。

quarkus.io

 

Red Hat社からQuarkusが発表されたのは2019年3月のことで、いくつか日本語でも記事が出ていた。 yoshio3.com

www.publickey1.jp

 

[カスタムランタイム自前実装の取り組み]

サーバレス環境と言われて、まず思いだすのがAWS Lambdaである。

 

通常、AWS Lambdaで関数を実装する場合には、公式に用意されているJava8、Python、Node.jsなどの言語を使うことになる。

しかし、昨年2018年年末のAWS re:Inventというイベントで、カスタムランタイムという任意の言語でAWS Lambda関数を実装することができる機能追加が発表された。

aws.amazon.com

 

Quarkusの発表を聞いてすぐに、このカスタムランタイムとの組み合わせてJavaで書かれた関数を高速起動できないものか、と色々試していたのだが、なかなかうまく行かなかった。

kdnakt.hatenablog.com

 

[Quarkusのエクステンション]

Quarkusにはエクステンション(拡張機能)が存在し、Quarkusを利用したアプリケーションの実装を助けてくれる。

一例として、以下のようなエクステンションがある。

  • quarkus-rest-client:REST APIとの連携用
  • quarkus-amazon-dynamodb:Amazon DynamoDBとの連携用
  • quarkus-jdbc-mysqlMySQLとの連携用
  • quarkus-kotlin:Kotlinでの実装用
  • quarkus-keycloak-authorization:Keycloakとの連携用
  • quarkus-tika:Apache Tikaとの連携用

 

他にも利用可能なエクステンションはたくさんあり、一覧は以下のページにある。

Quarkus - Start coding with code.quarkus.io

quarkus/extensions at master · quarkusio/quarkus · GitHub

 

[AWS LambdaへQuarkusをデプロイ]

数あるエクステンションの中で、今回利用したのはamazon-lambdaエクステンションである。 公開されている最初期のバージョンでも同名のものが存在していたが、公式サイトでも説明がなく使い方が分からないまま途方に暮れていた。

しかし今回Quarkus 1.0のリリースが近いこともあってかドキュメントが整備されたおかげで、ようやく念願のGraalVMでコンパイルしたnative-imageをAWS Lambda上で動作させることができた。

 

動作確認に利用した環境情報は以下の通り。

$ uname -a
Darwin kdnakt.local 18.7.0 Darwin Kernel Version 18.7.0: Tue Aug 20 16:57:14 PDT 2019; root:xnu-4903.271.2~2/RELEASE_X86_64 x86_64
$ java -version
openjdk version "11.0.2" 2019-01-15
OpenJDK Runtime Environment 18.9 (build 11.0.2+9)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.2+9, mixed mode)

なお、GraalVMはgraalvm-ce-19.2.1を、Quarkusは1.0.0.CR1を利用した。

 

ドキュメントにしたがってMavenプロジェクトを作成する。途中でgroupIdやartifactIdの入力を求められるので、適当に入力する。

$ mvn archetype:generate \
  -DarchetypeGroupId=io.quarkus \
  -DarchetypeArtifactId=quarkus-amazon-lambda-archetype \
  -DarchetypeVersion=1.0.0.CR1
[INFO] Scanning for projects...
[INFO] 
[INFO] ------------------< org.apache.maven:standalone-pom >-------------------
[INFO] Building Maven Stub Project (No POM) 1
[INFO] --------------------------------[ pom ]---------------------------------
[INFO] 
[INFO] >>> maven-archetype-plugin:3.1.2:generate (default-cli) > generate-sources @ standalone-pom >>>
[INFO] 
[INFO] <<< maven-archetype-plugin:3.1.2:generate (default-cli) < generate-sources @ standalone-pom <<<
[INFO] 
[INFO] 
[INFO] --- maven-archetype-plugin:3.1.2:generate (default-cli) @ standalone-pom ---
[INFO] Generating project in Interactive mode
[INFO] Archetype repository not defined. Using the one from [io.quarkus:quarkus-amazon-lambda-archetype:0.28.0] found in catalog remote
Define value for property 'groupId': com.kdnakt
Define value for property 'artifactId': quarkus-lambda-sample
Define value for property 'version' 1.0-SNAPSHOT: : 
Define value for property 'package' com.kdnakt: : 
Confirm properties configuration:
groupId: com.kdnakt
artifactId: quarkus-lambda-sample
version: 1.0-SNAPSHOT
package: com.kdnakt
 Y: : 
[INFO] ----------------------------------------------------------------------------
[INFO] Using following parameters for creating project from Archetype: quarkus-amazon-lambda-archetype:1.0.0.CR1
[INFO] ----------------------------------------------------------------------------
[INFO] Parameter: groupId, Value: com.kdnakt
[INFO] Parameter: artifactId, Value: quarkus-lambda-sample
[INFO] Parameter: version, Value: 1.0-SNAPSHOT
[INFO] Parameter: package, Value: com.kdnakt
[INFO] Parameter: packageInPathFormat, Value: com/kdnakt
[INFO] Parameter: package, Value: com.kdnakt
[INFO] Parameter: groupId, Value: com.kdnakt
[INFO] Parameter: artifactId, Value: quarkus-lambda-sample
[INFO] Parameter: version, Value: 1.0-SNAPSHOT
[INFO] Project created from Archetype in dir: /Users/akito/Develop/kdnakt/quarkus-lambda-sample
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  18.720 s
[INFO] Finished at: 2019-11-09T01:17:16+09:00
[INFO] ------------------------------------------------------------------------

 

こうして作成したプロジェクトは以下のような構成になっている。

github.com

$ tree
.
├── create-native.sh
├── create.sh
├── delete-native.sh
├── delete.sh
├── invoke-native.sh
├── invoke.sh
├── payload.json
├── pom.xml
├── src
│   ├── assembly
│   │   └── zip.xml
│   ├── main
│   │   ├── java
│   │   │   └── com
│   │   │       └── kdnakt
│   │   │           ├── InputObject.java
│   │   │           ├── OutputObject.java
│   │   │           ├── ProcessingService.java
│   │   │           ├── TestLambda.java
│   │   │           └── UnusedLambda.java
│   │   └── resources
│   │       └── application.properties
│   └── test
│       └── java
│           └── com
│               └── kdnakt
├── update-native.sh
└── update.sh

TestLambdaとUnusedLambdaという2つのハンドラが作成され、application.propertiesの設定によってどちらのハンドラを利用するか選択することができる。デフォルトのTestLambdaというハンドラは、

{
  "name": "Bill",
  "greeting": "hello"
}

というリクエストを受け付けて、

{"result":"hello Bill","requestId":"78d8778c-da5d-41d5-bb5a-1a3f1f6a8595"}

というレスポンスを返すだけのシンプルな実装だ(requestIdの値は毎回変わる)。

 

AWS Lambda関数を作成する前に、IAMロールを作成しておく必要がある。デフォルトで選択されているTestLambdaというハンドラ自体は他のAWSリソースを利用しないので、AWS管理ポリシーである「AWSLambdaBasicExecutionRole*1」を設定してあれば良い。

ポリシーの内容は以下の通り。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        }
    ]
}

 

作成したIAMロールのARNを控えておき、create.shとcreate-native.shの2つのファイル内に記載されているデフォルトのARN「arn:aws:iam::1234567:role/lambda-cli-role」を作成したARNで置き換える必要がある。

 

通常のJava 8ランタイムでAWS Lambda関数を作成する場合には、mvn clean installのコマンドを実行したのち、bash ./create.shを実行すると、my-functionという名前でLambda関数が作成される。Lambda関数を呼び出すにはbash ./invoke.shのコマンドを呼び出せば良い。同じディレクトリにoutというファイル名でレスポンスの内容が出力される。リクエストを変更したい場合にはpayload.jsonの内容を編集すれば良い。

 

ここからが本題。

カスタムランタイムを利用して、native-imageで作成したバイナリを用いてLambda関数を作成する。

Linux環境であればmvn clean install -Dnativeというコマンドを実行する。Mac(やWindows)でビルドする場合には、カスタムランタイムの実行環境であるAmazon Linuxに合わせるためmvn clean install -Dnative -Dquarkus.native.container-build=trueとコンテナビルド用のオプションを設定してコマンドを実行する必要がある。

ともあれ、コマンドを実行するとtargetディレクトリに成果物が出力される。3月にカスタムランタイムを自分で実装していた際には、ハンドラを呼び出す部分のシェルを自前で作成していたが、今回自動的に作成されたfunction.zipを解凍してみると、バイナリファイルが1つ入っているだけで、シェルスクリプトの実装はなかった。おそらくそのほうが起動も速いのだろう。

function.zipをデプロイしたLambda関数を作成するには、bash ./create-native.shを実行する。この関数呼び出すにはbash ./invoke-native,shコマンドを実行すれば良い。得られる結果についてはJava8ランタイムのものと同じなので割愛。

 

[native-imageの実行速度]

最後に、AWS Lambda関数を呼び出した際の実行速度についてまとめておく。

 

Java8ランタイムで連続して5回関数を呼び出した結果は以下の通りだった。

  • 1回目:233.19 ms
  • 2回目:0.75 ms
  • 3回目:0.70 ms
  • 4回目:0.58 ms
  • 5回目:0.60 ms

 

また、ネイティブバイナリをデプロイしたカスタムランタイムの結果は以下の通り。

  • 1回目:1.22 ms
  • 2回目:1.12 ms
  • 3回目:0.97 ms
  • 4回目:0.91 ms
  • 5回目:1.04 ms

 

カスタムランタイムはコールドスタートの影響をあまり受けず、初回から爆速なのは嬉しい。使いどころを誤らなければ、非常に有用に思える。一方、Java8ランタイムは初回起動時にはやはりコールドスタートのため時間がかかるものの、2回目以降は十分に速いことがわかる。この辺りの問題については、native-imageの技術基盤となっているGraalVMについてすでに指摘されている通り。

nowokay.hatenablog.com

 

また、いずれの場合も、コールドスタートの影響を受けない場合の実行時間は1ms程度なので、100ms単位で課金されるAWS Lambdaの料金体系との付き合い方をよく考える必要がありそうだ*2

 

[まとめ]

  • Quarkusはサーバレス、Kubernetes向けのJavaフレームワーク
  • Quarkusエクステンションを使うと各種ライブラリとの連携や各JVM言語での実装が容易である
  • amazon-lambdaエクステンションを利用して念願のネイティブバイナリをLambda関数上で動かすことができた 

 

コンテナビルドのオプションをつけ忘れてissueを立ててしまったのはナイショ。ごめんなさい🙇‍♂️

github.com

*1:ポリシーなのにRoleという名前がついている。ややこしい。ARNはarn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

*2:他のAWSサービスとの連携時にはAPI通信でもう少し時間がかかるので、100msを使い切る感じにできるかもしれない。いずれ試してみたい。