[AWS]LambdaのCustomRuntime+GraalVM_NativeImageでJVM言語のファンクションを高速起動実行する

Pocket

はじめに

AWS LambdaでランタイムとしてJVM(Java8)を使用したことはありますでしょうか?
JVMはランタイム(Node.js, Python, Ruby, Java, Go, .NET)の中では(個人的に)一番コーディングしやすいのですが、
初回起動(コールドスタート)が遅いという問題があります。

そのための対策として、これまでは「CloudWatch Eventsを使用して定期的に実行し、コールドスタートの発生を抑制する」という微妙な方法がしばしば使用されてきました。
ですが、最近その状況が変わりつつあります。
一つは「AWS Lambdaで任意のものが実行できる”Custom Runtime”というものが登場」したことです。
そしてもう一つは「Fat Jarをネイティブバイナリに変換できる、GraalVMのNative Imageというものが登場」したことです。
(ただしGraalVMのNative Imageはまだ正式リリースされていませんが…)

https://www.graalvm.org/docs/reference-manual/aot-compilation/

GraalVM Native Image is available as an early adopter plugin

そこで、この記事では
「GraalVM Native Imageでネイティブバイナリに変換したFat Jarを、AWS LambdaのCustom Runtimeで動かす」
ことを試していきます。

前提条件

  • GraalVM: Community Edition 19.1.0
  • ビルド環境OS: Ubuntu 18.04.2 LTS

AWS Lambda Custom Runtime とは

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-custom.html

AWS Lambda ランタイムは、どのプログラミング言語でも実装できます

とあるように、任意のプログラミング言語で実装可能なLambda環境です。
実際には、(必要なファイルをzipに固めてデプロイした上で、そのzipの直下にある)
bootstrap という名前のファイルをエントリポイントとして呼び出す、という仕組みになっており
そのbootstrapをどう実装するのも自由
(だけど、少なくともbootstrapは前提条件なしで実行可能であること)
という仕組みです。

GraalVM とは

https://www.graalvm.org/

GraalVM is a universal virtual machine for running applications written in JavaScript, Python, Ruby, R, JVM-based languages like Java, Scala, Groovy, Kotlin, Clojure, and LLVM-based languages such as C and C++.

とあるように、JVM言語に限らず様々なものを動かすことができる汎用仮想マシンです。
単純に他のJDK/JRE等をGraalVMに変更するだけでも少なからず高速化するそうです。
さらに(開発途中の)プラグインであるNative Imageというものを使用すると
JVM用のバイトコードをスタンドアローンで実行可能なネイティブバイナリに変換することが可能となります。

実際に試してみる

bootstrap

上で説明した通り、bootstrapは最初に呼び出されるファイルです。
マネジメントコンソールからLambda関数を作成する場合「デフォルトのブートストラップを使用する」を選択すると
デフォルトのもの(シェルスクリプト)が自動で生成されます。
ただし、デフォルトのものは
「独自処理本体を呼び出したときの標準出力を処理結果として返す」
という実装になっているため、処理結果以外にログを出力したい、という場合には問題があります。

今回は処理結果以外にログを出力したいため、独自のbootstrapを実装します。
実装方法は(処理時間が多少かかることに目をつぶれば)独自処理内で結果をファイルに書き出し、
bootstrap側のシェルスクリプトでそのファイルを読み込む、という方法も可能です。
ですが、今回は「高速」を重視し、bootstrap部分も独自処理も全て1つのネイティブバイナリとします。
これらをJVM言語(今回はScala)で実装します。

ソースコード

Bootstrap.scala
object Bootstrap {
  def main(args: Array[String]): Unit = {
    val awsLambdaRuntimeApi = System.getenv("AWS_LAMBDA_RUNTIME_API")
    // CustomRuntimeのエントリポイントは無限ループ
    while(true) {
      // イベントを取得
      // イベント用エンドポイントから取得したLambda-Runtime-Aws-Request-Idヘッダとbodyを抽出
      val httpHandler: HttpHandler = new HttpHandlerImpl
      val nextInvocation = httpHandler.get(s"http://${awsLambdaRuntimeApi}/2018-06-01/runtime/invocation/next", Map.empty)
//      EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
      nextInvocation.headers.get("Lambda-Runtime-Aws-Request-Id") match {
        case Some(requestId) =>
//          REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
          val result = Sample.lambdaEntryPoint(nextInvocation.body)
          httpHandler.post(s"http://${awsLambdaRuntimeApi}/2018-06-01/runtime/invocation/${requestId}/response", Map.empty, result)
//          curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" -d "$RESPONSE"
        case None => ()
      }
    }
  }
}
Sample.scala
import scala.util.Try

object Sample {
  def lambdaEntryPoint(body: String): String = {
    val param = Try(body.toInt).toOption match {
      case Some(p) => p
      case None => throw new IllegalArgumentException("require numeric param")
    }
    val result = logic(param)
    result.toString
  }

  def logic(n: BigInt): BigInt = {
    val start = System.currentTimeMillis()
    val (fib, memo) = Logic.fibonacci(n)
    println(s"""fibonacci($n) : $fib""")
    println(s"""memo : ${memo.toList.sortBy(_._1).map { case (k, v) => s"$k -> $v" }.mkString(", ")}""")
    println(s"""total time : [${System.currentTimeMillis() - start}]""")
    fib
  }
}
Logic.scala
object Logic {
  def fibonacci(n: BigInt): (BigInt, Map[BigInt, BigInt]) = {
    def f(nn: BigInt, memo: scala.collection.mutable.Map[BigInt, BigInt]): BigInt = {
      nn match {
        case BigIntSupport(0) => 0
        case BigIntSupport(1) => 1
        case i if i >= 2 =>
          memo.getOrElse(i, {
            val fibi = f(i - 1, memo) + f(i - 2, memo)
            memo.put(i, fibi)
            fibi
          })
        case _ => throw new IllegalArgumentException("must be equal or more than 0")
      }
    }

    val memo = scala.collection.mutable.Map[BigInt, BigInt]()
    val result = f(n, memo)
    (result, memo.toMap)
  }
  object BigIntSupport {
    def unapply(arg: BigInt): Option[Int] = if(arg.isValidInt) Some(arg.intValue()) else None
  }
}
HttpHandler.scala
import com.softwaremill.sttp._

import scala.concurrent.duration._

case class CustomResponse(statusCode: Int, status: String, headers: Map[String, String], body: String)

trait HttpHandler {
  def get(url: String, headers: Map[String, String]): CustomResponse
  def post(url: String, headers: Map[String, String], requestBody: String): CustomResponse
  def put(url: String, headers: Map[String, String], requestBody: String): CustomResponse
  def delete(url: String, headers: Map[String, String]): CustomResponse
}

class HttpHandlerImpl extends HttpHandler {
  implicit val backend: SttpBackend[Id, Nothing] = HttpURLConnectionBackend()
  val readTimeout: Duration = 5.seconds

  def get(url: String, headers: Map[String, String]): CustomResponse = httpRequest(Method.GET, url, headers)

  def post(url: String, headers: Map[String, String], requestBody: String): CustomResponse = httpRequest(Method.POST, url, headers, Option(requestBody))

  def put(url: String, headers: Map[String, String], requestBody: String): CustomResponse = httpRequest(Method.PUT, url, headers, Option(requestBody))

  def delete(url: String, headers: Map[String, String]): CustomResponse = httpRequest(Method.DELETE, url, headers)

  def httpRequest(webMethod: Method, url: String, headers: Map[String, String], requestBody: Option[String] = None): CustomResponse = {
    val request = {
      var r = sttp
        .method(webMethod, uri"$url")
        .headers(headers)
        .readTimeout(readTimeout)
      requestBody.foreach { s =>
        r = r.body(s)
      }
      r
    }
    val response = request.send()
    extractResponse(response)
  }

  def extractResponse(response: Response[String]): CustomResponse = {
    val statusCode = response.code
    val httpStatus = statusCode match {
      case StatusCodes.Ok => "OK"
      case StatusCodes.Created => "CREATED"
      case StatusCodes.Unauthorized => "UNAUTHORIZED"
      case StatusCodes.BadRequest => "BAD_REQUEST"
      case StatusCodes.NotFound => "NOT_FOUND"
      case StatusCodes.NoContent => "NO_CONTENT"
      case StatusCodes.InternalServerError => "INTERNAL_SERVER_ERROR"
      case _ => "OTHER_STATUS_CODE"
    }
    val headers = response.headers.toMap
    val body = response.body.right.get
    CustomResponse(statusCode, httpStatus, headers, body)
  }
}
build.sbt
name := "graalvm-sample"

version := "1.0"

scalaVersion := "2.12.8"

libraryDependencies ++= Seq(
  "com.softwaremill.sttp" %% "core" % "1.5.11"
)

assemblyJarName in assembly := "sample.jar"

mainClass in assembly := Some("jp.ne.khddmks.Bootstrap")

assemblyMergeStrategy in assembly := {
  case PathList("org", "apache", "commons", "logging", xs @ _*)         => MergeStrategy.first
  case manifest if manifest.contains("MANIFEST.MF") || manifest.contains("io.netty.versions.properties") =>
    // We don't need manifest files since sbt-assembly will create
    // one with the given settings
    MergeStrategy.discard
  case referenceOverrides if referenceOverrides.contains("reference-overrides.conf") =>
    // Keep the content for all reference-overrides.conf files
    MergeStrategy.concat
  case x =>
    // For all the other files, use the default sbt-assembly merge strategy
    val oldStrategy = (assemblyMergeStrategy in assembly).value
    oldStrategy(x)
}

ビルド手順

  1. GraalVMをダウンロードしてインストール
    wget https://github.com/oracle/graal/releases/download/vm-19.1.0/graalvm-ce-linux-amd64-19.1.0.tar.gz
    tar zxvf graalvm-ce-linux-amd64-19.1.0.tar.gz
    export GRAALVM_HOME=[展開したディレクトリ]/graalvm-ce-19.1.0
    export PATH=$PATH:$GRAALVM_HOME/bin
    
  2. GraalVMからNative Imageプラグインをインストール
    gu install native-image
    
  3. Fat Jarを生成
    sbt
    > clean
    > compile
    > assembly
    mv ./target/scala-2.12/[変換対象のFat Jar].jar .
    
  4. Native Imageを使ってFat Jarをネイティブバイナリに変換
    native-image \
    -jar [変換対象のFat Jar].jar \
    -H:IncludeResources=".*.xml|.*.conf" \
    -H:+ReportUnsupportedElementsAtRuntime \
    -H:Name=[出力ファイル名] \
    --verbose \
    --initialize-at-build-time=scala.Function1 \ # 今回使用したhttp通信用ライブラリで必要な設定
    --initialize-at-build-time=scala.Function3 \ # 今回使用したhttp通信用ライブラリで必要な設定
    -H:EnableURLProtocols=http                   # プログラム内でhttp通信を行う場合に必要な設定
    

実行結果及びJVMランタイムとの比較

※Lambdaのメモリはどちらも128MB

実行時間(ms)
ランタイム 初回(起動+実処理) 2回目 3回目 4回目 5回目
Custom Runtime 323.47 44.03 19.26 24.40 50.45
JVM 9081.96 3.12 3.64 33.81 129.79

実行時間を見ると、コールドスタートとなる初回は圧倒的にネイティブバイナリの方が速いことが分かります。
ただし、2回目以降は必ずしもネイティブバイナリの方が早いとは限らないようです。

メモリ消費(MB)
ランタイム 初回(起動+実処理) 2回目 3回目 4回目 5回目
Custom Runtime 45 45 46 47 47
JVM 118 119 119 119 119

メモリ消費も、ネイティブバイナリの方が大幅に少ないことが分かります。
(ただし、Lambdaの処理性能は最大メモリサイズに依存するため、
メモリ消費が少ないからといってメモリサイズを小さくする方がいいとは限りません。)

まとめ

GraalVM Native Imageを使用することで、JVMランタイムで動かす場合に比べて
少なくともコールドスタートでは圧倒的に高速化し、メモリ消費も大幅に減少することが確認できました。
GraalVM Native Imageはまだ正式リリースされているわけではないため、
現段階ではまだプロダクション環境に適用できるわけではないですが
今後が非常に楽しみですね!
また、そもそもRust等でプログラムを作成すれば今すぐにでもCustom Runtimeを最大限活用できると思います。

余談

bootstrapの処理を見れば分かる通り、Custom RuntimeではLambdaのリクエスト・レスポンス用のエンドポイントに対するhttp通信が必要となります。
そこでScala上で使えるhttp通信用ライブラリをいろいろ試しましたが
リフレクションで参照しているクラスやメソッドが見つからない、等の問題で
Native Imageでの変換時やその後のネイティブバイナリ実行時にエラーとなるライブラリがありました。
(といいますか今回のサンプルコードで使用しているライブラリの前に4種類くらいのライブラリを試しましたが
それら全てがどこかで引っかかって使用できませんでした)
おそらくhttp通信用以外のライブラリであっても、同じように何らかの問題で使えない場合があると思いますので注意が必要です。

参考

Custom Runtime側のログ
START RequestId: 7c643023-6bb6-43d7-8a23-5b08f5802d22 Version: $LATEST
fibonacci(10) : 55
memo : 2 -> 1, 3 -> 2, 4 -> 3, 5 -> 5, 6 -> 8, 7 -> 13, 8 -> 21, 9 -> 34, 10 -> 55
total time : [1]
END RequestId: 7c643023-6bb6-43d7-8a23-5b08f5802d22
REPORT RequestId: 7c643023-6bb6-43d7-8a23-5b08f5802d22	Init Duration: 91.23 ms	Duration: 232.24 ms	Billed Duration: 400 ms Memory Size: 128 MB	Max Memory Used: 45 MB
START RequestId: ae1065ec-ff34-403a-bed6-de333f036cb8 Version: $LATEST
fibonacci(15) : 610
memo : 2 -> 1, 3 -> 2, 4 -> 3, 5 -> 5, 6 -> 8, 7 -> 13, 8 -> 21, 9 -> 34, 10 -> 55, 11 -> 89, 12 -> 144, 13 -> 233, 14 -> 377, 15 -> 610
total time : [0]
END RequestId: ae1065ec-ff34-403a-bed6-de333f036cb8
REPORT RequestId: ae1065ec-ff34-403a-bed6-de333f036cb8	Duration: 44.03 ms	Billed Duration: 100 ms Memory Size: 128 MB	Max Memory Used: 45 MB
START RequestId: b4218b37-3569-43cd-af0e-f171615d1821 Version: $LATEST
fibonacci(20) : 6765
memo : 2 -> 1, 3 -> 2, 4 -> 3, 5 -> 5, 6 -> 8, 7 -> 13, 8 -> 21, 9 -> 34, 10 -> 55, 11 -> 89, 12 -> 144, 13 -> 233, 14 -> 377, 15 -> 610, 16 -> 987, 17 -> 1597, 18 -> 2584, 19 -> 4181, 20 -> 6765
total time : [0]
END RequestId: b4218b37-3569-43cd-af0e-f171615d1821
REPORT RequestId: b4218b37-3569-43cd-af0e-f171615d1821	Duration: 19.26 ms	Billed Duration: 100 ms Memory Size: 128 MB	Max Memory Used: 46 MB
START RequestId: ce169421-b603-4e51-ad7a-6e6b639a3cd8 Version: $LATEST
fibonacci(30) : 832040
memo : 2 -> 1, 3 -> 2, 4 -> 3, 5 -> 5, 6 -> 8, 7 -> 13, 8 -> 21, 9 -> 34, 10 -> 55, 11 -> 89, 12 -> 144, 13 -> 233, 14 -> 377, 15 -> 610, 16 -> 987, 17 -> 1597, 18 -> 2584, 19 -> 4181, 20 -> 6765, 21 -> 10946, 22 -> 17711, 23 -> 28657, 24 -> 46368, 25 -> 75025, 26 -> 121393, 27 -> 196418, 28 -> 317811, 29 -> 514229, 30 -> 832040
total time : [0]
END RequestId: ce169421-b603-4e51-ad7a-6e6b639a3cd8
REPORT RequestId: ce169421-b603-4e51-ad7a-6e6b639a3cd8	Duration: 24.40 ms	Billed Duration: 100 ms Memory Size: 128 MB	Max Memory Used: 47 MB
START RequestId: bc01397b-2c98-4d25-b445-2c16215c14a6 Version: $LATEST
fibonacci(50) : 12586269025
memo : 2 -> 1, 3 -> 2, 4 -> 3, 5 -> 5, 6 -> 8, 7 -> 13, 8 -> 21, 9 -> 34, 10 -> 55, 11 -> 89, 12 -> 144, 13 -> 233, 14 -> 377, 15 -> 610, 16 -> 987, 17 -> 1597, 18 -> 2584, 19 -> 4181, 20 -> 6765, 21 -> 10946, 22 -> 17711, 23 -> 28657, 24 -> 46368, 25 -> 75025, 26 -> 121393, 27 -> 196418, 28 -> 317811, 29 -> 514229, 30 -> 832040, 31 -> 1346269, 32 -> 2178309, 33 -> 3524578, 34 -> 5702887, 35 -> 9227465, 36 -> 14930352, 37 -> 24157817, 38 -> 39088169, 39 -> 63245986, 40 -> 102334155, 41 -> 165580141, 42 -> 267914296, 43 -> 433494437, 44 -> 701408733, 45 -> 1134903170, 46 -> 1836311903, 47 -> 2971215073, 48 -> 4807526976, 49 -> 7778742049, 50 -> 12586269025
total time : [1]
END RequestId: bc01397b-2c98-4d25-b445-2c16215c14a6
REPORT RequestId: bc01397b-2c98-4d25-b445-2c16215c14a6	Duration: 50.45 ms	Billed Duration: 100 ms Memory Size: 128 MB	Max Memory Used: 47 MB
JVM側のログ
START RequestId: 2788d251-5940-4574-888a-6ae0f0ea0365 Version: $LATEST
fibonacci(10) : 55
memo : 2 -> 1, 3 -> 2, 4 -> 3, 5 -> 5, 6 -> 8, 7 -> 13, 8 -> 21, 9 -> 34, 10 -> 55
total time : [1981]
END RequestId: 2788d251-5940-4574-888a-6ae0f0ea0365
REPORT RequestId: 2788d251-5940-4574-888a-6ae0f0ea0365	Duration: 9081.96 ms	Billed Duration: 9100 ms Memory Size: 128 MB	Max Memory Used: 118 MB
START RequestId: 94713727-ffe5-447b-91e8-b2b54b4d179d Version: $LATEST
fibonacci(15) : 610
memo : 2 -> 1, 3 -> 2, 4 -> 3, 5 -> 5, 6 -> 8, 7 -> 13, 8 -> 21, 9 -> 34, 10 -> 55, 11 -> 89, 12 -> 144, 13 -> 233, 14 -> 377, 15 -> 610
total time : [2]
END RequestId: 94713727-ffe5-447b-91e8-b2b54b4d179d
REPORT RequestId: 94713727-ffe5-447b-91e8-b2b54b4d179d	Duration: 3.12 ms	Billed Duration: 100 ms Memory Size: 128 MB	Max Memory Used: 119 MB
START RequestId: c39293fb-8656-4928-b295-6e923554ac47 Version: $LATEST
fibonacci(20) : 6765
memo : 2 -> 1, 3 -> 2, 4 -> 3, 5 -> 5, 6 -> 8, 7 -> 13, 8 -> 21, 9 -> 34, 10 -> 55, 11 -> 89, 12 -> 144, 13 -> 233, 14 -> 377, 15 -> 610, 16 -> 987, 17 -> 1597, 18 -> 2584, 19 -> 4181, 20 -> 6765
total time : [4]
END RequestId: c39293fb-8656-4928-b295-6e923554ac47
REPORT RequestId: c39293fb-8656-4928-b295-6e923554ac47	Duration: 3.64 ms	Billed Duration: 100 ms Memory Size: 128 MB	Max Memory Used: 119 MB
START RequestId: 7da17455-5eb4-43a7-ae28-7b0b55c9ece2 Version: $LATEST
fibonacci(30) : 832040
memo : 2 -> 1, 3 -> 2, 4 -> 3, 5 -> 5, 6 -> 8, 7 -> 13, 8 -> 21, 9 -> 34, 10 -> 55, 11 -> 89, 12 -> 144, 13 -> 233, 14 -> 377, 15 -> 610, 16 -> 987, 17 -> 1597, 18 -> 2584, 19 -> 4181, 20 -> 6765, 21 -> 10946, 22 -> 17711, 23 -> 28657, 24 -> 46368, 25 -> 75025, 26 -> 121393, 27 -> 196418, 28 -> 317811, 29 -> 514229, 30 -> 832040
total time : [33]
END RequestId: 7da17455-5eb4-43a7-ae28-7b0b55c9ece2
REPORT RequestId: 7da17455-5eb4-43a7-ae28-7b0b55c9ece2	Duration: 33.81 ms	Billed Duration: 100 ms Memory Size: 128 MB	Max Memory Used: 119 MB
START RequestId: 123d44be-7eec-4ed1-9196-2936496909e6 Version: $LATEST
fibonacci(50) : 12586269025
memo : 2 -> 1, 3 -> 2, 4 -> 3, 5 -> 5, 6 -> 8, 7 -> 13, 8 -> 21, 9 -> 34, 10 -> 55, 11 -> 89, 12 -> 144, 13 -> 233, 14 -> 377, 15 -> 610, 16 -> 987, 17 -> 1597, 18 -> 2584, 19 -> 4181, 20 -> 6765, 21 -> 10946, 22 -> 17711, 23 -> 28657, 24 -> 46368, 25 -> 75025, 26 -> 121393, 27 -> 196418, 28 -> 317811, 29 -> 514229, 30 -> 832040, 31 -> 1346269, 32 -> 2178309, 33 -> 3524578, 34 -> 5702887, 35 -> 9227465, 36 -> 14930352, 37 -> 24157817, 38 -> 39088169, 39 -> 63245986, 40 -> 102334155, 41 -> 165580141, 42 -> 267914296, 43 -> 433494437, 44 -> 701408733, 45 -> 1134903170, 46 -> 1836311903, 47 -> 2971215073, 48 -> 4807526976, 49 -> 7778742049, 50 -> 12586269025
total time : [129]
END RequestId: 123d44be-7eec-4ed1-9196-2936496909e6
REPORT RequestId: 123d44be-7eec-4ed1-9196-2936496909e6	Duration: 129.79 ms	Billed Duration: 200 ms Memory Size: 128 MB	Max Memory Used: 119 MB
Pocket

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です