※本記事は、Oleg Šelajevによる”Java frameworks for the cloud: Establishing the bounds for rapid startups“を翻訳したものです。
GraalVMプラットフォームでサーバーレス・アプリケーションを使用する事例を作る
著者:Oleg Šelajev
2020年8月3日
世界にはすばらしいJavaフレームワークが数多く存在しており、多種多様な好みに対応したものを見つけられます。Java EEベース、Springベース、クラウド・ネイティブ、Kubernetesネイティブ。柔軟なもの、こだわりがあるもの。フルスタック、モジュール型。軽量なもの、開発者にやさしいもの、運用しやすいもの、DevOpsに向いたもの、クラウドに向いたもの、MicroProfileベースのもの。マイクロフレームワーク、エコシステムの統合ポイントとして機能するフレームワーク、ライブラリとして使ってユーザー自身で他のライブラリに組み込む形のフレームワーク。そして、最先端、優れたパフォーマンス、使いやすさ、Kotlin対応、卓越した表現力とエレガントさなど、最新フレームワークの作成者の語彙にあるありとあらゆる肯定的な形容詞を組み合わせたものが存在します。
いずれにせよ、HTTPリクエストに応答できるWebアプリケーションの作成に必要な基本機能は、このようなフレームワークすべてで提供されています。それでは、どうすれば最適なものを選ぶことができるでしょうか。現実的な知恵として挙げられるのは、チームがもっとも熟知しているフレームワークや、熟知している人を簡単に見つけられるくらいに普及しているフレームワークを使うことです。
このありきたりで合理的な説明の中には、エンジニアリング的な真実の種が確かに含まれています。大半のフレームワーク、少なくとも普及しているフレームワークは、同じような機能を提供し、同じような低レベル・スタック(多くの場合はNetty)をベースとし、同じようなアノテーションやプログラミング・モデルをサポートし、組込みの依存性注入メカニズムなどを提供しています。
すべてのフレームワークは多少似通っており、違いはあってもそのほとんどは「ソフト」であり、好みは「テイスト」であると言えます。一方で、一部の違いは、そのフレームワークらしい記述法の選択や実際のアプリケーションのパフォーマンスに影響します。
本記事はフレームワークを比較するものではありません。制約のあるクラウド環境でフレームワークを実行する際に予想されることについて説明するため、実験を行いたいと思います。
クラウド・オプションの検討
クラウドのワークロードはプロバイダーのサーバーで動作し、通常は、消費したリソースに対して従量課金されます。また、コードのパフォーマンスは、クラウドでソフトウェアを実行するコストに直接影響します。つまり、遅ければ高くつき、速ければ安くすむということです。
これは単純な理屈のように思えますが、問題なのは、アプリケーションが異なれば、パフォーマンスが優れている、パフォーマンスが劣るという表現が別の内容を指すことです。パフォーマンスが優れているとは、スループットがよいという意味であることも、メモリ使用量が少ないという意味であることも、コールド・スタート時の起動時間が短いという意味であることもあります。
あるパフォーマンス指標の重要性は、そのユースケースでもっとも重視することに応じて変化します。アプリケーションを適切に実行すれば、フレームワークを変えなくても、ユースケースにいっそう適したパフォーマンス・プロファイルを取得できます。たとえば、アプリケーションをさらに高速に実行できるように、より適切なガベージ・コレクションの構成を見つける、異なる分散構成を選択するなど、Javaプロセスに渡すランタイム・パラメータをチューニングしなければならないかもしれません。
図1は、望みどおりのパフォーマンス・プロファイルを実現するためにどのようにJavaアプリケーションを実行すればよいかを説明した便利な図です。コードのスループットを最大にしたい場合(つまり、同じリソースでできるだけ多くのユーザーからのリクエストに応答したい場合)は、Oracle GraalVM Enterprise Edition(GraalVM Enterprise)でアプリケーションを実行することを検討してください。本記事では、特にサーバーレス・アプリケーションでこれを使う事例を作ります。筆者が考えるに、GraalVM EnterpriseのJust-In-Time(JIT)コンパイラは、現在利用できるものの中でおそらくもっとも強力で、ほとんどの場合に優れたスループットを提供します。
テール・レイテンシを最適化したい場合や、アプリケーションが絶対に中断しないようにしたい場合は、利用できる最高のJITコンパイラを使うか、低レイテンシGCアルゴリズムを選ぶ必要があります。
多くのクラウド・ワークロードでもっとも重要な指標は、メモリ使用量が少なく高速に起動することです。そのような場合に優れたランタイム・パフォーマンスを提供するのが、GraalVM Native Imageです。Native Imageによって、事前にアプリケーションをネイティブ・バイナリにコンパイルすることができます。これにより、JITコンパイル機能を含めて使用する必要が一切なくなるため、コールド・スタート時の起動時間が数十ミリ秒に収まり、メモリ使用量も減少します。

図1:望みどおりのパフォーマンス・プロファイルを実現するJavaアプリケーションを実行する方法(大きな画像を表示)
Javaアプリのベースラインの決定
クラウド環境でいくつかのフレームワークを使い、Javaアプリケーションのベースラインとなるパフォーマンス・プロファイルを決定しました。確認したのは、以下の項目です。
- アプリケーションの起動速度
- アプリケーションが何らかの有用な作業を行うまでの時間
- アプリケーションのメモリ使用量
以上を確認するために、さまざまなフレームワークを使っていくつかの小さな「helloworld」アプリケーションをビルドし、そのアプリケーションをリソースが限られた小さなDockerコンテナで実行しました。このコンテナは、クラウドにデプロイする際の一時的なセットアップを模したものです。
このプロジェクトでは、128 MBのメモリでDockerコンテナを構成しました。この数字は、偶然に選んだ適当なものではありません。多くのサーバーレス・ソリューションでは、1秒あたりのギガバイト単位のメモリ消費量に基づいて課金を行っています。その最初の段階が128 MBの環境です。そのため、今回の実験ではもっとも小さく安価な環境で大まかなパフォーマンスを見積もりました。その際に特に注目したのは、サーバーレス機能で非常に重要となる起動時間です。
以下に示すのは、Dropwizard、Micronaut、Quarkusの各フレームワークでの結果です。テスト要件を満たす「helloworld」アプリをビルドした手順とともに紹介します。
Dropwizard:公式のDropwizardスタート・ガイドに従い、Mavenアーキタイプを起動して、ガイド記載のコードをビルドすることは容易でした。以下に手順を示します。
- 次のコマンドを実行してプロジェクトを作成します。
mvn archetype:generate -DarchetypeGroupId=io.dropwizard.archetypes -DarchetypeArtifactId=java-simple -DarchetypeVersion=2.10.0
- 質問にインタラクティブに回答します。
make 'artifactId: dropwizard-getting-started`
- ディレクトリを変更します。
cd dropwizard-getting-started
- 次のコマンドを実行します。
mvn verify
- 簡単なDockerfileを作成します。
FROM adoptopenjdk/openjdk11:alpine-slim COPY target/dropwizard-getting-started-1.0-SNAPSHOT.jar complete.jar EXPOSE 8080 CMD java -jar complete.jar server
- Dockerイメージをビルドします。
docker build -f Dockerfile -t shelajev/dropwizard-getting-started
- 128 MBのメモリを割り当ててコンテナを実行します。
docker run --rm --memory 128M -p 8083:8080 shelajev/dropwizard-getting-started
アプリを実行したところ、3.5秒でサーバーが起動したことが報告されました。
INFO [2020-06-16 22:16:38,386] org.eclipse.jetty.server.Server: Started @3594ms
これはまずまずの結果ですが、サーバーレス・アプリケーションでは理想的とは言えません。アプリケーションが有用なことを実際には何も行っていない3.5秒間についても支払う必要があるという結果だからです。ただし、このテストは非常に基本的なもので、フレームワークを高速に起動させるようなチューニングは行いませんでした。一方、何らかのフレームワークを使っている大きなアプリケーションでは、アプリケーション・ランタイムの開始時に追加クラスの読込み、キャッシュの初期化、クラス階層やプラグイン・システムを読み込むなどのタスクを実行する必要があるため、起動が遅くなる傾向にあります。
Micronaut:Micronautを使うのはとても簡単で、Micronautのスタート・ガイドに従って行った、アプリケーションの準備はすぐに完了しました。
このテストでは、執筆時点で最新リリースだったMicronaut 1.3.6を使いました。使用したコマンドを示します。スタート・ガイドに記載されるような内容とほとんど同じです。
mn create-app example.micronaut.complete mv complete micronaut-getting-started cd micronaut-getting-started ./gradlew build docker build -f Dockerfile -t shelajev/micronaut-getting-started .
Dockerfileには、Micronautのサンプルで提供されているデフォルトのものを使いました。
FROM adoptopenjdk/openjdk13-openj9:jdk-13.0.2_8_openj9-0.18.0-alpine-slim COPY build/libs/complete-*-all.jar complete.jar EXPOSE 8080 CMD ["java", "-Dcom.sun.management.jmxremote", "-Xmx128m", "-XX:+IdleTuningGcOnIdle", "-Xtune:virtualized", "-jar", "complete.jar"]
アプリを実行したところ、起動時間が1.6秒だったことが報告されました。
> docker run --rm --memory 128M -p 8083:8080 shelajev/micronaut-getting-started 23:02:02.716 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 1646ms. Server Running: http://ffe91862cc6d:8080
Quarkus:Quarkusは、「KubernetesネイティブJavaスタック」を自認しています(実のところ、プロジェクトのWebサイトには「Quarkusでは、楽しく使える統合フルスタック・フレームワークを提供しています。皆さんが使っているお気に入りの最高品質ライブラリを活用しており、その数は50を超えて増え続けています」とあります)。
Quarkusでも同じ実験を行い、結果を確認しました。使用したQuarkusガイドの内容は、他のガイドとほとんど同じです。
Dockerイメージのビルドでは、次のコマンドを実行しました(今回は、Mavenコマンドを使いました)。
mvn io.quarkus:quarkus-maven-plugin:1.5.1.Final:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=getting-started \
-DclassName="org.acme.getting.started.GreetingResource" \
-Dpath="/hello"
cd getting-started
./mvnw quarkus:add-extension -Dextensions="container-image-docker"
./mvnw clean package -Dquarkus.container-image.build=true
Quarkusアプリケーションを実行したところ、2秒で起動というまずまずの結果でした。
> docker run --rm --memory 128M -p 8083:8080 shelajev/quarkus-getting-started:1.0-SNAPSHOT exec java -Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager -XX:+ExitOnOutOfMemoryError -cp . -jar /deployments/app.jar __ ____ __ _____ ___ __ ____ ______ --/ __ \/ / / / _ | / _ \/ //_/ / / / __/ -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \ --\___\_\____/_/ |_/_/|_/_/|_|\____/___/ 2020-06-16 23:16:14,161 INFO [io.quarkus] (main) quarkus-getting-started 1.0-SNAPSHOT on JVM (powered by Quarkus 1.5.1.Final) started in 2.108s. Listening on: http://0.0.0.0:8080 2020-06-16 23:16:14,217 INFO [io.quarkus] (main) Profile prod activated. 2020-06-16 23:16:14,218 INFO [io.quarkus] (main) Installed features: [cdi, resteasy]
この段階では、Micronautが最適なのだろうかとおそらく思うでしょう。これまでのテストでは、すべて数秒で起動しました。Micronautがわずかに勝っていたため、これが最適かもしれません。しかし、とてもシンプルな「初めてのhelloworld」設定でさまざまなフレームワークを実行した本当の目的は、GraalVM Native Imageを使ってアプリケーションを実行する場合と比較するベースラインを確認することです。
GraalVM Native Imageの導入
本記事で取り上げたフレームワークのうち、少なくとも2つではGraalVM Native Image機能が十分に動作します。そこで今回は、別の限界として、クラウドで使う環境と同じような、かなり制約が強い環境において、起動時間の上限のようなものを確認しようと思います。
ここでは、現在進行中のSpring-GraalVM-native機能を使い、サンプルSpringフレームワーク・アプリケーションによって同じテストを実施しました。現在、このプロジェクトはアルファ版の状態です。主な目的は機能を動作させることで、高速化の前の第1段階にあります。
実際、この機能の最新版である0.7.0リリースを紹介したSébastien Deleuze氏の記事では、今後のリリースをターゲットにした最適化関連の作業について触れています。つまり、この作業はまだ完了していないということです。そのため、以下の結果は、まだまだ最適化されていない数値だと考えてください。
リポジトリの手順に従い、「actuator-webmvc」サンプルをビルドしました。ビルド・プロセスでは、いくつかの統計情報が表示され、期待できそうな数字でした。
Build memory: 7.08GB Image build time: 281.9s RSS memory: 116.6M Image size: 95.4M Startup time: 0.211 (JVM running for 0.215)
違う点があります。アクチュエータ付きのwebmvcを使ったSpringアプリケーションのビルド・プロセスの起動時間は、メモリの消費が128 MB未満であるにもかかわらず、200ミリ秒です。これは、ヒープ・メモリではなく、Resident Set Size(RSS)のメモリ(物理的に常駐するメモリ)である点に注意してください。
次に、実行可能ファイルをDockerイメージに格納しました。以下にDockerfileを示します。ここでは、distrolessコンテナを使いました。これは、アプリケーションがリンクするライブラリ以外はほとんど何も含まない小さなイメージです。libzを追加するために、マルチステージ・ビルドを使用しました。現在のところ、ネイティブ・イメージにはこれが必要です。
FROM debian:stable-slim AS build-env FROM gcr.io/distroless/base COPY actuator-webmvc /app COPY --from=build-env /lib/x86_64-linux-gnu/libz.so.1 /lib/x86_64-linux-gnu/libz.so.1 ENTRYPOINT ["/app"]
このイメージを実行したところ、次の結果が表示されました。
``` > docker run --rm --memory 128M -p 8083:8080 shelajev/spring-graalvm-native-actuator-webmvc . ____ _ __ _ _ /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: 2020-06-16 23:42:11.327 INFO 1 --- [ main] com.example.demo.DemoApplication : Starting DemoApplication on 99ea19f6bf3c with PID 1 (/app started by root in /) 2020-06-16 23:42:11.328 INFO 1 --- [ main] com.example.demo.DemoApplication : No active profile set, falling back to default profiles: default 2020-06-16 23:42:11.763 INFO 1 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http) Jun 16, 2020 11:42:11 PM org.apache.coyote.AbstractProtocol init INFO: Initializing ProtocolHandler ["http-nio-8080"] Jun 16, 2020 11:42:11 PM org.apache.catalina.core.StandardService startInternal INFO: Starting service [Tomcat] Jun 16, 2020 11:42:11 PM org.apache.catalina.core.StandardEngine startInternal INFO: Starting Servlet engine: [Apache Tomcat/9.0.36] Jun 16, 2020 11:42:11 PM org.apache.catalina.core.ApplicationContext log INFO: Initializing Spring embedded WebApplicationContext 2020-06-16 23:42:11.767 INFO 1 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 438 ms 2020-06-16 23:42:11.771 WARN 1 --- [ main] i.m.c.i.binder.jvm.JvmGcMetrics : GC notifications will not be available because MemoryPoolMXBeans are not provided by the JVM 2020-06-16 23:42:11.820 INFO 1 --- [ main] o.s.s.concurrent.ThreadPoolTaskExecutor : Initializing ExecutorService 'applicationTaskExecutor' 2020-06-16 23:42:11.845 INFO 1 --- [ main] o.s.b.a.e.web.EndpointLinksResolver : Exposing 13 endpoint(s) beneath base path '/actuator' Jun 16, 2020 11:42:11 PM org.apache.coyote.AbstractProtocol start INFO: Starting ProtocolHandler ["http-nio-8080"] 2020-06-16 23:42:11.870 INFO 1 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path '' 2020-06-16 23:42:11.884 INFO 1 --- [ main] com.example.demo.DemoApplication : Started DemoApplication in 0.573 seconds (JVM running for 0.579) 2020-06-16 23:42:21.923 INFO 1 --- [nio-8080-exec-2] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet' 2020-06-16 23:42:21.924 INFO 1 --- [nio-8080-exec-2] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms Started DemoApplication in 0.573 seconds (JVM running for 0.579)
アノテーションではなく、機能Beanを明示的に定義することでアプリケーションを構成すれば、起動時間をさらに短縮することができます。
~/repo/java-magazine-framework-comparison/spring-graalvm-native took 8s > docker run --rm --memory 128M -p 8083:8080 shelajev/spring-graalvm-native-jafu . ____ _ __ _ _ /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/ ___)| |_)| | | | | || (_| | ) ) ) ) ' |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot :: Jun 17, 2020 8:49:47 AM org.springframework.boot.StartupInfoLogger logStarting INFO: Starting application on e4c2037ec2d8 with PID 1 (started by root in /) Jun 17, 2020 8:49:47 AM org.springframework.boot.SpringApplication logStartupProfileInfo INFO: No active profile set, falling back to default profiles: default Jun 17, 2020 8:49:47 AM org.springframework.boot.StartupInfoLogger logStarted INFO: Started application in 0.018 seconds (JVM running for 0.02) jafu running
これは高速です。
まとめ
本記事では、Javaエコシステムで広く使われているいくつかのフレームワークについて見てきました。各フレームワークについて、利用可能なメモリを128 MBに固定(クラウドのサーバーレス・ワークロードでもっとも安価なオプションである可能性が高いため)し、かなり制約の強い環境で実行して報告された起動時間を記録しました。
このプロジェクトは、フレームワークを比較したものではありません。それよりも、GraalVM Native Imageを使ってSpringアプリケーションを実行するという実験と、試験運用版であるSpring-GraalVM-nativeプロジェクトを紹介したいと考えました。実験では、GraalVM Native Imageで実行していない他のアプリケーションを大幅に上回るパフォーマンスが確認されました。そこから、ネイティブ・イメージを十分にサポートしていないフレームワークを選択した場合、サービスの低下が発生する可能性があることがわかります。特に、クラウドでのパフォーマンスが重要な場合には、それが当てはまります。
![]() |
Oleg ŠelajevOleg Šelajev(@shelajev):Oracle Labsのデベロッパー・アドボケート。高パフォーマンスで埋込み可能な多言語仮想マシンGraalVMに携わる。VirtualJUG(オンラインJavaユーザー・グループ)およびGDG Tartu(エストニア)の主催者。空いた時間を使い、動的なシステム・アップデートとコード進化を研究テーマとして、博士号取得を目指している。2017年にJava Championに選出。 |

