GraalVM Native ImageとOracle Functionsで爆速Java

この記事は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について、こちらのスライドがとても参考になりました。

www.slideshare.net

是非ご覧下さい。

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

関数の作成

今回検証に用いた関数はこちらにあります。

github.com

今回検証する関数は次の三種類です。

  • 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回ずつ計測して平均すると次の結果となりました。

f:id:dhigashi:20191203113126p:plain
実行時間

Native Image関数とJava関数を比較するとCold状態からの実行時間がJVMの起動時間分早くなっています。また、Native Image関数はgo関数と同等の時間となっています。

「ホット」な関数

Cold状態では差が見られましたが、Warm状態では先ほど図で示した様に差はみられませんでした。
これは、関数がシャットダウンされず後続のリクエストを待ち受けているとき、イメージのプルやコンテナ、JVMの起動といった関数の立ち上げ処理を行う必要が無いためです。

この様な関数をイベントループなどを実装し一から作成することは簡単ではありませんが、FDK (Function Development Kit) を利用すれば開発者は意識すること無く簡単に実現することができます。

medium.com

メモリサイズ

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関数と同等の時間となりました。

f:id:dhigashi:20191203115033p:plain
Cloud Functions - コールドスタート

Warm状態ではローカル実行時と同様に実行時間に差はありません。

f:id:dhigashi:20191202215417p:plain
Cloud Functions - warm

まとめ

GraalVMのNative Imageの機能を利用し、Serverless環境におけるJavaアプリケーションのコールドスタート時のパフォーマンスの改善について検証を行いました。
今回検証に用いたアプリケーションは現実のシナリオを反映したものでは無いので必ずしも有用とは限りませんが、検討してみてはいかがでしょうか。

Oracle CloudならGraalVM EEのサポートがついてきますしね! (ココがこの記事で一番大事)

明日の記事もお楽しみに。