/var/log/jsoizo

メモ帳 技術とか趣味とか

Scala + GraalVM AOTコンパイル + AWS Lambdaでwebアプリを動かしたい

この記事は スタンバイ 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に置いてある。

github.com

ポイント

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プロジェクトとして構築する。

※ 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

デプロイはAWS SAM CLIによって行う

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のイベントを直列に処理してるのでスループット悪いよねとか。

この辺はもしもプロダクトで本格的に使いたいとなったとしたらネックになりそうだなとは思うが実験なので目をつむることにする。

参考

zenn.dev

github.com

zenn.dev

docs.aws.amazon.com