🌊

ScalaでGraalVM native-imageを作ってAWS Lambdaで動かす

2021/04/18に公開
2

はじめに

あのプログラミング言語だったらこんなことするのラクなのに〜 を Scala でやってみる第一弾です。
Scala やっててこんなこと思ったりしませんか?

  • CLI ツールを作ってみたいけど実行マシンに JRE 入れないといけない
  • AWS Lambda 上で動かしたいけど Java アプリケーションになるのでコールドスタートが遅い

それいい感じにできるソリューションあります!

サンプルの完成品はコチラ ignission/aws-lambda-graal-native-scala-example からご覧いただけます。

このサンプルでは、serverless frameworkを使って Lambda へのデプロイまで行っていますが、build.sbtproject/plugins.sbtの内容を流用していただければ CLI ツールとしても構築することが可能です。
よく使いそうな機能は一通り入れています。あ、CLI ツール作るときにオプションを変換するライブラリはscoptがいいみたいです。

  • HTTP Client
  • JSON
  • Logging

その他シリーズの記事はコチラ:

要約

  • sbt-native-imageという sbt plugin を使うと、 GraalVM をインストールせずに native-image 化ができる
  • AWS Lambda のカスタムランタイムの仕様に則って実装する
  • 依存するライブラリは少ないほうが後々ラク
  • リフレクション使ってるライブラリだと設定が大変になるので選定が大事
  • AWS SDK for Java を使うとビルドに失敗したので今後の課題

説明

GraalVM のセットアップ

native-image を作ろうとすると、最初に GraalVM のインストールが必要になるのですが、sbt-native-imageという sbt plugin を使うことでインストール作業が不要になります。

plugin を追加します。

project/plugins.sbt
addSbtPlugin("org.scalameta" % "sbt-native-image" % "0.3.0")

plugin を有効化します。

build.sbt
enablePlugins(NativeImagePlugin)

ビルドコマンドは次の通りです。

sbt nativeImage

リフレクション設定

Logging にリフレクションが含まれているので、以下のファイルをgraalディレクトリに配置します。
本当は akka を使いたかったのですが、設定が大変すぎたので諦めて Logging だけの構成にしました。

  • graal/reflect-config.json
  • graal/reflectconf-jul.json

ビルド設定

大まかな設定は以下のようになります。

build.sbt
nativeImageOptions ++= List(
  "-H:+ReportExceptionStackTraces",
  "-H:IncludeResources=.*\\.properties",
  "-H:ReflectionConfigurationFiles=" + baseDirectory.value / "graal" / "reflect-config.json",
  "-H:EnableURLProtocols=http,https",
  "-H:+TraceClassInitialization",
  "--initialize-at-build-time=scala.runtime.Statics$VM,scala,ch.qos,org.slf4j,jdk,javax,org.apache,com.sun",
  "--no-fallback",
  "--no-server",
  "--allow-incomplete-classpath"
)

ReflectionConfigurationFiles

"-H:ReflectionConfigurationFiles=" + baseDirectory.value / "graal" / "reflect-config.json",

先程のリフレクション設定を読み込みます。

EnableURLProtocols

"-H:EnableURLProtocols=http,https",

外部に HTTP リクエストを送るために必要になります。

initialize-at-build-time

--initialize-at-build-time

ここがプロジェクトによって変わってくると思います。
例えばsbt nativeImageコマンドでビルドしようとしたときに、以下のようなエラーが発生することがあります。

Error: Classes that should be initialized at run time got initialized during image building:
(省略)
Try marking this class for build-time initialization with --initialize-at-build-time=com.sun.org.apache.xerces.internal.impl.XMLDTDScannerImpl

こんなときは--initialize-at-build-timeにカンマ区切りで package を追加してあげます。ちゃんと調べてないですが、package が多くなるとどんどんビルドに時間がかかっている気がします。

AWS Lambda のカスタムランタイム

カスタムランタイムで Lambda を動かすには、カスタムランタイムの仕様通りに実装する必要があります。
生のコードはMain.scalaを参照ください。
抜粋して説明していきます。

src/main/scala/example/Main.scala
  while (true) {
    val program = for {
      invocation <- getNextInvocation()
      _          <- Logger.info(s"Request body: ${invocation.body}")
      requestId  <- getAwsRequestId(invocation)
      _          <- Logger.info(s"Request id: $requestId")
      result     <- execute("Hello! GraalVM native-image with Scala!")
      _          <- returnResponse(requestId, result)
    } yield result

    runtime.unsafeRun(
      program
        .provideLayer(layer)
        .fold(
          error => println("Error: " + error.toString()),
          value => println("Result: " + value.toString())
        )
    )
  }

1. Lambda の指定されたエンドポイントにアクセス

環境変数AWS_LAMBDA_RUNTIME_APIで渡されるエンドポイントにリクエストを送って、レスポンスを取得します。
ここでは必ずリクエストに成功するわけではないようなので、エラーが発生しても無限ループを抜けない(例外を投げない)ようにする必要があります。

src/main/scala/example/Main.scala
  private val awsLambdaRuntimeApi = System.getenv("AWS_LAMBDA_RUNTIME_API")

  private def getNextInvocation(): ZIO[AppType, AppError, HttpResponse] =
    Http
      .get(s"http://${awsLambdaRuntimeApi}/2018-06-01/runtime/invocation/next")
      .mapError(e => AwsRequestFailed(e.getMessage()))

2. レスポンスから AWS Request ID を取得

先程のレスポンスヘッダにAWS Request IDが含まれているので取り出します。

src/main/scala/example/Main.scala
  private def getAwsRequestId(response: HttpResponse): IO[AppError, String] =
    IO.fromEither(
      response.headers
        .get("Lambda-Runtime-Aws-Request-Id")
        .map(Right(_))
        .getOrElse(Left(RequestIdNotDefined(response.headers)))
    )

3. 何かしらの処理をする

今回の例では固定の文字列を返しているだけですが、1のレスポンスボディにユーザーからのリクエストが入っているのでそこから何かしらの処理をするのが一般的かなと思います。

src/main/scala/example/Main.scala
  private def execute(message: String): ZIO[AppType, AppError, String] =
    // write some logic ...
    UIO.effectTotal(message)

4. 処理の結果を返す

AWS Request IDが含まれる特定のエンドポイントに POST リクエストを送ります。

src/main/scala/example/Main.scala
  private def returnResponse[A](requestId: String, value: A)(implicit
      encoder: Encoder[A]
  ): ZIO[AppType, AppError, HttpResponse] = {
    import example.formatters.APIGatewayJsonFormats._

    val response = APIGatewayResponse.success(value.asJson.noSpaces)

    Http
      .post(
        s"http://${awsLambdaRuntimeApi}/2018-06-01/runtime/invocation/${requestId}/response",
        response.headers,
        response.asJson.noSpaces
      )
      .mapError(e => AwsResponseFailed(e.getMessage))
  }

指定の JSON フォーマットは以下のようになっています。

src/main/scala/example/formatters/APIGatewayJsonFormats.scala
  implicit val responseEncoder: Encoder[APIGatewayResponse] =
    new Encoder[APIGatewayResponse] {
      override def apply(a: APIGatewayResponse): Json =
        Json.obj(
          ("statusCode", a.statusCode.asJson),
          ("headers", a.headers.asJson),
          ("body", a.body.asJson),
          ("isBase64Encoded", a.isBase64Encoded.asJson)
        )
    }

5. ビルド

次のコマンドで native-image を作成します。

sbt dist

成功すると以下の設定により、serverless/dist/bootstrapというファイルが作成されます。

build.sbt
nativeImageOutput := file("serverless") / "dist" / "bootstrap",

6. デプロイ

次のコマンドでデプロイします。

sbt deploy

デプロイのプロセスも sbt の task として定義しています。実態は serverless コマンドを叩いているだけです。
serverless でデプロイできるように Lambda の仕様に合わせています。

project/Serverless.scala
object Serverless {
  def deploy: Unit = {
    val logger        = new Logger
    val distDir       = new File("serverless/dist")
    val serverlessDir = new File("serverless")

    Process("chmod 755 bootstrap", distDir) ! logger.log
    Process("zip lambda.zip bootstrap", distDir) ! logger.log
    Process("yarn", serverlessDir) ! logger.log
    logger.print()
    logger.flush()

    Process("./node_modules/serverless/bin/serverless.js deploy", serverlessDir) ! logger.log
    logger.print()
  }
}

おわりに

native-image 化から、Lambda にデプロイするという応用編まで一気にやってみました。
native-image にすることで起動が早くなり、また JVM も必要なくなるので CLI ツールとして配布したり使ってもらう敷居が下がったかなと思います。
一方で普段使い慣れたライブラリを使おうとするとリフレクションの設定に苦労するので、ライブラリを最小限にして始めてみるといいです。
Lambda としても使えて、Scala なら難しい処理も結構スッキリ書けるのでおすすめです。もっと使い込もうとすると AWS SDK for Java を入れる必要が出てきて、依存すると大量のビルドエラーが発生したので今後の課題にしたいです。

これを見て Scala に興味を持ってもらえると嬉しいです!

参考

Discussion

110416110416

本当は akka を使いたかったのですが、設定が大変すぎたので諦めて Logging だけの構成にしました。

sbt native imageの nativeImageRunAgentでリフレクションやjniの設定ファイルを自動生成できるので、もし試していなければ一度試してみるといいかと。

Shoma NishitatenoShoma Nishitateno

コメントありがとうございます!sbt-native-imageにもAgent呼べる機能あるんですね。
また試してみます!