🔊

AmplifyでLambda(java)にgraalVMのnative-imageをDeployする(Micronaut)

2022/02/17に公開
1

前提

  • 過去の(苦い)経験からインフラがスケールしやすいサーバレスでサービスを構築したい。
  • 既に歴史あるOSSがありそれらを利用するためにjavaを採用している
     (Scala,Kotlinでも論理的には良かったが、最終的に採用難になるのではとの懸念)
  • 効率化のためにAmplifyを既に採用している。

課題

  • Amplify×Lambda(Java)で業務アプリを開発。
  • しかしながら、初回の起動に10秒以上かかることが多かった。
  • そこで、プロビジョニングされた同時実行数を設定した
    • プロビジョニングされた同時実行設定を設定する
    • かつ、CloudWatchによるMetricsの監視を行い、6割以上使用率が増えた場合には同時実行設定数を増やす(AutoScale)
    • お片付けとして夜になると同時実行数を元に戻す。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-concurrency.html

この方法論の問題点

  1. 同時実行設定数の監視は数分おきであり、なだらかなスケーリングにならない。
  2. Lambda関数にてメモリ設定を上げ、高性能のインスタンスを割り当てると、同時実行数が多いと費用に大きく跳ねる。のでパフォーマンスチューニングの際の費用が一足飛びに上がるし、なんか勿体ない。
  3. 何と言っても確率としては低いもののcold startを引いてしまった時のUXが最悪。

なので全ての元凶であるjavaのLambda起動時問題は積年(1年立ってないから気持ち的には)の課題。

GraalVM native-image

native-imageと言う形式のバイナリを作成することで即実行可能な形式にすることで、初回起動速度を上げれるらしい!

以下、引用

こうした課題に対して、Oracle が打ち出したのが GraalVM と総称される一連の技術です。

GraalVM にはいくつかの技術が含まれるのですが、今回重要なのは native-image と呼ばれる技術です。native-image をカンタンに説明すると「Java プログラムを事前コンパイル (AOT、Ahead-Of-Time) して、単独実行可能なネイティブバイナリを生成するツール」です。C 言語や C++ 言語などで開発したときと似ていますね。

ネイティブという言葉から想像しやすいように、native-image で生成されたネイティブバイナリは、次のメリットがあります。

非常に高速に起動する
必要なメモリが少なく済む
これはまさに、今回の実験でお見せしました Java ランタイムの課題にピッタリですね。

https://aws.amazon.com/jp/builders-flash/202110/jvm-lambda-function/?awsf.filter-name=*all

実は、Java コミュニティにはすでに、GraalVM を活用して Lambda 開発を助けるフレームワークがいくつか登場しています。有名なところでは、Spring Cloud Function、QUARKUS、MICRONAUT の 3 つです。

このあたりを中心にFWを探してみます。

Spring Native vs Quarkus vs Micronaut

他にもあると思いますが取り急ぎ3つをざっくり比較。

Spring Native

https://docs.spring.io/spring-native/docs/0.11.2/reference/htmlsingle/

The goal of this project is to incubate the support for Spring Native, an alternative to Spring JVM, and provide a native deployment option designed to be packaged in lightweight containers. In practice, the target is to support your Spring applications, almost unmodified, on this new platform.

Springで書いたソースをnative-imageにできるだけ少ない変更で変換できるようにすることを目標としているようだ。

Quarkus

https://betterprogramming.pub/which-java-microservice-framework-should-you-choose-in-2020-4e306a478e58

The goal of Quarkus is to make Java a leading platform in Kubernetes by allowing faster startup, low memory consumption, and near-instant scale up in container-orchestration platforms.

QuarkusはKubernatesの起動初速を上げるようなモチベーションで開発されていそう

Micronaut

https://micronaut.io/2020/04/07/micronaut-vs-quarkus-vs-spring-boot-performance-on-jdk-14/

速そう(安直)。と言うことと、サーバレスのこの問題自体を解決するために開発されていそう。

At the same time Micronaut aims to avoid the downsides of frameworks like Spring, Spring Boot and Grails by providing:

Fast startup time
Reduced memory footprint
Minimal use of reflection
Minimal use of proxies
No runtime bytecode generation
Easy Unit Testing

https://docs.micronaut.io/snapshot/guide/index.html

選定

  • 現行Springの資産はない(!)のでSpring Nativeを利用する理由が特にない
  • QuarkusはKubernatesの起動初速を上げるようなモチベーションで開発されていそう?
  • Micronautは何と言っても速いらしいこと、開発者向けの各種ソリューションが多そう

で、Micronautを採用。

Micronautで作ったnative-imageをAWSLambdaに上げてみる

記事を書いておいて恐縮なのですが、ほとんどこの動画で説明されています。
以下動画を補足する形で内容を記載します。

https://www.youtube.com/watch?v=vYmk5-JynvQ

注意点

①流れるように操作していますが、macで作成したnative-image(3:10)をAWS Lambdaで起動することはできません。なのでどこかのLinuxでビルドし直したものをアップロードしている(4:35)ようです。(コメント欄にも同様の指摘あり。ちなみにmacのnative-imageで起動すると exit 126とかになる)
②後に記載するがMicronaut3.3.1 と micronaut gradle plugin 3.2.0だとこの動画で実施しているようにカスタムしてmainクラスを使用する際の設定、mainClassを application から指定することができなかった。直接 graalVMのplugin側の引数として渡すことで起動する


この辺りの手順(micronaut& AWS Lambda & nativeImage)に関してはこちらに公開されてました(2022/3/2 追記)
https://guides.micronaut.io/latest/mn-serverless-function-aws-lambda-graalvm-gradle-java.html


以下は動画の流れを踏まえた上での手順補足

1. micronautプロジェクトをコピー

https://micronaut.io/launch/

必要なプロジェクトがDLとかgitHubにpushできる。こういうのほんとに助かる。
適当に選ぶと色々ついてくるが、DB周りのツールやDataDogなどはハンズオンでは避けた方が無難。
基本的には gradleに依存関係がついてくるようなイメージ。(micronaut-cli.ymlと言うファイルに記載されるようだ)
動画のとおりgraalVMのnative-image周りは必須。

2. GraalVMをDLしてPATHを通す

参考ドキュメントはこちら

https://guides.micronaut.io/latest/micronaut-creating-first-graal-app-gradle-java.html
 
sdkmanでも直接DLしても。
直接の人はここからGitHubに。macOSってdarwinなんですね。

https://www.graalvm.org/downloads/

3. Micronautの基本構造 (1:26~)

Micronautはフルスタックフレームワークなので今回紹介されているところだけかいつまんで概要を。
動画の通りなんだけど一回目はあまり頭に入ってこなかったので、メモ。

  • mainClassを指定してビルドするとnative-imageがどこを起動するのかを定義。例えばXXXRuntimeを規定する。(gradle application mainClassで定義。ここがnative-image独自のところ)

  • XXXRuntime extends AbstractMicronautLambdaRuntime.mainが呼ばれ、createRequestHandlerでHandlerクラスを規定。

  • YYYHandler extends MicronautRequestHandler.executeが呼ばれる と言う形。
    ここからはService層に分けることがFWのお勧めっぽい。

ソースコードの書き方自体に関しては省略します。以下は参考程度に。

https://www.youtube.com/watch?v=dboYoxQFePo&t=486s

4. native-imageをビルドしてあげてみる。

ポイントは3つ

  • Lambdaのruntimeをjava11ではなくprovided.al2(Amazon Linux2でユーザ独自のブートストラップを提供する)にすること(3:37)
  • よりよき人生とするためにnative-imageとbootstrapをzip化すること(3:05)
  • 1つ目の動画の通りのcustomのmainClass利用の場合に、build.gradleのapplicationでmainClassが指定できない(=mainClassが常にMicronautLambdaRuntimeになってしまう)場合はgraalvmPluginで以下の引数を渡す。

micronaut gradle pluginはこのgraalvmPluginを透過的に扱ってる(的な記事をどっかでみたような)のでこう言うことができたはず。

application {
    mainClass="com.example.XXXRuntime"  //だめでした
    mainClass.set("com.example.XXXRuntime") //これもだめ
}

graalvmNative {
    binaries {
        main{
            buildArgs.add('-H:Class=com.example.XXXRuntime')
        }
    }
}

何やってるかって言うと native-imageをビルドする際に引数としてどこをmainClassとするかを指定する(-H:Class=)んですが、application(micronaut gradle plugin)で指定しても(上記だめ参照)うまくそれが伝達しなかったんですよね。ので直書きしてやりました、です。

(なんかmicronaut gradle pluginのソースみるにAWS Lanbdaで特定用件満たすとだと強制的に上書きって話もあるんですが、とにかくうまくいかなかったのでこれで回避してます。micronautさんの意図とずれてたら申し訳ないですが。)

native-imageとの処理速度比較結果

私の簡単な処理でのmicronaut all.jarとnative-imageの処理比較はこちらです。
(ソースコードが同じで、通常のjarでデプロイしたものか、native-imageでデプロイしたものかと言う差があります。通常のjarでデプロイしたものがall.jarと書いているものです。)

(間隔をあけない)2回目以降の起動は速くなって差がわからなくなるので初回起動の内容です。

この関数の場合は処理速度が大幅に向上したと言って良いのではないかと思います。
じゃあこれ今使っているAmplifyでできたりするといいよね、と言う話になりますよね。

AmplifyでLambda(java)にgraalVMのnative-imageをDeployする(Micronaut)

ここでやっとのタイトルコールです。

amplify add functionで作ったLambdaに先ほど作ったmicronautのプロジェクトをコピーしていきます。
注意点としてはbootstrapで別のプロジェクト名になってる人とかはその辺り確認お願いします。
bootstrapが間違ってるとLambdaで起動した時にexit 1とかでエラーになります。

気をつけることは3つだけです。
① amplify push をするのはLinux (また言ってるよこの人)
② gradle のバージョンを上げよう(7.3.1を利用しています)
 当然pathも旧バージョンではなくそっちに通しましょうね。
 ※この辺り私はすんなりいきましたが、問題が起きるかもしれません。
  余分な環境作るなどリスクを排してトライしてみてください。(Cloud9推し)
  開発環境大事ですし、あなたの時間はもっと大事です。
③ xxx-cloudformation-template.jsonのruntimeをprovided.al2にしよう

これを

 "Runtime": "java11",
        "Layers": [],
        "Timeout": 25

こう

 "Runtime": "provided.al2",
        "Layers": [],
        "Timeout": 25

さて、残る手順としてはgradle buildのタスクに nativeCompileを絡ませる、と言うことになりますがそれがこちらになります。該当functionのbuild.gradleのbuildZipあたりを以下のように変更

task buildZip(type: Zip) {
    mustRunAfter check          // testの前とかにこの時間かかるビルドしたくないので
    dependsOn nativeCompile     // buildZipやる前にnativeCompile
    from "./build/native/nativeCompile" // この階層にnaitve-imageができます
    from "./bootstrap"                             // bootstrapをzipに混ぜ込んじゃいます
    archiveFileName = 'latest_build.zip' // 初めからこの名前だからこの名前が良さそう
}
build.dependsOn buildZip  // おそらく元からある?

amplify push で nativeイメージがDeployされますね

今後の課題

macで amplify push できないし、普通の開発でnative-imageなんかいらないよね?

前提

①ローカルでの開発 MacOS ->amplify push -> amplify env dev
②評価/本番環境 git hub-> Amplify Consoleからの自動ビルド(Linux)による amplify push -> amplify env test etc

①の時はnative-imageのdeployなんてそこまで要らないことと、そもmacからなのでnative-imageのpush不可。なのでこの場合は通常のjarをdeployしたい。
反面、②評価/本番環境などに適用する際はLinuxを使うのでnative-imageで適用したい。

(それだと評価環境でしかnative-imageの評価ができなくて困るだろうという鋭いご指摘があると思いますが、micronautにはgradleのタスクとしてnativeTestがありnative binaryを起動する、と言うところまではできるのである程度は担保できるのでは無いかと、、(こちらは未検証))

具体的な実装① Lambdaのruntimeの分岐

amplifyのenv(amplify env list のenv)名称で開発環境かどうかを区別し、開発環境であれば、java11のruntimeでCfnを定義。それ以外ではnative image向けにprovided.al2を指定するようにします。

Amplify内の対象functionのXXX-cloudforamation-template.jsonのConditionsに以下を追記

"Conditions": {
 "IsLocalDevelopmentEnv": {
      "Fn::Equals": [
        {
          "Ref": "env"
        },
        "dev"
      ]
    }
}

で、Runtimeを指定するところで分岐。

 "Runtime": {
          "Fn::If": [
            "IsLocalDevelopmentEnv",
            "java11",
            "provided.al2"
          ]
         }

具体的な実装② macの時はgradleのbuildZipでnaitveBuildしない

これはOSで判定したいと思います。(むしろこちらの方が正しい気が)

System.properties['os.name'].toLowerCase().contains('linux')

ちょっと①とはずれますが、私の環境だと問題が無いので一旦これで対応しました。

Amplify内の対象function内のbuild.gradle

task buildZip(type: Zip) {
    if(System.properties['os.name'].toLowerCase().contains('linux')){
        println("Linux so native build will proceed");
        mustRunAfter check
        dependsOn nativeCompile
        from "./build/native/nativeCompile"
        from "./bootstrap"
    }else{
        println("not Linux so normal build will proceed");
        from compileJava
        from processResources
        into('lib') {
            from configurations.runtimeClasspath
        }
    }
    archiveFileName = 'latest_build.zip'
}

参考

https://stackoverflow.com/questions/11235614/how-to-detect-the-current-os-from-gradle

Amplify Consoleからのビルドができない

Amplifyにはbranchにマージすると自動でCfnに基づいたインフラや成果物を環境にDeployしてくれる素晴らしいCI/CDフローがあります。
その際にビルドをどのECRコンテナで実行するかを指定することができるのですが、このイメージ内にあらかじめgraalVm等をインストールしておくことで、graalVMでのコンパイル&Deployが理屈上、可能になります。

そこでamplifyのjava用に公開したイメージがあるのでそれを改変したのですが、実際にビルドしてみるとnativeCompileのタスクでメモリ不足のためエラーになりました。

OpenJDK 64-Bit Server VM warning: INFO: os::commit_memory(0x00000006e9d00000, 1088946176, 0) failed; error='Not enough space' (errno=12)
Error: Image build request failed with exit status 1

これはAmplify Consoleからのビルド用インスタンスのメモリ上限が固定(7G)と言うことに起因します。

これを設定等で可変とするために既にFeatureRequestは上がっているみたいなので、要望を通りやすくするためにもThums upしてくれると有難いです。
https://github.com/aws-amplify/amplify-console/issues/654

取り急ぎ、暫定的にAmplify Consoleの時はnaitveCompileをしないようにするなどして、実際のnativeCompile系のバックエンドのpushに関してはCloud9から手動で実施するなどの運用にしようと思います。(16GのUbuntuでは該当の関数のnativeCompileが動作することは確認済み)

Discussion