Graal VM の native image を使って Java で爆速 Lamdba の夢を見る
Graal VM の native image を使って Java で爆速 Lamdba の夢を見る:
前日は mike_neck さんの AWS Lambda のカスタムランタイムにて Java のカスタムランタイムで関数を動かす でした。
偶然にも(?)今日も引き続き、 Lambda Custom Runtime で Java を動かす話です。
AWS Lambda 提供する言語の一つに Java があります。
Java はホットスタートの処理速度は速いもの、コールドスタートでは 5 から 10 秒ほど要することがあります。
また、メモリ消費量も多く Lambda と Java の組み合わせは速度重視の場面では使われていないように思います。
2018年の re:invent で、 AWS Lambda の Csutom Runtime が発表されました。
お作法に従いさえすればどのような言語でも Lambda として実行可能になりました。
さらに近年 Java 界隈では新しい JIT コンパイラの Graal と Graal やその他周辺機能を盛り込んだ JVM である Graal VM が登場しました。
Graal VM の機能の一つにネイティブバイナリの生成機能があり、これを使えば JVM 起動の処理時間を短縮できます。
そのため、Graal VM でネイティブバイナリ化した Java プログラムを Lambda の Csutom Runtime で動かせば、
爆速の Lamdba を Java でできそうだと感じました。
実際にやってみたところ爆速になったので、その手順と結果を記述します。
今回、ネイティブバイナリを作るにあたって、 Micronaut というフレームワークを使っていますので、
micronaut に関する説明も一緒に行います。
説明する内容は次の通りです。
Micronaut は今年 GA したマイクロサービス向けの Java・Kotlin・Groovy で書けるフレームワークです。
一言で言えば、今の技術でゼロから作った Spring Boot です。
以下の特徴があります。
特に Graal に関するサポートが入っているのが Graal 初心者にはありがたかったので、今回の記事で採用しています。
では、あとでネイティブ実行とのパフォーマンスを比較するため、 Micronaut で作った Function を AWS Lambda にデプロイしてみます。
詳細な手順は http://guides.micronaut.io/micronaut-function-aws-lambda/guide/index.html にあります。
上記を参考に作ったサンプルは https://github.com/kencharos/micornaut-lambda-sample にあります。
Function の実装内容は、
これを実装した関数は次の通りです。
Micronaut では、 Function や Prider などを実装したクラスに
Lambda の関数としたり、あるいはスタンドアローンの Web アプリのエンドポイントにすることができます。
(今回は割愛しますが、 Spring MVC ライクに
ソースができたら、
少々迷いますが、ハンドラ名にはガイドにある通り、
FatJar のファイルサイズは約 11MB 程度でした。
さて、上記の Lambda をメモリを変えて実行した結果は次の通りでした。
なお、コールドスタートはデプロイ直後のテスト実行、ホットスタートはその後にさらに何度か実行した後の結果です。
Lambda は確保したメモリサイズで CPU の処理能力が変わります。
128 MB の状態では、 Metaspace が足りず OutOfMemoryError になりました。
512 MB のホットスタートでやっと使い物になる感じです。
コールドスタートはだいぶ遅いです。
それでもメモリ消費量が 100 MB を切る Micronaut は結構すごいです。
さて、じゃあ Graal VM でネイティブを作りたいところですが、
上記の Function スタイルのアプリケーションをそのまま Custom Runtime には適用できないので、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 のお作法は次の通りです。
そのため、実現方法は次の2通りが考えれます。
チュートリアルにある通り、 bootstrap はシェルで実装、HTTPリクエストは CURL で行います。
上記 3.2 の関数実行を 別の シェルやスクリプト、バイナリなど別の実行可能ファイルにすることができます。
この方法の場合、 3.2 に相当する部分は単純なmプログラム引数から何らかの結果を返すようなプログラムを実装すればよく、ここに Graal VM でネイティブ化したバイナリを設定できそうです。
別の方法として、 bootstrap にイベントループと関数の処理全部を記述したシングルバイナリを設定する方法もあります。
実際、 C++ や RUST の Custom Runtime はこの方法で実現されています。イベントループに相当する部分をライブラリとして提供されていて、ライブラリと自作関数をまとめて bootstrap ファイル
にビルドできます。
Graal VM でもシングルバイナリの方式を取ることができますが、その場合HTTP 通信を含むことになりますし、動作確認が面倒です。
まずは前者の シェル + 単純なバイナリ の方法で実現することにしました。
ここからが本番です。
まずは、ネイティブ化したバイナリの入出力を考えてみます。
bootstrap シェルで、 curl で Lambda のエンドポイントを叩くので、
バイナリにどうやって入力を与え、バイナリから結果の出力をどのように受け取るかを考えます。
今回は入力はプログラム引数で、出力は所定のファイルに結果を書き込むという方針にしました。
結果を標準出力に出す方法もありますが、ログ出力の兼ね合いもあるのでよほど簡単でなければ避けたほうがよいでしょう。
上記を踏まえ、まずは bootstrap を作ってみます。
公式のチュートリアルの内容をベースに修正したものです。
ポイントは、
では、この引数から処理を実行する Micronaut アプリケーションを作ります。
Micronaut は CLI アプリケーションも作れます。
普通に main メソッドを持つプログラムを作ってもよいのですが、 Micornaut なら CLI でも DI ができたり、設定ファイルと環境変数を合成できたり、HTTP クライアントや JSON 変換などの機能もあるので何かと便利です。
ネイティブ化することも考え、以下のガイドを参考に Graal 用のひな方を作って作業を始めます。
https://docs.micronaut.io/latest/guide/index.html#graalServices
これをベースに https://docs.micronaut.io/latest/guide/index.html#picocli を参照しつつ、 picocli を使った CLI アプリケーションを作っていきます。
今回 picocli を初めて知りました。コマンドライン引数の解析やチェックを行う便利なライブラリです。
完成したものは https://github.com/kencharos/try-graal-lambda/tree/basic-bootstrap にあります。
一部を抜粋して解説します。
まず、関数の処理本体はコンポーネントとして実装します。
前述の関数で実装したものと同じ内容です。
main関数、コマンドライン引数の取得、上記コンポーネントの実行はコマンドクラスで行います。
クラス宣言には、picocli の
コマンドライン引数の値は
アノテーションの属性には、ショートオプション、ロングオプション、必須・任意、説明などの色々な設定ができるようになっています。
bootstrap で出てきた、 -r, -d, -o, -e 4つのフィールドを用意します。
Micronaut では
コマンドライン引数の設定、インジェクションが終わった後、 run メソッドが実行されます。
JSON文字列をオブジェクト変換した後、コンポーネントを実行し、その結果をJSON化してファイルに書き出しています。
まずは、手元の通常の Java アプリケーションとして実行してみます。
-o で指定したファイルに結果が書き込まれました。
次はこれを Graal VM を使ってバイナリを作ります。
Graal VM には native-image コマンドが付属していて、これを使うと jar ファイルを解析し、AOT コンパイルを行ってバイナリを生成します。
2018年12月時点では、Linux, および Mac のみですので Windows 環境では Docker を使います。
今回はひな形にある Dockerfile を改修して Oracle 公式の oracle/graalvm-ce:1.0.0-rc9 イメージを使ってバイナリ化を実施しました。
なお、どんなプログラムでもバイナリ化できるわけではなく、制約や準備作業が必要なものがあります。
参考資料を以下に掲載します。
リフレクションを使用したコードは、そのままではバイナリ化できたとしても実行時エラーになります。
そのため、リフレクションの対象となるクラスやメンバーについてはあらかじめその内容を リフレクション定義ファイル(JSON)に書き出しておく必要があります。
かなり面倒な作業ですが、Micronaut はリフレクション定義ファイルの生成を行うユーティリティを用意してくれています。
Micronaut が用意している Dockerfile にあるビルド手順を、今回の CLI アプリケーション向けに修正したものは以下の通りです。
2つめのコマンドにある、
このファイルには例えば以下のようなものが含まれます。
Micronaut関係のクラスのほか、依存する netty や Jackson, 他にアノテーションなどの内容が記述されています。
また、今回の範囲には含まれないですが自分で DI 対象のクラスを作った場合は、そのクラスやシグネチャにある POJO なども対象になります。 これは、 POJO を JSON にする際に暗黙的にリフレクションを使うためです。
その後、3番目のコマンド native-image でバイナリを作ります。
オプションがいっぱいありますね。リフレクションのほかにも、リソースファイルの明示、http通信の明示、static initializer を使うクラスの明示など、色々大変です。
ビルドするとこんな感じのログが出ます。
私がビルドをした環境は、 Windows 10 の Docker for windows です。
ホストマシンは、 Core i7, 16GB RAM , SSD
Hyper-V の設定は、 2CPU, 4GB RAM でした。
それでも5分弱かかるので、中々に重い作業です。プログラムがミスってたら暗い気分になります。
お金で殴った Mac が欲しくなります。
ビルドが成功すると、 my-graal というバイナリができるので実行してみます。
元の Jar で実行したのと同じ結果が得られました。体感的に通常の Linux コマンドを実行しているくらいの速さで実行できました。
以上が基本的なバイナリ作成の流れなのですが、今回 Lambda で動かすにあたり色々と調べたり妥協したリした点があるので、補足しておきます。
Micronaut のリフレクション定義ファイルの自動作成は万全ではないです。
サードパーティライブラリを導入したり、POJO を自分で ObjectMapper や HttpClient などで JSON 化する場合などは、それらについてのリフレクション定義ファイルを作る必要があります。
picocli のコマンドクラスについては Issue が上がっているのでいずれは改善されるかもしれませんが、現時点ではコマンドクラスについては、クラスとフィールドについては自作が必要です。
また、今回 ObjectMapper を直接使っているので、JSON にしている POJO の設定も必要です。
次のようになりました。
このファイルを、native-image コマンドの
AllowVMInspection オプションは、SEGV などのシグナル起因でヒープダンプを出すようなオプションなのですが、これを無効にしないと Lamdbda では動きませんでした。
EC2 上なら動くのに Lamdba では動かないのは謎ではありますが、 issue を報告しています。
シグナルの扱いとかに制約があるのかな? ネイティブ周りは良くわからないです。。
あとは、バイナリと bootstrapファイルを zip に固めて lambda にデプロイするだけです。
Lambda を作る際に、ランライムは独自ランタイムの使用、 ハンドラ名はバイナリファイルの名前 を指定しておきます。
ちなみにメモリは 128MB にしました。
ちなみに、今回のケースでは FatJar のサイズは 11MB、 ネイティブバイナリのサイズは 35MB 程度でした。
テストデータを作って実行してます。
まずはデプロイ直後のコールドスタートした場合のログです。
bootstarp に書いた cURL の POST のログまで出てますね。
Duration に、 Init Duration と Duration 2つの時間が出ていて、課金時間はその合計になるようです。
実行時間は 2900ms, メモリは 81M でした。
約3秒。普通の Java よりは早いですが、Golang ほどではないですね。
ホットスタートの場合は次のような感じです。
実行時間は約 480ms でした。
思ったよりも早くない感じですね。
これは bootstrap シェルと バイナリ実行が分かれているせいでオーバーヘッドが大きいからかもしれません。
せっかくなので、シングルバイナリ版でも実装してみましょう。
説明した通り、 CLI アプリケーションに HTTP 通信とイベントループを実装してみます。
完成版は前述のリポジトリの master ブランチです。
https://github.com/kencharos/try-graal-lambda
コマンドクラスをイベントループに対応します。
また Micronaut の HttpClient を使って、HTTP 通信を実現します。
コマンドライン引数が減った分、むしろシンプルになりました。ただしエラー処理は割愛しています。
(リフレクション定義ファイルからもコマンドライン引数を除外します)
前述と同じ手順でビルドし、 バイナリファイル名を bootstrap にリネームして、ファイル単体で zip にして Lambda にデプロイします。
処理時間は次の通りです。
処理時間は 380ms。Golang に匹敵する速度で文句なしです。
こちらも 31ms で文句なしの爆速です。
また、 通常の Java Lamdba では 起動できなかった 128MB でも問題なく動くのが素晴らしいです。
メモリ消費量に違いはなかったので、処理時間をまとめておきます。
シングルバイナリの速さがすさまじいですね。
長いビルド時間に耐えるだけの価値はありそうです。
Micronaut の完成度は結構高く、native-image を試すにあたり勉強になりました。また通常の Web アプリケーションを作るためでも十分に実用的なフレームワークだと思いました。
Graal VM も native-image しか試していませんが Truffle も含め、ロマンを感じました。
そして 改めて JVM のすごさを実感しました。ネイティブ化にまつわるあれやこれを JVM はずっとやっていてくれたんだなと。
さあ、みんなで Graal と Custom Runtime で遊びましょう。
はじめに
前日は mike_neck さんの AWS Lambda のカスタムランタイムにて Java のカスタムランタイムで関数を動かす でした。偶然にも(?)今日も引き続き、 Lambda Custom Runtime で Java を動かす話です。
AWS Lambda 提供する言語の一つに Java があります。
Java はホットスタートの処理速度は速いもの、コールドスタートでは 5 から 10 秒ほど要することがあります。
また、メモリ消費量も多く Lambda と Java の組み合わせは速度重視の場面では使われていないように思います。
2018年の re:invent で、 AWS Lambda の Csutom Runtime が発表されました。
お作法に従いさえすればどのような言語でも Lambda として実行可能になりました。
さらに近年 Java 界隈では新しい JIT コンパイラの Graal と Graal やその他周辺機能を盛り込んだ JVM である Graal VM が登場しました。
Graal VM の機能の一つにネイティブバイナリの生成機能があり、これを使えば JVM 起動の処理時間を短縮できます。
そのため、Graal VM でネイティブバイナリ化した Java プログラムを Lambda の Csutom Runtime で動かせば、
爆速の Lamdba を Java でできそうだと感じました。
実際にやってみたところ爆速になったので、その手順と結果を記述します。
今回、ネイティブバイナリを作るにあたって、 Micronaut というフレームワークを使っていますので、
micronaut に関する説明も一緒に行います。
説明する内容は次の通りです。
- micronaut で Java の Lambda 関数を作る
- AWS Lambda Custom Runtime の説明
- micronaut CLI アプリケーションを Graal VM で ネイティブにする
- ネイティブを Custom Runtime で動かす
micronaut で Java の Lambda 関数を作る
Micronaut は今年 GA したマイクロサービス向けの Java・Kotlin・Groovy で書けるフレームワークです。一言で言えば、今の技術でゼロから作った Spring Boot です。
以下の特徴があります。
- annotation processor でコンパイル時に DI コードを生成するためコンポーネントスキャンが無く、起動が早く省メモリ
- MVC による Webアプリ、 FasS型アプリ、 CLI アプリなど様々な形式のアプリケーションが作れる
- マイクロサービスや 各種クラウド向けの機能や設定が最初からある
- 実験的に Graal の native image サポートがある
- 分かりやすいドキュメント
特に Graal に関するサポートが入っているのが Graal 初心者にはありがたかったので、今回の記事で採用しています。
では、あとでネイティブ実行とのパフォーマンスを比較するため、 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; } }
@FunctionBean
を付与することで、Lambda の関数としたり、あるいはスタンドアローンの Web アプリのエンドポイントにすることができます。
(今回は割愛しますが、 Spring MVC ライクに
@Controler
や @Get
のようなアノテーションを使った Web アプリケーションスタイルでの開発もできます。 )ソースができたら、
./gradlew shadowJar
で FatJar を作成し、 Java8 ランタイムを指定して Lambda に jar ファイルをデプロイします。少々迷いますが、ハンドラ名にはガイドにある通り、
io.micronaut.function.aws.MicronautRequestStreamHandler
を指定します。FatJar のファイルサイズは約 11MB 程度でした。
さて、上記の Lambda をメモリを変えて実行した結果は次の通りでした。
なお、コールドスタートはデプロイ直後のテスト実行、ホットスタートはその後にさらに何度か実行した後の結果です。
メモリ | コールドスタードの処理時間 | ホットスタートの処理時間 | メモリ消費量 |
---|---|---|---|
128MB | 26秒後に OutOfMemoryError | - | - |
256MB | 13.5秒 | 500 ミリ秒 | 90 MB |
512MB | 6.6 秒 | 120 ミリ秒 | 90 MB |
128 MB の状態では、 Metaspace が足りず OutOfMemoryError になりました。
512 MB のホットスタートでやっと使い物になる感じです。
コールドスタートはだいぶ遅いです。
それでもメモリ消費量が 100 MB を切る Micronaut は結構すごいです。
さて、じゃあ Graal VM でネイティブを作りたいところですが、
上記の 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 のお作法は次の通りです。
- 実行可能な bootstrap というファイルがあること
- bootstrap にはイベントループという無限ループを実装すること
- イベントループ一回の処理で次の処理を行う
- イベントデータを
/2018-06-01/runtime/invocation/next
エンドポイントから GET する。
- Lambda への入力データと、 コンテキスト(リクエストIDなど)はここから取得できる
- 何らかの処理を実行し関数の結果を生成する
- 関数の結果を
/2018-06-01/runtime/invocation/<リクエストID>/response
に POST する
- エラーにしたい場合は、
/2018-06-01/runtime/invocation/<リクエストID>/error
に POST する
- エラーにしたい場合は、
- イベントデータを
そのため、実現方法は次の2通りが考えれます。
bootstrap シェル + 関数処理ファイル
チュートリアルにある通り、 bootstrap はシェルで実装、HTTPリクエストは CURL で行います。上記 3.2 の関数実行を 別の シェルやスクリプト、バイナリなど別の実行可能ファイルにすることができます。
この方法の場合、 3.2 に相当する部分は単純なmプログラム引数から何らかの結果を返すようなプログラムを実装すればよく、ここに Graal VM でネイティブ化したバイナリを設定できそうです。
bootstarp シングルバイナリ
別の方法として、 bootstrap にイベントループと関数の処理全部を記述したシングルバイナリを設定する方法もあります。実際、 C++ や RUST の Custom Runtime はこの方法で実現されています。イベントループに相当する部分をライブラリとして提供されていて、ライブラリと自作関数をまとめて bootstrap ファイル
にビルドできます。
Graal VM でもシングルバイナリの方式を取ることができますが、その場合HTTP 通信を含むことになりますし、動作確認が面倒です。
まずは前者の シェル + 単純なバイナリ の方法で実現することにしました。
Micronaut CLI アプリケーションを Graal VM でネイティブ化する
ここからが本番です。まずは、ネイティブ化したバイナリの入出力を考えてみます。
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 変換などの機能もあるので何かと便利です。
ネイティブ化することも考え、以下のガイドを参考に Graal 用のひな方を作って作業を始めます。
https://docs.micronaut.io/latest/guide/index.html#graalServices
mn create-app my-graal --features graal-native-image
コマンドで graal-native-image を有効にしてひな形をつくると、 graal VM の 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; } }
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 とほぼ同じなので割愛 } }
@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}
次はこれを Graal VM を使ってバイナリを作ります。
native-image コマンドによるバイナリ化
Graal VM には native-image コマンドが付属していて、これを使うと jar ファイルを解析し、AOT コンパイルを行ってバイナリを生成します。2018年12月時点では、Linux, および Mac のみですので Windows 環境では Docker を使います。
今回はひな形にある Dockerfile を改修して Oracle 公式の oracle/graalvm-ce:1.0.0-rc9 イメージを使ってバイナリ化を実施しました。
なお、どんなプログラムでもバイナリ化できるわけではなく、制約や準備作業が必要なものがあります。
参考資料を以下に掲載します。
- https://www.graalvm.org/docs/reference-manual/aot-compilation/
- https://github.com/oracle/graal/blob/master/substratevm/REFLECTION.md
- https://github.com/oracle/graal/blob/master/substratevm/LIMITATIONS.md
リフレクションを使用したコードは、そのままではバイナリ化できたとしても実行時エラーになります。
そのため、リフレクションの対象となるクラスやメンバーについてはあらかじめその内容を リフレクション定義ファイル(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
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 }
また、今回の範囲には含まれないですが自分で 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
ホストマシンは、 Core i7, 16GB RAM , SSD
Hyper-V の設定は、 2CPU, 4GB RAM でした。
それでも5分弱かかるので、中々に重い作業です。プログラムがミスってたら暗い気分になります。
お金で殴った Mac が欲しくなります。
ビルドが成功すると、 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}
以上が基本的なバイナリ作成の流れなのですが、今回 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 } ]
-H:ReflectionConfigurationFiles
オプションに追加してあげます。
AllowVMInspection の無効化
AllowVMInspection オプションは、SEGV などのシグナル起因でヒープダンプを出すようなオプションなのですが、これを無効にしないと Lamdbda では動きませんでした。EC2 上なら動くのに Lamdba では動かないのは謎ではありますが、 issue を報告しています。
シグナルの扱いとかに制約があるのかな? ネイティブ周りは良くわからないです。。
Lambda にデプロイする
あとは、バイナリと bootstrapファイルを zip に固めて lambda にデプロイするだけです。Lambda を作る際に、ランライムは独自ランタイムの使用、 ハンドラ名はバイナリファイルの名前 を指定しておきます。
ちなみにメモリは 128MB にしました。
ちなみに、今回のケースでは 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
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
思ったよりも早くない感じですね。
これは 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); } } }
処理時間は次の通りです。
コールドスタート
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
ホットスタート
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
また、 通常の Java Lamdba では 起動できなかった 128MB でも問題なく動くのが素晴らしいです。
まとめ
メモリ消費量に違いはなかったので、処理時間をまとめておきます。
コールドスタート
メモリサイズ | Java | シェル+バイナリ | シングルバイナリ |
---|---|---|---|
128MB | N/A | 3000 ms | 380 ms |
256MB | 13500 ms | - | - |
512MB | 6600 ms | - | - |
ホットスタート
メモリサイズ | Java | シェル+バイナリ | シングルバイナリ |
---|---|---|---|
128MB | N/A | 480 ms | 35 ms |
256MB | 500 ms | - | - |
512MB | 120 ms | - | - |
長いビルド時間に耐えるだけの価値はありそうです。
Micronaut の完成度は結構高く、native-image を試すにあたり勉強になりました。また通常の Web アプリケーションを作るためでも十分に実用的なフレームワークだと思いました。
Graal VM も native-image しか試していませんが Truffle も含め、ロマンを感じました。
そして 改めて JVM のすごさを実感しました。ネイティブ化にまつわるあれやこれを JVM はずっとやっていてくれたんだなと。
さあ、みんなで Graal と Custom Runtime で遊びましょう。
コメント
コメントを投稿