AWS LambdaをScala（Java 11）とGraalVMで

# はじめに

AWS Lambdaを標準で選択できるJava 11のランタイムで動かすと、コールドスタートの問題等があるので、メモリをかなり積んでスペックを上げないといけないですが、Lamdaは積んだメモリと実行時間でコストが算出されるため、実行時間にあまり差が出ない範囲で、メモリはできるだけ少なめに抑えたいところです。

標準で選択できるJava 11のランタイムではなく、GraalVMのnative imageを使ってカスタムランタイムを作成すると、少ないメモリで動かすことができ、コールドスタート時にも爆速起動が期待できます。

ただ、Scalaのバージョン、Javaのバージョン、GraalVMのバージョンの組み合わせにより、実行バイナリが作成できなかったり、作成できても実行時のエラーが解決できなかったり、AWSのライブラリが上手く使えなかったりと、一筋縄ではいかずに沼に嵌っていった経験をされた人も多いのでは。。

今回は、Scala 2.13.3、Java 11、GraalVM 20.1.0の組み合わせで、S3にアクセスし、引数で指定したバケット内のキーのリストを返すようなAWS Lambdaを作成してみます。

ちなみに今回の成果物は下記に置いてます。

https://github.com/imahiro-t/scala-graalvm-lambda-sample

# 実装

## 前準備

GraalVMでnative imageを作成する際には、assemblyしたjarファイルが必要になるので`assembly.sbt`に下記を追加しておきます。

```project/assembly.sbt
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.9")
```

また、今回はAWS LambdaへのINとOUTはJSON形式とするため、JSONパーサとしてcirceのライブラリを追加し、AWSのリソースとしてS3を使用するため、S3のライブラリを`Dependencies.scala`に追加します。

```scala:project/Dependencies.scala
import sbt._

object Dependencies {
  lazy val circeVersion = "0.13.0"
  val circeCore = "io.circe" %% "circe-core" % circeVersion
  val circeGeneric = "io.circe" %% "circe-generic" % circeVersion
  val circeParser = "io.circe" %% "circe-parser" % circeVersion
  val circeDeps = Seq(circeCore, circeGeneric, circeParser)

  lazy val awssdkVersion = "2.15.7"
  val awssdkUrlConnectionClient = "software.amazon.awssdk" % "url-connection-client" % awssdkVersion
  val awssdkS3 = "software.amazon.awssdk" % "s3" % awssdkVersion
  val awssdkDeps = Seq(awssdkUrlConnectionClient, awssdkS3)
}
```

`software.amazon.awssdk.url-connection-client`も別途追加していますが、こちらはAWSのライブラリ標準で使用されるHTTPClientではエラーとなるため、代替として使用するために追加しています。

## build.sbt

`build.sbt`は下記のようになります。

```build.sbt
import Dependencies._
import scala.sys.process._

lazy val lambdaBuild = taskKey[Unit]("Build GraalVM native image")

lazy val root = (project in file(".")).
  settings(
    inThisBuild(List(
      organization := "erin",
      scalaVersion := "2.13.3",
      version      := "0.1.0-SNAPSHOT"
    )),
    name := "scala-graalvm-lambda-sample",
    libraryDependencies ++= (circeDeps ++ awssdkDeps),
    mainClass in assembly := Some("bootstrap.Main"),
    assemblyJarName in assembly := s"scala-graalvm-lambda-sample_${version.value}.jar",
    test in assembly := {},
    assemblyMergeStrategy in assembly := {
      case PathList("module-info.class") => MergeStrategy.first
      case "codegen-resources/customization.config" => MergeStrategy.concat
      case "codegen-resources/paginators-1.json" => MergeStrategy.concat
      case "codegen-resources/service-2.json" => MergeStrategy.concat
      case "META-INF/io.netty.versions.properties" => MergeStrategy.concat
      case x =>
        val oldStrategy = (assemblyMergeStrategy in assembly).value
        oldStrategy(x)
    },
    lambdaBuild := {
      assembly.value
      val jarName = (assemblyJarName in assembly).value
      ("docker run -d -it --name graalvm-builder --rm oracle/graalvm-ce:20.1.0-java11 /bin/bash" #&&
        s"docker cp target/scala-2.13/$jarName graalvm-builder:server.jar" #&&
        "time docker exec graalvm-builder gu install native-image" #&&
        "time docker exec graalvm-builder native-image --verbose --initialize-at-build-time --enable-all-security-services --no-fallback -H:+TraceClassInitialization -H:+ReportExceptionStackTraces -H:EnableURLProtocols=http,https -H:+ReportUnsupportedElementsAtRuntime --allow-incomplete-classpath -cp server.jar --no-server -jar server.jar" #&&
        "docker cp graalvm-builder:server target/bootstrap" #&&
        "docker cp graalvm-builder:/opt/graalvm-ce-java11-20.1.0/lib/libsunec.so target/libsunec.so" #&&
        "docker stop graalvm-builder" #&&
        "zip -j target/bundle.zip target/bootstrap target/libsunec.so").!
    }
  )
```

ビルド時に`$ sbt clean lambdaBuild`とすることで`target/bundle.zip`というファイルが作成され、そのファイルをAWS Lambdaにカスタムランタイムとしてアップロードして実行することになります。

なお、lambdaBuild内では以下の処理を行っています。

1. oracle/graalvm-ce:20.1.0-java11のDockerイメージを起動
2. assemblyしたjarファイルをコンテナ内にコピー
3. コンテナ内にGraalVMのnative imageをインストール
4. jarファイルからnative imageを作成
5. 作成した実行ファイルと必要なライブラリをzipファイルに変換

4のnative imageを作成する際のパラメータが重要で、今回の、Scala、Java、GraalVMの組み合わせではこのパラメータで大丈夫でした。

## Main.scala

今回はサンプルということで、`Main.scala`にカスタムランタイムの実装と、本来のAWS Lambdaで記述するロジックの実装を行っています。

```scala:src/main/scala/bootstrap/Main.scala
package bootstrap

import java.net._
import java.net.http._
import java.time.Duration
import java.nio.charset.StandardCharsets.UTF_8

import scala.jdk.CollectionConverters._
import scala.util.{Failure, Success, Try}

import io.circe._, io.circe.generic.auto._, io.circe.parser._, io.circe.syntax._

import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.auth.credentials.EnvironmentVariableCredentialsProvider
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.ListObjectsRequest;

final case class Input(bucket: String)
final case class Output(keys: Seq[String])

object Main {

  def main(args: Array[String]): Unit = {
    val context = System.getenv()
    val runtime = context.get("AWS_LAMBDA_RUNTIME_API")
    val http = HttpClient.newBuilder().build()
    val s3 = S3Client.builder()
      .region(Region.AP_NORTHEAST_1)
      .credentialsProvider(EnvironmentVariableCredentialsProvider.create())
      .httpClient(UrlConnectionHttpClient.builder().build())
      .build()

    while (true) {
      try {
        val response = http.send(
          HttpRequest.newBuilder().uri(URI.create(s"http://$runtime/2018-06-01/runtime/invocation/next")).GET().build(),
          HttpResponse.BodyHandlers.ofString(UTF_8)
        )
        val requestId = response.headers().map().get("lambda-runtime-aws-request-id").asScala.head
        Try {
          decode[Input](response.body()) match {
            case Right(Input(bucket)) =>
              val listObjects = ListObjectsRequest
                .builder()
                .bucket(bucket)
                .build()
              Output(s3.listObjects(listObjects).contents().asScala.map(_.key()).toSeq).asJson.noSpaces
            case Left(error) =>
              throw new Exception(error)
          }
        } match {
          case Success(response) =>
            http.send(
              HttpRequest.newBuilder().uri(URI.create(s"http://$runtime/2018-06-01/runtime/invocation/$requestId/response")).POST(HttpRequest.BodyPublishers.ofString(response)).build(),
              HttpResponse.BodyHandlers.ofString(UTF_8)
            )
          case Failure(e) =>
            http.send(
              HttpRequest.newBuilder().uri(URI.create(s"http://$runtime/2018-06-01/runtime/invocation/$requestId/error")).POST(HttpRequest.BodyPublishers.ofString(e.getMessage)).build(),
              HttpResponse.BodyHandlers.ofString(UTF_8)
            )
        }
      } catch {
        case e: Exception =>
          System.err.println(e.getMessage)
      }
    }
  }
}
```

カスタムランタイムの実装については、[この辺り](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-custom.html)を参考にしてください。Java 11からはHttpClientが標準ライブラリに入っているので、そちらを利用しています。GraalVMではできるだけ標準ライブラリを使用する方針でいた方がトラブルが少なくていいと思います。

AWSのライブラリを使う際のポイントは、S3Clientを生成する際の`.httpClient(UrlConnectionHttpClient.builder().build())`の箇所で、ここでAWSのライブラリ内で使用されるHttpClientをデフォルトのHttpClientからUrlConnectionを利用したHttpClientに切り替えています。これでDynamoDBやSQS等、内部的にRest APIで通信しているライブラリを使用することが可能になります。

## Zipファイルの作成

下記のコマンドでカスタムランタイムで必要なbootstrapを含んだZipファイルを作成します。

```
$ sbt clean lambdaBuild
```

## 関数の登録

下記コマンドでAWS Lambdaに関数を登録します。

```
$ aws lambda create-function \
    --function-name my-function \
    --runtime provided.al2 \
    --zip-file fileb://target/bundle.zip \
    --handler MyApp \
    --role arn:aws:iam::XXXXXXXX:role/XXXrole_for_lambdaXXX
```

## 動作確認

下記コマンドで動作確認してみます。

```
$ aws lambda invoke \
    --function-name my-function \
    --payload '{ "bucket": "my_bucket" }' \
    response.json; cat response.json

{
    "ExecutedVersion": "$LATEST", 
    "StatusCode": 200
}
{"keys":["my_key1", "my_key2"]}
```

問題なくLambda関数が実行されました。

# さいごに

AWS LambdaをScalaやJavaで実装して利用するという選択が、今のところベストではないような現状ですが、GraalVMが使える範囲であれば、ScalaやJavaのメリットを享受できるのではないかと思います。

特に、現状Java 11のランタイム上で稼働しているAWS LambdaをGraalVMのnative imageでバイナリ化されたカスタムランタイムで動かすことができれば、Lambdaを最小メモリで動かすことも可能になるので、運用コストを大幅に削減できるかもしれません。