この記事は スタンバイ Advent Calendar 2021 の21日目です。
はじめに
業務ツール用の適当なアプリケーションを動かしたいだとか、サービス間の連携のためにちょっとpub/subから取り出して他のdbやファイルサーバに保存するだけとか、そういう用途でAWS Lambdaを使いたいケースは往々にして存在するが、 Scalaエンジニアとしては極力慣れたScalaコードを書いていたい(ほかを学ぶのが面倒くさいだけ)。
一方でScalaで書いたアプリケーションをAWS LambdaのJavaランタイムで動かそうとするとJVMのコールドスタートが遅い問題があり、
それを避けるためにGraalVMのAOTコンパイル=ネイティブバイナリを出力してLambdaのカスタムランタイムで動かす事例も見受けられる。
今回はそこからもう少し踏み込んで、
Scalaで実装したWebアプリケーションをGraalVMのAOTコンパイルした上でAWS Lambda上で動かす
ということをやってみるのが趣旨。Webアプリケーションがこの構成で簡易に作れるということがわかると大抵をLambda化できるのでシステム設計の幅が広がるんじゃなかろうか。また、AWS SDK JavaもGraalVMのAOTコンパイルに対応したのでAWSのソリューションを使ったアプリケーションについてはほぼ障壁なくできるかと。
実装したものはgithubに置いてある。
ポイント
AWS Lambda + API GatewayでWebアプリケーションを構築する場合、 API Gatway経由のHTTPリクエスト情報がAWS Lambdaのイベントとなるため、 これをパースしてHTTPリクエストのオブジェクトに変換できるとLambda上で動くWebアプリケーションが実装可能となる。
幸いにもtapirでまさにそういった事ができるようなので利用する。
tapir.softwaremill.com
tapirはWebアプリケーションのビジネスロジックの宣言と実行を分離するためのライブラリ
であり、HTTPサーバに何を使うかに関係なく同じようにロジックを実装できる。さらにAPI GatewayのHTTP ApiとLambdaを接続した際にLambdaに伝達されるイベント(のJSONと同じフィールドを持った値)をルータに渡すことができるようになっているので今回の目的にちょうどマッチする。
プロジェクト構成
以下を一つのsbtプロジェクトとして構築する。
- api : APIのビジネスロジック
- lambdaNative : APIをAWS Lambda上で動作させるための実装
- apiに依存
- sbt-native-image プラグインを利用
- local : APIを一般的なHTTPサーバで動作させるための実装
- apiに依存
※ tapirを利用することで得られるメリットの一つとしてHTTPサーバの置き換えが容易になる事が挙げられる。そのメリットを感じるために同一のロジックを別のサーバで動かしてみている。
ビジネスロジックの実装
ビジネスロジックだけが実装されたsbtプロジェクトを作成する。
lazy val api = project .in(file("api")) .settings( name := "api", libraryDependencies ++= Seq( "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirVersion, "org.typelevel" %% "cats-effect" % catsEffectVersion, ) ) lazy val apiDependency: ClasspathDependency = api % "test->test;compile->compile"
このプロジェクトの中で以下のような単純なレスポンスを返すだけのエンドポイントを定義する。
trait ApiRoute { val pingEndPoint: ServerEndpoint[Any, IO] = endpoint.get .in("api" / "ping") .out(stringBody) .serverLogicSuccess(_ => IO.pure("pong!")) }
Lambda上で動作するアプリケーションの実装
sbtのプロジェクト設定。
今回のキモになる tapir-aws-lambda
の依存を追加するのと、 AWSのランタイムAPIを叩いてイベントを取得するのでHTTPクライアントライブラリも追加する。HTTPクライアントの実装はJava 11から追加されている標準のクライアントを利用する(※ AsyncHttpClientなどnettyベースのHTTPクライアントはnative-image化時に失敗するため)。
lazy val lambdaNative = project .in(file("lambda-native")) .enablePlugins(NativeImagePlugin) .dependsOn(apiDependency) .settings( name := "lambda-native", libraryDependencies ++= Seq( "com.softwaremill.sttp.tapir" %% "tapir-aws-lambda" % tapirVersion ) ++ Seq( "com.softwaremill.sttp.client3" %% "core", "com.softwaremill.sttp.client3" %% "circe", "com.softwaremill.sttp.client3" %% "httpclient-backend-fs2" ).map(_ % sttpVersion) ++ Seq( "io.circe" %% "circe-core", "io.circe" %% "circe-generic", "io.circe" %% "circe-parser" ).map(_ % circeVersion), Compile / mainClass := Some("com.jsoizo.LambdaApp"), // native-package周りは後述 )
上記のApiRoute
をミックスインしつつ、
Lambdaのカスタムランタイムの仕様に則ってAPIサーバを実装していく。
object LambdaApp extends App with ApiRoute { // ルータ = ビジネスロジックの宣言 val options: AwsServerOptions[IO] = AwsCatsEffectServerOptions.default[IO].copy(encodeResponseBody = false) val route: Route[IO] = AwsCatsEffectServerInterpreter(options).toRoute(pingEndPoint) // 環境変数からAWS LambdaのRuntime APIのホスト名を取得 val lambdaRuntimeApiHostName = Option { System.getenv("AWS_LAMBDA_RUNTIME_API") }.getOrElse("127.0.0.1:3001") // Runtime APIを呼び出して処理対象のリクエスト(Lambda呼び出しリクエスト)を取得する // APIのHTTPレスポンスはtapirのAwsRequest型へデコードする def callNextInvocationApi: IO[Response[Either[ResponseException[String, circe.Error], AwsRequest]]] = HttpClientFs2Backend.resource[IO]().use { basicRequest .get(uri"http://${lambdaRuntimeApiHostName}/2018-06-01/runtime/invocation/next") .response(asJson[AwsRequest]) .send(_) } // Runtime APIを呼び出してリクエスト(Lambda呼び出しリクエスト)に対するレスポンスを送信する def callResponseApi(requestId: String, response: AwsResponse): IO[Response[Either[String, String]]] = HttpClientFs2Backend.resource[IO]().use { basicRequest .post(uri"http://${lambdaRuntimeApiHostName}/2018-06-01/runtime/invocation/${requestId}/response") .body(response.asJson) .contentType("application/json") .send(_) } // HTTPヘッダーからAWS LambdaのリクエストIDを取り出す def getAwsRequestId(headers: Seq[Header]): IO[String] = { val requestIdHeaderName = "lambda-runtime-aws-request-id" IO.fromOption(headers.find(_.name == requestIdHeaderName).map(_.value))( new RuntimeException(s"Cannot found ${requestIdHeaderName} header from request") ) } // HTTPの処理 = ルータに対してLambdaのイベントを渡してビジネスロジックを呼び出す def handleHttpBody(awsRequest: AwsRequest): IO[AwsResponse] = route(awsRequest) // RuntimeApiのボディパースエラーをレスポンスに変換する def handleRuntimeApiBodyError(error: ResponseException[String, circe.Error]): IO[AwsResponse] = { val errorMessage = error match { case DeserializationException(body, e) => s"${e.getMessage} body: ${body}" case e => e.getMessage } IO.pure(AwsResponse(Nil, isBase64Encoded = false, StatusCode.BadRequest.code, Map.empty, errorMessage)) } while (true) { val io = for { // RuntimeApiを呼び出し次のイベントを取り出す nextInvocationApiResult <- callNextInvocationApi // RuntimeApiのレスポンスヘッダからLambdaリクエストのIDを取得 requestId <- getAwsRequestId(nextInvocationApiResult.headers) // RuntimeApiのリクエストボディ=Lambdaイベントのパースに // 成功 => イベントをもとにルータ呼び出してレスポンスを返却 // 失敗 => 失敗時のレスポンスを返却 response <- nextInvocationApiResult.body match { case Right(awsRequest) => handleHttpBody(awsRequest) case Left(error) => handleRuntimeApiBodyError(error) } // ルータから帰ってきた結果をRuntimeApiに返却 _ <- callResponseApi(requestId, response) } yield () io.unsafeRunSync() } }
native-image化
アプリケーションをnative-image化するためのsbtの設定。
nativeImageInstalled := true, nativeImageOptions += s"-H:ReflectionConfigurationFiles=${baseDirectory.value / "native-image-configs" / "reflect-config.json"}", nativeImageOptions += s"-H:ConfigurationFileDirectories=${baseDirectory.value / "native-image-configs"}", nativeImageOptions += "-H:+JNI", nativeImageOutput := target.value / "native-image" / "bootstrap", nativeImageVersion := "21.3"
GraalVMのAOTコンパイルする際に、リフレクション等を利用している箇所は明示してあげる必要があるが、それらの設定である refrect-config.json
などの生成のために一度 sbt nativeImageRunAgent
を叩いておく。
なおデフォルトだとtarget配下に生成されるが、一般的にはtarget以下はgitignoreの対象でありCI/CDすることを考えると毎回これらのファイル生成のためだけにアプリケーションを実行するのは微妙だと考えるので、target以外のディレクトリに出力してgit管理することにした。
Lambda用ビルド & デプロイ
Lambdaで動かすためのビルド用スクリプト。LambdaのカスタムランタイムはAmazon Linux上で動作するためMac等でビルドする場合はDocker等でLinux環境を用意しつつビルドする必要がある。そのために以下2つ用意しておく。
① ビルド環境を作るスクリプト (create_build_container.sh
, buildcontainer.dockerfile
)
#!/bin/sh SCRIPT_DIR=$(cd "$(dirname $0)" && pwd) DOCKER_FILE_PATH="${SCRIPT_DIR}/../buildcontainer.dockerfile" cd "${SCRIPT_DIR}" || exit BUILD_IMAGE_NAME="tapir-aws-lambda-build" docker build -t ${BUILD_IMAGE_NAME} -f ${DOCKER_FILE_PATH} .
FROM hseeberger/scala-sbt:graalvm-ce-21.3.0-java11_1.5.5_3.1.0 RUN microdnf install gcc glibc-devel zlib-devel libstdc++-static && gu install native-image
② ビルドを行うスクリプト (build_lambda_binary.sh
)
#!/bin/sh SCRIPT_DIR=$(cd "$(dirname $0)" && pwd) PROJECT_DIR=$(cd "${SCRIPT_DIR}/../../" && pwd) TARGET_DIR="${PROJECT_DIR}/lambda-native" BUILD_IMAGE_NAME="tapir-aws-lambda-build" MOUNT_DIR="/app" # clean targets rm -rf "${TARGET_DIR}/dist" rm -rf "${TARGET_DIR}/target" # build linux binary SBT_OPTS="-Xmx4096m" docker run \ -ti \ --rm \ -v "${PROJECT_DIR}:${MOUNT_DIR}" \ -v "${PROJECT_DIR}/.cache:/root/.cache" \ -e "SBT_OPTS=${SBT_OPTS}" \ -w "${MOUNT_DIR}" \ "${BUILD_IMAGE_NAME}" \ sbt lambdaNative/nativeImage
template.yaml
を以下のように記述しLambda FunctionとAPI Gatewayを宣言。
AWSTemplateFormatVersion: 2010-09-09 Transform: AWS::Serverless-2016-10-31 Description: >- tapir-aws-lambda-native application. written in Scala, compiled by GraalVM native compiler, runs on Lambda custom runtime. Resources: Api: Type: AWS::Serverless::HttpApi Properties: AccessLogSettings: DestinationArn: !GetAtt AccessLogs.Arn Format: $context.requestId FailOnWarnings: true Function: Type: AWS::Serverless::Function Properties: FunctionName: tapir-aws-native-lambda-function Description: tapir-aws-native-lambda-function Runtime: provided.al2 Handler: bootstrap CodeUri: lambda-native/target/native-image/ MemorySize: 512 Timeout: 15 Events: ApiEvent: Type: HttpApi Properties: ApiId: !Ref Api Method: GET Path: /api/ping AccessLogs: Type: AWS::Logs::LogGroup Outputs: Api: Description: "API endpoint URL for Prod environment" Value: !Sub "https://${Api}.execute-api.${AWS::Region}.${AWS::URLSuffix}/" Function: Value: !GetAtt Function.Arn FunctionIamRole: Value: !GetAtt FunctionRole.Arn
s3にファイルを配置しAWS環境にデプロイする
sam package --template-file template.yaml --output-template-file packaged.yaml --s3-bucket {任意のS3バケット名} sam deploy --template-file packaged.yaml --stack-name {任意のスタック名} --capabilities CAPABILITY_IAM
動作確認
curl http://{API Gatewayのホスト名}/api/ping pong!
感想
タイトル通りのやりたいことが実現できたし実践でも行けるのではという感触を得たし、ビジネスロジックは共通でHTTPサーバの実装を切り替えることもできたのは実りがあった。
とはいえ自分でも突っ込みどころは多く、、、例えばなんでAPIのロジックはCats Effect使って実装してるの?って聞かれると「tapirのaws lambda用実装がCats Effectしか対応していないからだよ」と答えざるを得ずHTTPサーバの都合がロジックに影響あるということになっているとか。lambdaのイベントを直列に処理してるのでスループット悪いよねとか。
この辺はもしもプロダクトで本格的に使いたいとなったとしたらネックになりそうだなとは思うが実験なので目をつむることにする。