はじめに
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を作成してみます。
ちなみに今回の成果物は下記に置いてます。
実装
前準備
GraalVMでnative imageを作成する際には、assemblyしたjarファイルが必要になるのでassembly.sbt
に下記を追加しておきます。
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.9")
また、今回はAWS LambdaへのINとOUTはJSON形式とするため、JSONパーサとしてcirceのライブラリを追加し、AWSのリソースとしてS3を使用するため、S3のライブラリを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
は下記のようになります。
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内では以下の処理を行っています。
- oracle/graalvm-ce:20.1.0-java11のDockerイメージを起動
- assemblyしたjarファイルをコンテナ内にコピー
- コンテナ内にGraalVMのnative imageをインストール
- jarファイルからnative imageを作成
- 作成した実行ファイルと必要なライブラリをzipファイルに変換
4のnative imageを作成する際のパラメータが重要で、今回の、Scala、Java、GraalVMの組み合わせではこのパラメータで大丈夫でした。
Main.scala
今回はサンプルということで、Main.scala
にカスタムランタイムの実装と、本来のAWS Lambdaで記述するロジックの実装を行っています。
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)
}
}
}
}
カスタムランタイムの実装については、この辺りを参考にしてください。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を最小メモリで動かすことも可能になるので、運用コストを大幅に削減できるかもしれません。