はじめに
ブランドソリューション開発部プロダクト開発チームの木目沢です。
Fulfillment by ZOZO(以下、FBZ)で提供しているAPIの開発に携わっています。以前「FBZにおけるドメイン駆動設計(DDD)とサーバーレスアーキテクチャを組み合わせた設計戦術」という記事を公開しました。そこでは、AWS Lambdaを中心としたサーバーレスアーキテクチャを採用していること、ドメイン駆動設計でAWSのサービス処理とビジネス処理を分離していることをご紹介しました。
FBZはリリース前の設定時にはJavaも検討していました。しかし、結果として採用を見送ることにしました。その理由とリリースから4年が経過した今、改めてJavaに関して調査した結果を本記事ではご紹介します。
JavaではなくPythonを選択した理由
FBZの設計をしていた当時、Lambdaで使用可能な言語は、Node.js、Python、Javaの3つでした。FBZは最終的にPythonを選択し実装されていますが、設計の途中までは以下の理由からJavaを最有力候補として考えていました。
- ビジネスロジックが複雑でドメイン駆動で設計していくことを前提としていたため、型のある言語が必要だった
- Scalaの実装経験を持つメンバーが多く、Javaへの親和性が高かった
- AWS Lambdaで使用できる言語かつ、Scala同様のJVM言語であるJavaが一番扱いやすい言語だった
しかし、サーバーレスアーキテクチャを検討していくなかで、以下の理由からJavaを採用することは難しいと感じるようになってきました。
- AWS Lambdaはハンドラーが呼ばれる度に起動される
- Javaの場合、起動が許容できないほど遅い
- 例えば、Pythonだと起動から終了まで0.3秒程のAWS Lambdaの処理が、Javaだと約7秒かかる
- デプロイパッケージサイズが圧縮済みで50MB、解凍して250MBという制約がある
- JavaでSpring Frameworkを使うだけでこの制約を超えてしまう
この2点の制約からJavaの採用を見送り、型ヒントが利用できるPythonを採用する方針にしました。
AWS Lambdaの進化
FBZリリースから4年が経ち、AWS Lambdaも進化しました。
特に以下の機能追加は「JavaでAWS Lambdaを実装できる」と十分に思わせてくれるものでした。
Lambdaレイヤー
AWS Lambda本体が使用するライブラリ群を「Lambdaレイヤー」という別レイヤーで管理することが可能になりました。これにより、Springなどのライブラリを含めない状態でAWS Lambdaを利用でき、圧縮後の50MBの制約を気にする必要がなくなりました。
ただし、「AWS Lambda本体 + Lambdaレイヤーの合計が250MBの制約」は残っているため、その点は引き続き考慮する必要があります。
Lambdaカスタムランタイム
あらかじめAWS側で用意されているランタイムはJava、Python、Node.js、Rubyです。しかし、それ以外のランタイムも利用できるようになりました。
AWS LambdaでJavaを利用する際のフレームワーク比較・検討
前述の2つの新機能を活かし、今回はベータ版のものも含めた3つのフレームワークでAWS Lambdaの実装を試してみました。
Spring Cloud Function
- フレームワークのライブラリをLambdaレイヤーに配置する
Micronaut & GraalVM
- ネイティブアプリにコンパイルし、Lambdaカスタムランタイムを利用して動作させる
Spring Native
- ベータ版
Spring Cloud Function
Spring Cloud FunctionはAWS LambdaをサポートしたSpring Frameworkです。Spring MVCのようなコントローラーの代わりに、java.util.function.Function
を実装したクラスがAWS Lambdaのハンドラーとして動作します。実装の詳細はドキュメントをご確認ください。これまでのSpring Frameworkの機能も利用できるので、Springに慣れたチームであれば容易に実装できるフレームワークです。
ところが、最低限必要なライブラリを追加するだけでjarファイルが20MB超えてしまいます。しかし、それをLambdaレイヤーを利用し、ライブラリと本体を分離して配置することで解消可能です。
Gradleのマルチプロジェクト機能を利用すると子プロジェクトを作成できます。そのため、プロダクト本体の子プロジェクトとライブラリの子プロジェクトを作成します。そして、本体側はライブラリ側を参照するように依存関係を設定するとうまく両者を管理できます。本体側でライブラリ側を参照する際にはcompileOnly
としておくことで、ビルドファイルから除外されるので便利です。
以下はLambdaレイヤー側のbuild.gradle
の例です。
dependencies { implementation("org.springframework.cloud:spring-cloud-function-adapter-aws") implementation("org.springframework.cloud:spring-cloud-function-web") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") implementation("com.amazonaws:aws-lambda-java-events") implementation("com.amazonaws:aws-lambda-java-core") implementation("com.amazonaws:aws-lambda-java-log4j") // other implementation }
次に、プロダクト側のbuild.gradle
の例です。
dependencies { compileOnly project(":layers") // other implementation }
Lambdaレイヤーとプロダクト側のプロジェクトをそれぞれビルドし、AWS Lambdaにアップロードします。この方法で、容量の問題をある程度解決できます。
ただし、この状況では実行速度の問題がまだ残っています。それを解決する選択肢として、Lambdaカスタムランタイムを利用した新しい解決策であるMicronaut & GraalVMを紹介します。
Micronaut & GraalVM
Micronaut(マイクロノート)はSpring Framework同様、フルスタックのフレームワークです。
これまでのフレームワークはDIなどにリフレクションを使用していました。リフレクションは動的にクラスやフィールドにアクセスする技術ですが、それはJavaがJavaバイトコードにコンパイルされ、JVM上で動作することを活かしたものです。しかし、Javaの起動が遅い原因の1つがこのリフレクションの処理でもあるため、Micronautではリフレクションを使用しないように設計されています。
さらに、リフレクションを使用しないため、JVMにこだわる必要もなくなり、GraalVMを利用してネイティブアプリにコンパイルできます。実装の詳細はAWS Lambdaに焦点を合わせた公式ガイドをご確認ください。
MicronautのGradleプラグインがビルド時に圧縮まで自動的に行ってくれます。そのため、その圧縮ファイルをアップロードするだけで設定が完了できます。
アップロードする際には、以下の画像のようにカスタムアプリとして登録します。
MicronautのGradleプラグインはbootstrapファイルも内包してくれますので、「ユーザー独自のブートストラップを提供する」を選択してください。
MicronautのGradleプラグインを使うとbuildNativeLambdaタスクが追加されます。このタスクを実行することで、ネイティブアプリにビルドできます。以下はbuild.gradle
の例です。
plugins { id("io.micronaut.application") version "1.3.3" } micronaut { processing { incremental(true) annotations("micronaut_sample.*") } version = "2.3.0" runtime "lambda" } dependencies { compileOnly("org.graalvm.nativeimage:svm") implementation("io.micronaut:micronaut-validation") implementation("io.micronaut:micronaut-runtime") implementation("io.micronaut.aws:micronaut-function-aws") implementation("io.micronaut.aws:micronaut-function-aws-custom-runtime") // other implementation }
Micronautでネイティブアプリ化をすることで、AWS Lambdaの実行速度はかなり改善されます。しかし、今やSpring Framework以外のフレームワークを利用することに抵抗があるチームも多いかと思います。
そんな中、3月にSpring Nativeのベータ版が発表されました。ベータ版ではありますが、Spring Nativeの検証も実施しました。
Spring Native(ベータ版)
Spring NativeもMicronautと同様に、GraalVMを利用してコンパイルされます。一度JVMにコンパイルした後、AOTコンパイル(Ahead-Of-Time・事前コンパイル)する仕組みです。
Gradleプラグインも用意されています。Spring Cloud Functionの設定にプラグインを追加し、下記のように設定するだけで利用可能になります。これにより、bootBuildImageタスクが追加されます。
plugins { id "org.springframework.experimental.aot" version "0.9.1" } bootBuildImage { builder = "paketobuildpacks/builder:tiny" environment = ["BP_NATIVE_IMAGE": "true"] }
このGradleプラグインはDockerでビルドされるので、このプラグインを使う場合はAmazon ECRにpushして参照する必要があります。
実行可能ファイルに変換するGradleプラグインは現時点で用意されておらず、Mavenを利用する必要があります。詳細はリファレンスの2.2 Getting started with native image Maven pluginを参考にしてください。
また、Mavenのプラグインはbootstrapファイルの用意までは実施してくれないため、自作する必要があります。
実行結果の比較・検討
各フレームワークで、起動から任意の文字列を100回ループして終了するまでの時間を計測し、比較してみました。
Spring Cloud Function | Micronaut & GraalVM | Spring Native |
---|---|---|
約7秒 | 約0.6秒 | 約0.6秒 |
冒頭でJavaを諦めた理由として、起動が遅いと述べました。Spring Cloud Functionではその認識通り、約7秒の時間を要していました。Spring Cloud Functionを単体で使う場合は、Lambda関数の暖機をおこなうServerless WarmUp Pluginなどを利用しないと実用的ではありません。
一方、Lambdaカスタムランタイムを利用してMicronautやSpring Nativeを利用することで圧倒的に所要時間が短くなりました。
MicronautやSpring Nativeの起動時間は大きく短縮できますが、ネイティブアプリにコンパイルする時間がかかります。Micronautで4分30秒程かかりました。
これを致命的と判断するかどうかで、評価が分かれてきそうです。AWS Lambdaの場合、アクセスされるたびにアプリケーションが起動されるので、起動時間の速度が非常に重要です。その点でネイティブアプリにコンパイルできるフレームワークは重宝されます。
まとめ
AWS Lambdaの進化と、フレームワークの進化によって選択肢が広がっていることを実感できました。これは、アーキテクチャの検討の際にも選択肢が増え、理想のアプリケーションを実現させる武器になります。
ブランドソリューション開発部では、今後はJavaを使ったWebアプリの開発をしていくことも検討しています。システムによっては、FBZでのサーバーレスアーキテクチャの開発経験を活かして、Java + サーバーレスアーキテクチャを組み込むのも良いでしょう。
ブランドソリューション開発部では、サーバーレスアーキテクチャやドメイン駆動設計など、テクノロジーを活用しサービスを成長させたい仲間を募集中です。ご興味ある方はこちらからぜひご応募ください!