この記事はOracle Cloud Infrastructure Advent Calendar 2019 - Adventarの6日目の記事です。遅れました😨
胡散臭いタイトルですが、GraalVM コトハジメ その1 - Cloudii blogの続きです。
目次
Serverless Java
こんにちはid:dhigashiです。今年もServerless盛り上がっていましたね。
遅ればせながらFirebaseとAWS Amplifyを使ったアプリを開発していて、特にAWS AppSync (GraphQL) の体験が凄く良かったです。
今では、ちょっとした事ならコードを書く必要は殆ど無く、サーバーサイドで必要なロジックは関数としてデプロイしておき必要な時にだけ利用する、といった事ができる環境が整ってきていますね。
しかし、Serverlessの世界でJavaのランタイムというと、
- コールドスタートが遅い
- デプロイパッケージのサイズが大きい (Fat Jar)
- JVMの初期起動時間が長い
- メモリの消費量が多い
といったイメージがあり、ユースケースによっては他のランタイムが利用されているように思います。
しかし、前回の記事で紹介したGraalVMのNative Imageを利用すると、これらの問題を解決することができるのではないでしょうか。
この記事では、ServerlessプラットフォームのFn Projectと、Oracle Cloudでサービスとして提供されているOracle Functionsで、GraalVMのNative Imageを利用しパフォーマンスが改善できるか検証を行います。
参考: Serverless時代のJavaについて
Serverless (AWS Lambda) におけるJavaについて、こちらのスライドがとても参考になりました。
是非ご覧下さい。
Fn&Dockerの導入
関数を開発するためにFnとDockerを導入します。
注意: 前回利用したOracle Linuxの環境で開発を行いたかったのですが、今Oracle LinuxではFn serverを利用する事ができません。 とりあえずDebian系では問題は無いので、以降はUbuntuで検証を行います。
詳しくは Issue をご覧下さい。
github.com
GitHub - fnproject/fn: The container native, cloud agnostic serverless platform.
と
Get Docker Engine - Community for Ubuntu | Docker Documentation
に従い、FnとDockerを導入します。
検証を行った環境は次の通りです。
$ cat /etc/os-release NAME="Ubuntu" VERSION="19.10 (Eoan Ermine)" ID=ubuntu ID_LIKE=debian PRETTY_NAME="Ubuntu 19.10" VERSION_ID="19.10" HOME_URL="https://www.ubuntu.com/" SUPPORT_URL="https://help.ubuntu.com/" BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" VERSION_CODENAME=eoan UBUNTU_CODENAME=eoan $ fn version Client version is latest version: 0.5.91 Server version: 0.3.747 $ docker version Client: Docker Engine - Community Version: 19.03.3 API version: 1.40 Go version: go1.12.10 Git commit: a872fc2f86 Built: Tue Oct 8 01:00:44 2019 OS/Arch: linux/amd64 Experimental: false Server: Docker Engine - Community Engine: Version: 19.03.3 API version: 1.40 (minimum version 1.12) Go version: go1.12.10 Git commit: a872fc2f86 Built: Tue Oct 8 00:59:17 2019 OS/Arch: linux/amd64 Experimental: false containerd: Version: 1.2.10 GitCommit: b34a5c8af56e510852c35414db4c1f4fa6172339 runc: Version: 1.0.0-rc8+dev GitCommit: 3e425f80a8c931f88e6d94a8c831b9d5aa481657 docker-init: Version: 0.18.0 GitCommit: fec3683
関数の作成
今回検証に用いた関数はこちらにあります。
今回検証する関数は次の三種類です。
- Java関数 (pure) :
java
ランタイムを指定して作成した関数 - Native Image関数 (native) : 初期化イメージに
fnproject/fn-java-native-init native
を指定して作成した関数 - go関数 :
go
ランタイムを指定して作成した関数 (比較用)
Native Image関数について
Fn CLIでは、初期化イメージ (init-image
) を指定し、組み込まれているランタイム以外の関数テンプレートを利用することができます。
docs/create-init-image.md at master · fnproject/docs · GitHub
今回は、初期化イメージにfnproject/fn-java-native-init native
を指定し、Native Image関数を作成しました。
プロジェクトに含まれるDockerfileを見てみると、マルチステージビルドを行っており、Mavenビルド、Native Image化、ビルドしたNative Imageを含めたデプロイ用のイメージの作成を行っていることが分かります。
$ cat Dockerfile FROM fnproject/fn-java-fdk-build:latest as build LABEL maintainer="tomas.zezula@oracle.com" WORKDIR /function ENV MAVEN_OPTS=-Dmaven.repo.local=/usr/share/maven/ref/repository ADD pom.xml pom.xml RUN ["mvn", "package", "dependency:copy-dependencies", "-DincludeScope=runtime", "-DskipTests=true", "-Dmdep.prependGroupId=true", "-DoutputDirectory=target"] ADD src src RUN ["mvn", "package"] FROM fnproject/fn-java-native:latest as build-native-image LABEL maintainer="tomas.zezula@oracle.com" WORKDIR /function COPY --from=build /function/target/*.jar target/ COPY --from=build /function/src/main/conf/reflection.json reflection.json COPY --from=build /function/src/main/conf/jni.json jni.json RUN /usr/local/graalvm/bin/native-image \ --static \ --no-fallback \ --initialize-at-build-time= \ --initialize-at-run-time=com.fnproject.fn.runtime.ntv.UnixSocketNative \ -H:Name=func \ -H:+ReportUnsupportedElementsAtRuntime \ -H:ReflectionConfigurationFiles=reflection.json \ -H:JNIConfigurationFiles=jni.json \ -classpath "target/*"\ com.fnproject.fn.runtime.EntryPoint FROM busybox:glibc LABEL maintainer="tomas.zezula@oracle.com" WORKDIR /function COPY --from=build-native-image /function/func func COPY --from=build-native-image /function/runtime/lib/* . ENTRYPOINT ["./func", "-XX:MaximumHeapSizePercent=80"] CMD [ "com.example.fn.Native::handleRequest" ]
関数の比較
では、Java、Native Image、go関数を比較していきます。
イメージサイズ
docker images
でDockerイメージのサイズを確認すると、次の結果となりました。
native | pure | go |
---|---|---|
20.7MB | 222MB | 17MB |
Native Image関数がGo関数より若干大きいが、Java関数と比較すると1/10程度に削減出来ています。
実行時間
Fn関数は呼び出されると、Dockerコンテナとして起動しリクエストを処理します。関数は30秒(デフォルト値)後続のリクエストを待ち、リクエストが無ければシャットダウンします。 この初回起動をCold
、リクエストを待ち受けている状態をWarm
とし、それぞれの状態での実行時間を計測し比較します。
実行する関数は次の通りです。
package com.example.fn; import java.lang.management.ManagementFactory; import com.fnproject.fn.api.FnConfiguration; public class Native { private long initialize; private long count; @FnConfiguration public void setUp() { initialize = ManagementFactory.getRuntimeMXBean().getUptime(); } public String handleRequest(String input) { long uptime = ManagementFactory.getRuntimeMXBean().getUptime(); count += 1; return String.format("Uptime: %d (init: %d), Call: %d", uptime, initialize, count); } }
@FnConfiguration
アノテーションが付与されたsetUp()
メソッドは初回起動時に一度だけ呼び出されるので、ここでRuntimeMXBean#getUptimeでJVM自体の起動時間を計測します。
(go関数はサボってHello Worldのままです)
$ time fn invoke myapp pure Uptime: 164 (init: 128), Call: 1 real 0m0.595s user 0m0.026s sys 0m0.000s $ time fn invoke myapp pure Uptime: 1655 (init: 128), Call: 2 real 0m0.038s user 0m0.014s sys 0m0.014s … $ time fn invoke myapp native Uptime: 16 (init: 0), Call: 1 real 0m0.384s user 0m0.027s sys 0m0.000s $ time fn invoke myapp native Uptime: 1270 (init: 0), Call: 2 real 0m0.037s user 0m0.021s sys 0m0.005s ... $ time fn invoke myapp go {"message":"Hello World"} real 0m0.439s user 0m0.027s sys 0m0.005s $ time fn invoke myapp go {"message":"Hello World"} real 0m0.042s user 0m0.020s sys 0m0.010s …
関数が立ち上がっていないCold状態からの呼び出し、関数が待ち受けているWarm状態での呼び出しでそれぞれ10回ずつ計測して平均すると次の結果となりました。
Native Image関数とJava関数を比較するとCold状態からの実行時間がJVMの起動時間分早くなっています。また、Native Image関数はgo関数と同等の時間となっています。
「ホット」な関数
Cold状態では差が見られましたが、Warm状態では先ほど図で示した様に差はみられませんでした。
これは、関数がシャットダウンされず後続のリクエストを待ち受けているとき、イメージのプルやコンテナ、JVMの起動といった関数の立ち上げ処理を行う必要が無いためです。
この様な関数をイベントループなどを実装し一から作成することは簡単ではありませんが、FDK (Function Development Kit) を利用すれば開発者は意識すること無く簡単に実現することができます。
メモリサイズ
docker stats
でメモリの使用量を確認すると次の結果となりました。
native | pure | go |
---|---|---|
1.574MiB | 19.36MiB | 2.098MiB |
初回リクエスト時点のメモリ使用量は、Native Image関数はGo関数と同程度で、Java関数と比較すると1/10程度となっています。
Oracle Functionsへのデプロイ
最後に、ローカルで実行していた関数をOracle Functionsへデプロイし比較を行います。
Dockerイメージを格納するContainer RegistryとOracle Functionsは共に東京リージョンを利用しました。
関数のメモリーは初期設定の128MBです。
Cold状態からの呼び出しをそれぞれの関数に5回行い平均すると次の結果となり、Native Image関数の起動時間が大きく改善されていることがわかります。
こちらもNative Image関数はgo関数と同等の時間となりました。
Warm状態ではローカル実行時と同様に実行時間に差はありません。
まとめ
GraalVMのNative Imageの機能を利用し、Serverless環境におけるJavaアプリケーションのコールドスタート時のパフォーマンスの改善について検証を行いました。
今回検証に用いたアプリケーションは現実のシナリオを反映したものでは無いので必ずしも有用とは限りませんが、検討してみてはいかがでしょうか。
Oracle CloudならGraalVM EEのサポートがついてきますしね! (ココがこの記事で一番大事)
明日の記事もお楽しみに。