ScalaをGraalVMでネイティブイメージにコンパイルしてAWS Lambdaでサクサク動かす
このエントリは ただの集団 Advent Calendar 2018 の18日目です。昨日は ugdark さんの 職種としてのスクラムマスターを調べてみた でした。
普段はScalaアプリケーションをECSやEC2で動かすことが多いのですが、ものによってはPythonやnode.jsでAWS Lambdaの関数ハンドラを書くこともあります。脳内コンテキストスイッチの抑制を考えるとLambda関数ハンドラもScalaで書きたいところですが、コールドスタート時におけるJVM起動のオーバーヘッドなどもあることから、今までにScalaでLambdaという選択は採ることがありませんでした。
そんな中、先日にGraalVMにネイティブイメージをコンパイルする機能があることを知りました。これを使ってScalaコードを実行可能なバイナリにコンパイルし、Lambdaでサクサクと動かしてみようと思います。
GraalVM
GraalVMはJSやPython、Ruby、R、JavaやScala、Kotlin、ClojureといったJVM言語、LLVM言語など様々な言語を実行可能で、ある言語から別の言語をオーバーヘッドなしに呼び出せるpolyglot
なVMです。
これはこれで大変面白いのですが、今回注目したいのはネイティブイメージのコンパイルを行う機能です。これによってJVM言語のアプリケーションを実行可能なバイナリにコンパイルすることで、起動時間の短縮やメモリフットプリントの削減などが期待できるようです。
ネイティブイメージの生成時にはAOT(ahead-of-time)コンパイルを行うため、動的にクラスロードを行うアプリケーションはコンパイルできないなどの制約もあるようですが、Lambda関数ハンドラで行うような処理では問題になることは少ないかも知れません。
AWS Lambda カスタムランタイム
先日のre:Invent 2018で発表されたのがAWS Lambdaのカスタムランタイムです。これによって、あらゆる言語、あらゆる処理系でLambda関数ハンドラが書けるようになりました。
上記のドキュメントから雑に要約すると、カスタムランタイムを動かすために必要なものは以下のとおりです。
- エントリーポイントとなるbootstrapの用意
- 初期化処理の実装
- イベントループの実装
bootstrap
エントリーポイントとなるbootstrap
という名前の実行可能なファイルを用意します。このファイルはLambdaインスタンス作成時に実行されます。
bootstrapは別のファイルを呼び出して実際の処理を委譲してもよいですし、このファイル単体でイベントループを実行しても大丈夫です。
初期化処理
Lambdaインスタンスの実行時に1回だけ実行される処理です。環境変数経由でセッティングを取得したり、DBとのコネクション作成などの初期化処理を必要に応じて行います。
イベントループの実装
Lambdaインスタンスが受け取るイベントを処理するループを実装します。ひとまず動かすためには、
next invocation API
にGETし、リクエストIDやリクエストボディなどが含まれたイベントデータを取得する処理の実装- イベントを処理するLambda関数ハンドラの実装
- 処理結果を
invocation response API
にPOSTする処理を実装 - エラー時に
invocation error API
にPOSTする処理を実装
あたりが必要です。
カスタムランタイムをScalaで実装する
では上で見たようなカスタムランタイムをScalaで実装します。今回はbootstrapファイルの中でイベントループも実装するようにします。
bootstrapの実装
package bootstrap import play.api.libs.json._ import scalaj.http._ object Main { def main(args: Array[String]): Unit = { // 初期化処理 val runtime = System.getenv("AWS_LAMBDA_RUNTIME_API") try { println(s"runtime: $runtime") } catch { case e: Exception => val message = Json.obj("errorMessage" -> e.getMessage, "errorType" -> e.getClass.getName).toString Http(s"http://$runtime/2018-06-01/runtime/init/error").postData(message).asString } // イベントループ while (true) { // イベントデータの取得 val HttpResponse(body, _, headers) = Http(s"http://$runtime/2018-06-01/runtime/invocation/next").asString val requestId = headers("lambda-runtime-aws-request-id").head try { val name = (Json.parse(body) \ "name").get val responseJson = Json.obj("message" -> s"Hello, $name!").toString // レスポンスデータの返却 Http(s"http://$runtime/2018-06-01/runtime/invocation/$requestId/response").postData(responseJson).asString } catch { case e: Exception => println(e.getClass.getName) println(e.getMessage) val message = Json.obj("errorMessage" -> e.getMessage, "errorType" -> e.getClass.getName).toString Http(s"http://$runtime/2018-06-01/runtime/invocation/$requestId/error").postData(message).asString } } } }
next invoke APIなどを呼び出すためにScalaJ-HTTPを、JSONのシリアライズ、デシリアライズにはplay-jsonを使用しています。
やっていることは簡単で、リクエストされたJSON {"name": "todokr"}
から{"message": "Hello, todokr!"}
というレスポンスのJSONを返しているだけです。
sbt-assemblyプラグインの追加
GraalVMでネイティブイメージを生成するnative-image
コマンドは入力としてFat Jarを受け取ります。
今回、Jarの生成にはsbt-assemblyを使います。project
以下にassembly.sbt
というファイルを用意し、sbt-assembly
をsbtプラグインの依存関係に追加します。
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.9")
GraalVMのDockerfileを用意する
MacではLambda実行環境であるLinux向けのネイティブイメージを生成できないので、GraalVMをDockerで動かして利用します。
(このあたりは@k2nakamuraさんのこの記事に助けられました。ありがとうございます。)
このようなDockerfileを用意します。
FROM oracle/graalvm-ce:1.0.0-rc10 RUN yum install -y java-1.8.0-openjdk-headless \ && update-alternatives --set java $JAVA_HOME/bin/java \ && mv $JAVA_HOME/jre/lib/security/cacerts $JAVA_HOME/jre/lib/security/cacerts.bak \ && ln -s /usr/lib/jvm/jre-1.8.0/lib/security/cacerts $JAVA_HOME/jre/lib/security/cacerts CMD tail -f /dev/null
build.sbtを編集する
ネイティブイメージの生成のため、build.sbtを編集します。
assemblyタスクのセッティング
jarのエントリーポイントの指定や生成されるjarの名前をsettings内で指定します。
mainClass in assembly := Some("bootstrap.Main"), assemblyJarName in assembly := s"scala-graalvm-lambda_${version.value}.jar",
GraalVMのdocker imageをビルド/起動するsbtカスタムタスクを用意
シェルスクリプトでやってもよいのですが、せっかくなのでsbtのカスタムタスクにしてみます。ビルド/起動済みかの判定はgrepのexit codeで判定するなどちょっと泥臭い感じです。scala.sys.process.__
が大活躍してます。
runBuildServer
タスクはbuildContainer
タスクへの依存を宣言しています。
lazy val buildContainer = taskKey[Unit]("Build docker image of GraalVM") lazy val runBuildServer = taskKey[Unit]("Run GraalVM server for build native image.") ... buildContainer := { val exitCode = ("docker images" #| "grep graal-build-img").!(ProcessLogger(_ => ())) if (exitCode == 1) { println("Build GraalVM container...") "docker build -f Dockerfile -t graal-build-img .".! } else println("Container is already built.") }, runBuildServer := { buildContainer.value val exitCode = ("docker ps" #| "grep graal-builder$").!(ProcessLogger(_ => ())) if (exitCode == 1) { println("Start build server...") "docker run --name graal-builder -dt graal-build-img:latest".! } else println("Build server is already running.") },
GraalVMでネイティブイメージをコンパイルし、zipに固めるカスタムタスクの用意
せっかくなのでこちらもシェルスクリプトではなくsbtのカスタムタスクにします。clean
とassembly
、runBuildServer
に依存を宣言しています。
nativeCompile := { clean.value assembly.value runBuildServer.value val jarName = (assemblyJarName in assembly).value (s"docker cp target/scala-2.12/$jarName graal-builder:server.jar" #&& "time docker exec graal-builder native-image -H:+ReportUnsupportedElementsAtRuntime -H:EnableURLProtocols=http,https -J-Xmx3G -J-Xms3G --no-server -jar server.jar" #&& "docker cp graal-builder:server target/bootstrap" #&& "docker cp graal-builder:/opt/graalvm-ce-1.0.0-rc10/jre/lib/amd64/libsunec.so target/libsunec.so" #&& "zip -j target/bundle.zip target/bootstrap target/libsunec.so").! }
最終的にbuild.sbtはこのようになりました。
このsbt nativeCompile
を実行すると、target/
にbundle.zip
というzipファイルが生成されるはずです。
AWS Lambdaへのデプロイ
AWSコンソールからLambda関数を作成し、生成したzipをアップロードします。あとはAPI Gatewayと接続するなどいい感じにやっておきます。
実行速度を見てみる
Cloudwatch LogsのREPORT
の行だけを抜き出すとこのような感じです。128MBの環境でもInit込みで150msほど、暖まっていると1.2 ~ 1.5msほど(!)で処理できていることが分かります。
REPORT RequestId: aa66a4c8-020b-11e9-8118-1f1b36c60d1d Init Duration: 122.14 ms Duration: 153.49 ms Billed Duration: 300 ms Memory Size: 128 MB Max Memory Used: 52 MB REPORT RequestId: ab5b8c12-020b-11e9-8b92-a1f863edf3ed Duration: 1.26 ms Billed Duration: 100 ms Memory Size: 128 MB Max Memory Used: 52 MB REPORT RequestId: af655404-020b-11e9-abba-97121f20de35 Duration: 1.56 ms Billed Duration: 100 ms Memory Size: 128 MB Max Memory Used: 52 MB REPORT RequestId: c70d15c1-020b-11e9-950c-4b268a6b6d47 Duration: 1.22 ms Billed Duration: 100 ms Memory Size: 128 MB Max Memory Used: 53 MB
このくらいサクサク動くのであれば、プロダクションでもScala x Lambdaが選択肢に挙がってきそうですね。
サンプルコードの全体はこちらにあります。