ScalaでGraalVM native-imageを作ってAWS Lambdaで動かす
はじめに
あのプログラミング言語だったらこんなことするのラクなのに〜 を Scala でやってみる第一弾です。
Scala やっててこんなこと思ったりしませんか?
- CLI ツールを作ってみたいけど実行マシンに JRE 入れないといけない
- AWS Lambda 上で動かしたいけど Java アプリケーションになるのでコールドスタートが遅い
それいい感じにできるソリューションあります!
サンプルの完成品はコチラ ignission/aws-lambda-graal-native-scala-example からご覧いただけます。
このサンプルでは、serverless frameworkを使って Lambda へのデプロイまで行っていますが、build.sbt
やproject/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 を追加します。
addSbtPlugin("org.scalameta" % "sbt-native-image" % "0.3.0")
plugin を有効化します。
enablePlugins(NativeImagePlugin)
ビルドコマンドは次の通りです。
sbt nativeImage
リフレクション設定
Logging にリフレクションが含まれているので、以下のファイルをgraal
ディレクトリに配置します。
本当は akka を使いたかったのですが、設定が大変すぎたので諦めて Logging だけの構成にしました。
graal/reflect-config.json
graal/reflectconf-jul.json
ビルド設定
大まかな設定は以下のようになります。
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を参照ください。
抜粋して説明していきます。
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
で渡されるエンドポイントにリクエストを送って、レスポンスを取得します。
ここでは必ずリクエストに成功するわけではないようなので、エラーが発生しても無限ループを抜けない(例外を投げない)ようにする必要があります。
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
が含まれているので取り出します。
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
のレスポンスボディにユーザーからのリクエストが入っているのでそこから何かしらの処理をするのが一般的かなと思います。
private def execute(message: String): ZIO[AppType, AppError, String] =
// write some logic ...
UIO.effectTotal(message)
4. 処理の結果を返す
AWS Request ID
が含まれる特定のエンドポイントに POST リクエストを送ります。
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 フォーマットは以下のようになっています。
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
というファイルが作成されます。
nativeImageOutput := file("serverless") / "dist" / "bootstrap",
6. デプロイ
次のコマンドでデプロイします。
sbt deploy
デプロイのプロセスも sbt の task として定義しています。実態は serverless コマンドを叩いているだけです。
serverless でデプロイできるように Lambda の仕様に合わせています。
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
sbt native imageの nativeImageRunAgentでリフレクションやjniの設定ファイルを自動生成できるので、もし試していなければ一度試してみるといいかと。
コメントありがとうございます!sbt-native-imageにもAgent呼べる機能あるんですね。
また試してみます!