Java
AWS
lambda
graalvm
Micronaut
JavaDay 8

GraalVM の native image を使って Java で爆速 Lambda の夢を見る


はじめに

前日は mike_neck さんの AWS Lambda のカスタムランタイムにて Java のカスタムランタイムで関数を動かす でした。

偶然にも(?)今日も引き続き、 Lambda Custom Runtime で Java を動かす話です。

AWS Lambda 提供する言語の一つに Java があります。

Java はホットスタートの処理速度は速いもの、コールドスタートでは 5 から 10 秒ほど要することがあります。

また、メモリ消費量も多く Lambda と Java の組み合わせは速度重視の場面では使われていないように思います。

2018年の re:invent で、 AWS Lambda の Custom Runtime が発表されました。

お作法に従いさえすればどのような言語でも Lambda として実行可能になりました。

さらに近年 Java 界隈では新しい JIT コンパイラの Graal と Graal やその他周辺機能を盛り込んだ JVM である GraalVM が登場しました。

GraalVM の機能の一つにバイナリの生成機能があり、これを使えば JVM 起動の処理時間を短縮できます。

そのため、GraalVM でバイナリ化した Java プログラムを Lambda の Custom Runtime で動かせば、

爆速の Lambda を Java でできそうだと感じました。

実際にやってみたところ爆速になったので、その手順と結果を記述します。

今回、バイナリを作るにあたって、 Micronaut というフレームワークを使っていますので、

Micronaut に関する説明も一緒に行います。

説明する内容は次の通りです。


  • Micronaut で Java の Lambda 関数を作る

  • AWS Lambda Custom Runtime の説明

  • Micronaut CLI アプリケーションを GraalVM で ネイティブにする

  • バイナリを Custom Runtime で動かす


Micronaut で Java の Lambda 関数を作る

Micronaut は今年 GA したマイクロサービス向けの Java・Kotlin・Groovy で書けるフレームワークです。

一言で言えば、今の技術でゼロから作った Spring Boot です。

以下の特徴があります。


  • annotation processor でコンパイル時に DI コードを生成するためコンポーネントスキャンが無く、起動が早く省メモリ

  • MVC による Webアプリ、 FaaS 型アプリ、 CLI アプリなど様々な形式のアプリケーションが作れる

  • マイクロサービスや 各種クラウド向けの機能や設定が最初からある

  • 実験的に GraalVM の native image サポートがある

  • 分かりやすいドキュメント

Spring, Spring Boot にも同様の機能はあったりしますが、それらの機能をデフォルトで備えています。

特に GraalVM に関するサポートが入っているのが GraalVM 初心者にはありがたかったので、今回の記事で採用しています。

では、あとでネイティブ実行とのパフォーマンスを比較するため、 Micronaut で作った Function を AWS Lambda にデプロイしてみます。

詳細な手順は http://guides.micronaut.io/micronaut-function-aws-lambda/guide/index.html にあります。

上記を参考に作ったサンプルは https://github.com/kencharos/micornaut-lambda-sample にあります。

Function の実装内容は、 {"v1":1, "v2":2} という JSON を受け取ったら、 v1 + v2 を計算して、 {"answer": 3} というJSONを返す単純なものです。

これを実装した関数は次の通りです。

import io.micronaut.function.FunctionBean;

import java.util.function.Function;

@FunctionBean("calc")
public class CalcFunction implements Function<SampleRequest, SampleResponse> {

/**
* SampleRequest は v1, v2 を持ち、 SampleResponse は answer を持つ。
*/

@Override
public SampleResponse apply(SampleRequest req) {

SampleResponse res = new SampleResponse();
res.setAnswer(req.getV1() + req.getV2());
return res;
}
}

Micronaut では、 Function や Provider などを実装したクラスに @FunctionBean を付与することで、

Lambda の関数としたり、あるいはスタンドアローンの Web アプリのエンドポイントにすることができます。

(今回は割愛しますが、 Spring MVC ライクに @Controler@Get のようなアノテーションを使った Web アプリケーションスタイルでの開発もできます。 )

ソースができたら、 ./gradlew shadowJar で FatJar を作成し、 AWS Lambda に Java8 ランタイムを指定して jar ファイルをデプロイします。

ハンドラ名にはガイドにある通り、 io.micronaut.function.aws.MicronautRequestStreamHandler を指定します。

なお、 FatJar のファイルサイズは約 11MB 程度でした。

さて、上記の Lambda をメモリを変えて実行した結果は次の通りでした。

なお、コールドスタートはデプロイ直後のテスト実行、ホットスタートはその後にさらに何度か実行した後の結果です。

メモリ
コールドスタードの処理時間
ホットスタートの処理時間
メモリ消費量

128MB
26秒後に OutOfMemoryError
-
-

256MB
13.5秒
500 ミリ秒
90 MB

512MB
6.6 秒
120 ミリ秒
90 MB

Lambda は確保したメモリサイズで CPU の処理能力が変わります。

128 MB の状態では、 Metaspace が足りず OutOfMemoryError になりました。

512 MB のホットスタートでやっと使い物になる感じです。

コールドスタートはだいぶ遅いです。

それでもメモリ消費量が 100 MB を切る Micronaut は結構すごいです。

さて、早速 GraalVM でネイティブを作りたいところですが、

上記の Function スタイルのアプリケーションをそのまま Custom Runtime には適用できないので、Custom Runtime のお作法を次に説明します。


AWS Custom Runtime のお作法

Custom Runtime を調べるにあたり、エムスリーさんの https://www.m3tech.blog/entry/aws-lambda-custom-runtime の記事には大変お世話になりました。謹んでお礼申し上げます。

Custom Runtime をシェルで作る公式チュートリアルは https://www.m3tech.blog/entry/aws-lambda-custom-runtime にあります。

Custom Runtime のお作法は次の通りです。


  1. 実行可能な bootstrap というファイルがあること

  2. bootstrap にはイベントループという無限ループを実装すること

  3. イベントループ一回の処理で次の処理を行う


    1. イベントデータを /2018-06-01/runtime/invocation/next エンドポイントから GET する。


      • Lambda への入力データと、 コンテキスト(リクエストIDなど)はここから取得できる



    2. 何らかの処理を実行し関数の結果を生成する

    3. 関数の結果を /2018-06-01/runtime/invocation/<リクエストID>/response に POST する


      • エラーにしたい場合は、 /2018-06-01/runtime/invocation/<リクエストID>/error に POST する





このお作法さえ守れば、どんな方法で実現しても OK です。

そのため、実現方法は次の2通りが考えれます。


bootstrap シェル + バイナリ

チュートリアルにある通り、 bootstrap をシェルで実装し HTTP リクエストは cURL で行います。

上記 3.2 の関数実行を 別の シェルやスクリプト、バイナリなど別の実行可能ファイルにすることができます。

この方法の場合、 3.2 に相当する部分は単純なプログラム引数から何らかの結果を返すようなプログラムを実装すればよく、ここに GraalVM でネイティブ化したバイナリを設定できそうです。


bootstarp バイナリ

別の方法として、 bootstrap にイベントループと関数の処理全部を行うバイナリを設定する方法もあります。

実際、 C++ や RUST の Custom Runtime はこの方法で実現されています。イベントループに相当する部分をライブラリとして提供されていて、ライブラリと自作関数をまとめて bootstrap ファイル

にビルドできます。

GraalVM でもbootstarp バイナリの方式を取ることができますが、その場合 HTTP 通信を含むことになりますし、動作確認が面倒です。

まずは前者の シェル + バイナリ の方法で実現することにしました。


Micronaut CLI アプリケーションを GraalVM でネイティブ化する

ここからが本番です。

まずは、バイナリファイルの入出力を考えてみます。

bootstrap シェルで、 curl で Lambda のエンドポイントを叩くので、

バイナリにどうやって入力を与え、バイナリから結果の出力をどのように受け取るかを考えます。

今回は入力はプログラム引数で、出力は所定のファイルに結果を書き込むという方針にしました。

結果を標準出力に出す方法もありますが、ログ出力の兼ね合いもあるのでよほど簡単でなければ避けたほうがよいでしょう。


bootstrap シェル の作成

上記を踏まえ、まずは bootstrap シェルを作ってみます。

公式のチュートリアルの内容をベースに修正したものです。

#!/bin/sh

set -euo pipefail
# EXEC はバイナリファイルのパス。
# $LAMBDA_TASK_ROOT はデプロイしたファイルの格納場所
# $_HANDLER は、イベントハンドラ名でここではバイナリファイル名を想定
EXEC=$LAMBDA_TASK_ROOT/$_HANDLER

# イベントループ
while true
do
 # レスポンスヘッダを保存するファイル
HEADERS="$(mktemp)"
# イベントの入力データの取得。レスポンスボディが入力データ。
# ここでは入力データは、 {"v1":1, "v2":2 } のような JSON文字列
EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
# Lambda のコンテキストに関するデータはレスポンスヘッダにあるので、レスポンスヘッダを保存したファイルから リクエストIDを抽出
REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)

# 成功結果、エラー結果を書き込む一時ファイルを作成
RESPONSE_FILE="$(mktemp)"
ERROR_FILE="$(mktemp)"
# バイナリ実行。
## -r リクエストID
## -d イベントデータ。JSON文字列
## -o 成功結果を書き込むファイルパス。
## -e エラー結果を書き込むファイルパス。
$EXEC -r "$REQUEST_ID" -d "$EVENT_DATA" -o $RESPONSE_FILE -e $ERROR_FILE

# 成功結果ファイルに内容があれば、その内容を responseエンドポイントに POSTし、そうでないならエラー結果ファイルの内容を erorエンドポイントにPOST
if [ -s $RESPONSE_FILE ]; then
RESPONSE=$(cat $RESPONSE_FILE)
curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" -d "$RESPONSE"
else
RESPONSE=$(cat $ERROR_FILE)
curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/error" -d "$RESPONSE"
fi
done

ポイントは、 $EXEC -r "$REQUEST_ID" -d "$EVENT_DATA" -o $RESPONSE_FILE -e $ERROR_FILE の部分でバイナリの引数に、リクエストID, イベントデータのJSON、成功・エラー結果を書き込むファイルのパスを渡しています。

では、この引数から処理を実行する Micronaut アプリケーションを作ります。


Micronaut CLI アプリケーションを作る

Micronaut は CLI アプリケーションも作れます。

普通に main メソッドを持つプログラムを作ってもよいのですが、 Micornaut なら CLI でも DI ができたり、設定ファイルと環境変数を合成できたり、HTTP クライアントや JSON 変換などの機能もあるので何かと便利です。

後で GraalVM でバイナリ化することも考え、以下のガイドを参考に GraalVM 用のひな形を作って作業を始めます。

https://docs.micronaut.io/latest/guide/index.html#graalServices

mn create-app my-graal --features graal-native-image コマンドで graal-native-image を有効にしてひな形をつくると、 GraalVM の Docker イメージで native-image コマンドを実行してバイナリを作る Dockerfile を作成してくれるほか、 Netty などのライブラリをバイナリ化するための設定などを盛り込んでくれます。

これをベースに https://docs.micronaut.io/latest/guide/index.html#picocli を参照しつつ、 picocli を使った CLI アプリケーションを作っていきます。

今回 picocli を初めて知りました。コマンドライン引数の解析やチェックを行う便利なライブラリです。

完成したものは https://github.com/kencharos/try-graal-lambda/tree/basic-bootstrap にあります。

一部を抜粋して解説します。

まず、関数の処理本体はコンポーネントとして実装します。

前述の関数で実装したものと同じ内容です。

@Singleton

public class CalculationService {

public SampleResponse calc(SampleRequest req) {
SampleResponse res = new SampleResponse();
res.setAnswer(req.getV1() + req.getV2());
return res;
}
}

main 関数、コマンドライン引数の取得、上記コンポーネントの実行はコマンドクラスで行います。

import com.fasterxml.jackson.databind.ObjectMapper;

import io.micronaut.configuration.picocli.PicocliRunner;
import picocli.CommandLine;

import javax.inject.Inject;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

@CommandLine.Command(name = "hello-graal")
public class SampleCommand implements Runnable {

public static void main(String[] args) throws Exception {
PicocliRunner.run(SampleCommand.class, args);
}
//各コマンドライン引数
@CommandLine.Option(names = {"-r"}, description = "RequestId")
public String requestId;

/** event data, json string */
@CommandLine.Option(names = {"-d"}, description = "event data")
public String event;

@CommandLine.Option(names = {"-o"}, description = "output")
public String result;

@CommandLine.Option(names = {"-e"}, description = "error output")
public String errorResult;
// コンポーネントをインジェクション
@Inject CalculationService service;
// Jackson もインジェクション
@Inject ObjectMapper mapper;

@Override
public void run() {
// eventData の JSON文字列をオブジェクトにする
SampleRequest req;
try {
req = mapper.readValue(event, SampleRequest.class);
} catch (IOException e) {
outputError("invalid event data format");
return;
}

// コンポーネントの実行
SampleResponse answer = service.calc(req);

// デバッグ
System.out.println(requestId + " Answer is " + answer.getAnswer());
// 成功データをファイルに書き込む。
outputResult(answer);
}

private void outputResult(SampleResponse res) {
try {
Files.write(Paths.get(result), mapper.writeValueAsBytes(res));
} catch (IOException e) {
e.printStackTrace();
}
}

private void outputError(String error) {
// outputResult とほぼ同じなので割愛
}
}

クラス宣言には、picocli の @CommandLine.Command アノテーションを付与する必要があり、また Runnable も実装します。

コマンドライン引数の値は @CommandLine.Option を付与したフィールドに実行時に代入されます。

アノテーションの属性には、ショートオプション、ロングオプション、必須・任意、説明などの色々な設定ができるようになっています。

bootstrap で出てきた、 -r, -d, -o, -e の4つのフィールドを用意します。

Micronaut では @Inject で インジェクションできます。前述の自作コンポーネントのほか、 Jackson の ObjectMapper などあらかじめ定義済みのコンポーネントが使えます。

コマンドライン引数の設定、インジェクションが終わった後、 run メソッドが実行されます。

JSON 文字列をオブジェクト変換した後、コンポーネントを実行し、その結果を JSON 化してファイルに書き出しています。

まずは、手元で通常の Java アプリケーションとして実行してみます。

$ ./gradlew build

$ java -cp build/libs/try-graal-0.1-all.jar my.graal.SampleCommand -r req1 -d "{\"v1\":3, \"v2\":39}" -o /tmp/success -e /tmp/error
req1 Answer is 42
$ cat /tmp/success
{"answer":42}

-o で指定したファイルに結果が書き込まれました。

次はこれを GraalVM を使ってバイナリを作ります。


native-image コマンドによるバイナリ化

GraalVM には native-image コマンドが付属していて、これを使うと jar ファイルを解析し、AOT コンパイルを行ってバイナリを生成します。

2018年12月時点では、Linux, および Mac のみですので Windows 環境では Docker を使います。

今回はひな形にある Dockerfile を改修して Oracle 公式の oracle/graalvm-ce:1.0.0-rc9 イメージを使ってバイナリ化を実施しました。

なお、どんなプログラムでもバイナリ化できるわけではなく、制約や準備作業が必要なものがあります。

参考資料を以下に掲載します。

制約の最たるものはリフレクションの使用です。

リフレクションを使用したコードは、そのままではバイナリ化できたとしても実行時エラーになります。

そのため、リフレクションの対象となるクラスやメンバーについてはあらかじめその内容を リフレクション定義ファイル(JSON)に書き出しておく必要があります。

かなり面倒な作業ですが、Micronaut はリフレクション定義ファイルの生成を行うユーティリティを用意してくれています。

Micronaut が用意している Dockerfile にあるビルド手順を、今回の CLI アプリケーション向けに修正したものは以下の通りです。

# FatJar を作る。

./gradlew assmeble
cp build/libs/try-graal-0.1-all.jar my-graal.jar

# リフレクション定義ファイル、build/reflect.json を作る。
java -cp my-graal.jar io.micronaut.graal.reflect.GraalClassLoadingAnalyzer
# native バイナリを作る。
## ひな形からの修正点
## + 自前のリフレクション定義 refcli.json を追加。
## + RC8以降で動かすため、 -H:-UseServiceLoaderFeature を追加
## + どうしても Lambda で動かなかったため、 -H:+AllowVMInspection を -H:-AllowVMInspection に修正。
native-image --no-server \
--class-path my-graal.jar \
-H:ReflectionConfigurationFiles=build/reflect.json,refcli.json \
-H:EnableURLProtocols=http \
-H:IncludeResources="logback.xml|application.yml|META-INF/services/*.*" \
-H:Name=my-graal \
-H:Class=my.graal.SampleCommand \
-H:+ReportUnsupportedElementsAtRuntime \
-H:-AllowVMInspection \
-H:-UseServiceLoaderFeature \
-R:-InstallSegfaultHandler \
--rerun-class-initialization-at-runtime='sun.security.jca.JCAUtil$CachedSecureRandomHolder,javax.net.ssl.SSLContext' \
--delay-class-initialization-to-runtime=io.netty.handler.codec.http.HttpObjectEncoder,io.netty.handler.codec.http.websocketx.WebSocket00FrameEncoder,io.netty.handler.ssl.util.ThreadLocalInsecureRandom

2つめのコマンドにある、 io.micronaut.graal.reflect.GraalClassLoadingAnalyzer の実行で、 FatJar にある Micoronaut 関係のクラスから、リフレクション対象のクラスを列挙して、 build/reflect.json を作ります。

このファイルには例えば以下のようなものが含まれます。

[ {

"name" : "com.fasterxml.jackson.databind.PropertyNamingStrategy$UpperCamelCaseStrategy",
"allDeclaredConstructors" : true
}, {
"name" : "javax.inject.Inject",
"allDeclaredConstructors" : true
},
"name" : "io.micronaut.websocket.interceptor.$ClientWebSocketInterceptorDefinitionClass",
"allDeclaredConstructors" : true
}, {
"name" : "io.netty.channel.socket.nio.NioServerSocketChannel",
"allDeclaredConstructors" : true
}

Micronaut 関係のクラスのほか、依存する netty や Jackson, 他にアノテーションなどの内容が記述されています。

また、今回の範囲には含まれないですが自分で DI 対象のクラスを作った場合は、そのクラスやシグネチャにある POJO なども対象になります。 これは、 POJO を JSON にする際に暗黙的にリフレクションを使うためです。

その後、3番目のコマンド native-image でバイナリを作ります。

オプションがいっぱいありますね。リフレクションのほかにも、リソースファイルの明示、http 通信の明示、static initializer を使うクラスの明示など、色々大変です。

ビルドするとこんな感じのログが出ます。

[my-graal:6]    classlist:   9,082.22 ms

[my-graal:6] (cap): 2,333.45 ms
[my-graal:6] setup: 5,286.29 ms
Warning: class initialization of class io.netty.handler.ssl.JdkNpnApplicationProtocolNegotiator failed with exception java.lang.ExceptionInInitializerError. This class will be initialized at runtime because option --report-unsupported-elements-at-runtime is used for image building. Use the option --delay-class-initialization-to-runtime=io.netty.handler.ssl.JdkNpnApplicationProtocolNegotiator to explicitly request delayed initialization of this class.
Warning: class initialization of class io.netty.handler.ssl.ReferenceCountedOpenSslEngine failed with exception java.lang.NoClassDefFoundError: io/netty/internal/tcnative/SSL. This class will be initialized at runtime because option --report-unsupported-elements-at-runtime is used for image building. Use the option --delay-class-initialization-to-runtime=io.netty.handler.ssl.ReferenceCountedOpenSslEngine to explicitly request delayed initialization of this class.
[my-graal:6] (typeflow): 56,523.36 ms
[my-graal:6] (objects): 17,740.91 ms
[my-graal:6] (features): 755.81 ms
[my-graal:6] analysis: 77,048.42 ms
[my-graal:6] universe: 12,747.82 ms
[my-graal:6] (parse): 22,634.54 ms
[my-graal:6] (inline): 12,653.11 ms
[my-graal:6] (compile): 127,654.94 ms
[my-graal:6] compile: 167,219.27 ms
[my-graal:6] image: 5,349.22 ms
[my-graal:6] write: 1,704.31 ms
[my-graal:6] [total]: 278,809.18 ms

私がビルドをした環境は、 Windows 10 の Docker for windows です。

ホストマシンは、 Core i7, 16GB RAM , SSD


Hyper-V の設定は、 2CPU, 4GB RAM でした。

それでも5分弱かかるので、中々に重い作業です。ビルド後にプログラムにバグがあったら暗い気分になります。

ビルドが成功すると、 my-graal というバイナリができるので実行してみます。

$ ./my-graal -r req1 -d "{\"v1\":3, \"v2\":39}"  -o /tmp/success -e /tmp/error

req1 Answer is 42
$ cat /tmp/success
{"answer":42}

元の Jar で実行したのと同じ結果が得られました。体感的に通常の Linux コマンドを実行しているくらいの速さで実行できました。

以上が基本的なバイナリ作成の流れなのですが、今回 Lambda で動かすにあたり色々と調べたり妥協したリした点があるので、補足しておきます。


リフレクション定義ファイルの自作

Micronaut のリフレクション定義ファイルの自動作成は万全ではないです。

サードパーティライブラリを導入したり、POJO を自分で ObjectMapper や HttpClient などで JSON 化する場合などは、それらについてのリフレクション定義ファイルを作る必要があります。

picocli のコマンドクラスについては Issue が上がっているのでいずれは改善されるかもしれませんが、現時点ではコマンドクラスについては、クラスとフィールドについては自作が必要です。

また、今回 ObjectMapper を直接使っているので、JSON にしている POJO の設定も必要です。

次のようになりました。

[

{
"name":"my.graal.SampleCommand",
"allDeclaredConstructors" : true,
"allPublicConstructors" : true,
"allDeclaredMethods" : true,
"allPublicMethods" : true,
"fields" : [
{ "name" : "requestId" },
{ "name" : "event" },
{ "name" : "result" },
{ "name" : "errorResult" }
]
},
{
"name": "my.graal.SampleRequest",
"allDeclaredConstructors" : true,
"allPublicConstructors" : true,
"allDeclaredMethods" : true,
"allPublicMethods" : true
},
{
"name": "my.graal.SampleResponse",
"allDeclaredConstructors" : true,
"allPublicConstructors" : true,
"allDeclaredMethods" : true,
"allPublicMethods" : true
}
]

このファイルを、native-image コマンドの -H:ReflectionConfigurationFiles オプションに追加してあげます。


AllowVMInspection の無効化

AllowVMInspection オプションは、SEGV などのシグナル起因でヒープダンプを出すようなオプションなのですが、これを無効にしないと Lamdbda では動きませんでした。

EC2 上なら動くのに Lamdba では動かないのは謎ではありますが、 issue を報告しています。

シグナルの扱いとかに制約があるのかな? ネイティブ周りは良くわからないです。。


Lambda にデプロイする

あとは、バイナリと bootstrap シェルを zip に固めて lambda にデプロイするだけです。

Lambda を作る際に、ランライムは独自ランタイムの使用、 ハンドラ名はバイナリファイルの名前 を指定しておきます。

ちなみにメモリは 128MB にしました。

2018-12-07_12h36_15.png

ちなみに、今回のケースでは FatJar のサイズは 11MB、 ネイティブバイナリのサイズは 35MB 程度でした。

テストデータを作って実行してます。


コールドスタート

まずはデプロイ直後のコールドスタートした場合のログです。

START RequestId: 6cec98ac-f8f9-11e8-a714-33c9118529bc Version: $LATEST

6cec98ac-f8f9-11e8-a714-33c9118529bc Answer is 24
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed

0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
100 29 100 16 100 13 395 321 --:--:-- --:--:-- --:--:-- 400
{"status":"OK"}
END RequestId: 6cec98ac-f8f9-11e8-a714-33c9118529bc
REPORT RequestId: 6cec98ac-f8f9-11e8-a714-33c9118529bc Init Duration: 53.38 ms Duration: 2862.32 ms Billed Duration: 3000 ms Memory Size: 128 MB Max Memory Used: 81 MB

bootstarp に書いた cURL の POST のログまで出てますね。

Duration に、 Init Duration と Duration 2つの時間が出ていて、課金時間はその合計になるようです。

実行時間は 2900ms, メモリは 81M でした。

約3秒。普通の Java よりは早いですが、Golang ほどではないですね。


ホットスタート

ホットスタートの場合は次のような感じです。

START RequestId: 81f8a020-f8f9-11e8-8aa3-e12b01cc879f Version: $LATEST

81f8a020-f8f9-11e8-8aa3-e12b01cc879f Answer is 24
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed

0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
100 29 100 16 100 13 209 170 --:--:-- --:--:-- --:--:-- 800
{"status":"OK"}
END RequestId: 81f8a020-f8f9-11e8-8aa3-e12b01cc879f
REPORT RequestId: 81f8a020-f8f9-11e8-8aa3-e12b01cc879f Duration: 478.25 ms Billed Duration: 500 ms Memory Size: 128 MB Max Memory Used: 81 MB

実行時間は約 480 ms でした。

思ったよりも早くない感じですね。

これは bootstrap シェルと バイナリ実行が分かれているせいでオーバーヘッドが大きいからかもしれません。

せっかくなので、bootstrap バイナリ版でも実装してみましょう。


bootstrap バイナリを作る

説明した通り、 CLI アプリケーションに HTTP 通信とイベントループを実装してみます。

完成版は前述のリポジトリの master ブランチです。

https://github.com/kencharos/try-graal-lambda

コマンドクラスをイベントループに対応します。

また Micronaut の HttpClient を使って、HTTP 通信を実現します。

コマンドライン引数が減った分、むしろシンプルになりました。ただしエラー処理は割愛しています。

(リフレクション定義ファイルからもコマンドライン引数を除外します)

import io.micronaut.configuration.picocli.PicocliRunner;

import io.micronaut.context.annotation.Value;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.client.RxHttpClient;
import picocli.CommandLine;

import javax.annotation.PostConstruct;
import javax.inject.Inject;
import java.net.URL;

@CommandLine.Command(name = "hello-graal")
public class SampleCommand implements Runnable {

public static void main(String[] args) throws Exception {
PicocliRunner.run(SampleCommand.class, args);
}

// エンドポイントは設定ファイルか環境変数から取得
@Value("${aws.lambda.runtime.api}") String lambdaRuntimeEndpoint;

@Inject CalculationService service;

RxHttpClient client;

// HTTP クライアントの構築
@PostConstruct
public void buildHttpClient() throws Exception{
System.out.println("Build Http Client");
this.client = RxHttpClient.create(new URL("http://" + lambdaRuntimeEndpoint));
}
// GET
private Pair<String, SampleRequest> fetchContext() {
HttpResponse<SampleRequest> response =
client.exchange(HttpRequest.GET("/2018-06-01/runtime/invocation/next"), SampleRequest.class)
.blockingFirst();

String requestId = response.header("Lambda-Runtime-Aws-Request-Id");
return new Pair<>(requestId, response.body());
}
// POST
private void sendResponse(String requestId, SampleResponse answer) {
String path = "/2018-06-01/runtime/invocation/" + requestId + "/response";
HttpResponse<String> response =
client.exchange(HttpRequest.POST(path, answer), String.class)
.blockingFirst();
System.out.println(response.status() + " " + response.body());
}

@Override
public void run() {
// イベントループ
while (true) {
// リクエストIDとイベントデータを取得
Pair<String, SampleRequest> input = fetchContext();
// call function
SampleResponse answer = service.calc(input.t2);
System.out.println(input.t1 + " Answer is " + answer.getAnswer());
// 結果を POST
sendResponse(input.t1, answer);
}
}
}

前述と同じ手順でビルドし、 バイナリファイル名を bootstrap にリネームして、ファイル単体で zip にして Lambda にデプロイします。

余談ですがこの時に Lambda に設定するハンドラ名は何でもよくなります。

ただ、ハンドラ名はバイナリからは環境変数で取得できるので、ハンドラ名を使って処理内容を変えたりすることができます。

例えば、今回イベントループ内で実行する関数を固定で、 CalculationService を実行するようにしましたが、ハンドラ名で実行する処理を動的に変えるような実装が期待できます。そうすると C++ や Rust 版の Custom Runtime のようにイベントループ部分をフレームワーク化できます。

処理時間は次の通りです。


コールドスタート

START RequestId: 1cce68d8-f902-11e8-b471-0b8fa675139e Version: $LATEST

1cce68d8-f902-11e8-b471-0b8fa675139e Answer is 24
ACCEPTED {"status":"OK"}

END RequestId: 1cce68d8-f902-11e8-b471-0b8fa675139e
REPORT RequestId: 1cce68d8-f902-11e8-b471-0b8fa675139e Init Duration: 250.27 ms Duration: 132.84 ms Billed Duration: 400 ms Memory Size: 128 MB Max Memory Used: 79 MB

処理時間は 380ms。Golang に匹敵する速度で文句なしです。


ホットスタート

START RequestId: 3d205575-f902-11e8-9fcd-4ff8d4d3054e Version: $LATEST

3d205575-f902-11e8-9fcd-4ff8d4d3054e Answer is 24
ACCEPTED {"status":"OK"}

END RequestId: 3d205575-f902-11e8-9fcd-4ff8d4d3054e
REPORT RequestId: 3d205575-f902-11e8-9fcd-4ff8d4d3054e Duration: 31.56 ms Billed Duration: 100 ms Memory Size: 128 MB Max Memory Used: 74 MB

こちらも 31ms で文句なしの爆速です。

また、 通常の Java Lambda では 起動できなかった 128MB でも問題なく動くのが素晴らしいです。


まとめ

メモリ消費量に違いはなく約 90MB くらいで同じだったので、処理時間をまとめておきます。


コールドスタート

メモリサイズ
Java
bootstrap シェル + バイナリ
bootstrap バイナリ

128MB
N/A
3000 ms
380 ms

256MB
13500 ms
-
-

512MB
6600 ms
-
-


ホットスタート

メモリサイズ
Java
bootstrap シェル + バイナリ
bootstrap バイナリ

128MB
N/A
480 ms
35 ms

256MB
500 ms
-
-

512MB
120 ms
-
-

bootstrap バイナリの速さがすさまじいですね。

長いビルド時間に耐えるだけの価値はありそうです。

Micronaut の完成度は結構高く、native-image を試すにあたり勉強になりました。また通常の Web アプリケーションを作るためでも十分に実用的なフレームワークだと思いました。

GraalVM も native-image しか試していませんが Truffle も含め、ロマンを感じました。

そして 改めて JVM のすごさを実感しました。ネイティブ化にまつわるあれやこれを JVM はずっとやっていてくれたんだなと。

さあ、みんなで GraalVM と Custom Runtime で遊びましょう。