こんにちは。エムスリーエンジニアリンググループでScalaとマミさんが好きな安江です。今回は私が所属しているデジカルチームのお話です。ZIO Lambdaを使ってScalaネイティブバイナリをAWS Lambdaで動かしてみました。こちらの技術スタックの紹介をします。
背景
特定の処理で、外部から提供されたJARライブラリを使う必要がありました。弊社電子カルテはRails製なのですが、別のバックエンドにSkinny(Scala)製のサーバーを使っていたため、そのサーバーに処理を相乗りさせました。しかし、すでに使用していた依存ライブラリのバージョン違いで処理が動かなくなることがわかりました。当時は絶妙なハック1を使って解決していましたが、既存処理が外部JARライブラリによって不安定になること、今回の特定の処理がバッチ的な性格だったことから、その処理をAWS Lambdaで再実装することにしました。
技術スタック
AWS Lambdaで実装するにあたって、次の技術スタックを使用しました。
Scala
ScalaはJavaとの互換性が高く、今回のようなJavaのライブラリを使う際にも適しています。また、Scala3.3はScalaのLTSバージョンで、今後も比較的安定して利用できます。
ZIO
ZIOはScala向けのエフェクトシステムライブラリです。エフェクトシステムとは、副作用の発生(=環境への作用)を極力後回しにすることで、参照透過的な(=関数型)プログラミングスタイルを可能にするシステムのことです。より詳しい内容は弊社テックブログの過去記事もあわせてご覧ください。
コーディングの特徴として、クラスやメソッドの名前は一般的なプログラミング言語で使われる命名が優先され、圏論の単語が出てこないように設計されています2。そのため、圏論に造詣が深くないユーザーでも、関数型プログラミングのパワーを得ることができます。
公式サイトにEcosystemがまとまっているのも魅力的です。ZIOを使うことで「どんな課題を解決できるか」に興味がある方は、その一覧を見るだけでも参考になると思います。後述するZIO Lambdaも、ZIOのEcosystemを構成する1つです。
あと、ロゴがかっちょいいです。
ZIO Lambda
ZIO Lambdaは、ZIOを使ってAWS Lambdaを書くためのライブラリです。ZIO Lambdaは、AWS LambdaのイベントハンドラーをZIOのエフェクトとして扱うことができます。
GraalVM Native Image
GraalVM Native ImageはJavaプログラムを自己完結型の実行ファイル(ネイティブバイナリ)にコンパイルできます。実行にJVMは必要なく、distrolessイメージを利用できます。distrolessイメージはサイズが小さいため、AWS Lambdaのようなユースケースに最適です。
実装
これらの技術スタックを使うことで、簡単にAWS Lambdaを実装できます。ぜひそのパワーを感じて欲しいので、実際に動くHello Worldプログラムをご紹介します3。
プロジェクトの作成
まずはプロジェクトを作成します。
project/build.properties
sbt.version=1.10.0
後述するマルチステージビルドのために、sbt-assemblyを追加します。
project/plugins.sbt
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.2.0")
ライブラリの追加
次に、build.sbtにライブラリを追加します。Scala3.3でビルドするために、ZIO JSONの古いバージョンをevictedします。
build.sbt
lazy val root = project .in(file(".")) .settings( name := "hello-world-zio-lambda", version := "0.1.0-SNAPSHOT", scalaVersion := "3.3.3", libraryDependencies ++= Seq( "dev.zio" %% "zio-json" % "0.6.2", "dev.zio" %% "zio-lambda" % "1.0.4" ) ) // assembly .settings( assembly / mainClass := Some("com.example.Handler") ) // publish .settings( // 既存動作を上書き publish := { val awsAccount = sys.env("AWS_ACCOUNT") val awsRegion = sys.env.getOrElse("AWS_REGION", "ap-northeast-1") val registryName = s"$awsAccount.dkr.ecr.$awsRegion.amazonaws.com" val appName = name.value val repositoryName = s"$registryName/$appName" val appVersion = version.value val imageTag = s"$repositoryName:$appVersion" import scala.language.postfixOps import scala.sys.process.* s"aws ecr get-login-password --region $awsRegion" #| s"docker login --username AWS --password-stdin $registryName" !!; s"docker buildx build --platform=linux/arm64 --provenance=false --tag=$imageTag . --push" !!; // クラウドにLambdaを作成済みの場合は以下のコメントを外すことで、Lambdaのコンテナイメージを更新できる // s"aws lambda update-function-code --function-name $appName --image-uri $imageTag" !! } )
ハンドラーの作成
次に、AWS Lambdaのハンドラーを作成します。ZIO LambdaがAWS Lambdaハンドラーの詳細を隠蔽してくれるので、実装は純粋なロジックを書くだけで良いです。
src/main/scala/com/example/Handler.scala
package com.example import zio.{Task, ZIO, ZIOAppDefault} import zio.lambda.{Context, ZLambdaRunner} object Handler extends ZIOAppDefault: val handler: (Event, Context) => Task[String] = (event, _) => ZIO.succeed(s"Hello world! ${event.message}") override val run: Task[Unit] = ZLambdaRunner.serve(handler)
イベントはcase classで表現します。
src/main/scala/com/example/Event.scala
package com.example import zio.json.{DeriveJsonDecoder, JsonDecoder} final case class Event(message: String) object Event: given JsonDecoder[Event] = DeriveJsonDecoder.gen[Event]
ネイティブバイナリのビルド
ZIO Lambdaはzipファイルを使った実行と、コンテナイメージによる実行の2種類を利用できます。両方を試してみたところ、コンテナイメージによる実行のパフォーマンスが良かった4ので、以下ではコンテナイメージによる実行例を紹介します。
Dockerマルチステージビルドを使ってネイティブバイナリをビルドします。ZIO Lambdaではネイティブバイナリを作るためにsbt-native-packagerを使っていますが、こうすることで、デプロイ処理の中でも処理を共通化できます。
ビルドステージではイメージにdocker-sbtを使います。docker-sbtにはsbt
とnative-image
が同梱された便利なイメージがあります。ビルドには開発環境のファイルをコピーするため、適切な.dockerignore
を設定することをオススメします。ignore設定は開発環境によって変わるので、ここでは紹介を省きます。
実行ステージではイメージにdistrolessを指定できます。ただし、glibcを解決するためにdebian12を使用します。
Dockerfile
FROM sbtscala/scala-sbt:graalvm-community-21.0.2_1.10.0_3.3.3 AS build WORKDIR /build COPY . . RUN ./build.sh FROM gcr.io/distroless/base-debian12 WORKDIR /app COPY --from=build /build/hello-world-zio-lambda ./hello-world-zio-lambda CMD ["/app/hello-world-zio-lambda"]
native-image
を呼び出すコードをシェルスクリプトに分けることで、CIでも同じ設定でビルドできるようになります。
build.sh
#!/usr/bin/env sh sbt assembly native-image \ -jar target/scala-3.3.3/hello-world-zio-lambda-assembly-0.1.0-SNAPSHOT.jar \ -o hello-world-zio-lambda \ --no-fallback \ --install-exit-handlers \ --enable-http \ --link-at-build-time \ --report-unsupported-elements-at-runtime \ -H:+UnlockExperimentalVMOptions \ -H:+StaticExecutableWithDynamicLibC \ --verbose
作成される実行ファイルはstripされていませんが、strip
してもファイルサイズはさほど変わりません。
AWS Lambdaへのデプロイ
AWS Lambda用のコンテナーリポジトリをAmazon ECRに作成します。
リポジトリにイメージをプッシュします。
AWS_ACCOUNT=xxxxxxxxxxxx sbt publish
ここで、xxxxxxxxxxxx
はAWSアカウントです。
AWSコンソールでLambdaを新規作成します。自動で実行ロールとCloudWatchロググループを作成してくれます。作成画面では「コンテナイメージ」を選択します。とくにこだわりがなければ、料金の安いArmでビルドすることをオススメします。今回紹介したマルチステージビルドでは、Docker Buildxを使ってArmでビルドしています。
テストタブからテスト実行できます。コールドスタートだと3〜5秒くらいかかっています。ホットスタートだと数ミリ秒で処理ができています。
コールドスタート | ホットスタート |
AWS Lambdaを更新する時は、build.sbt内のコメントを外してsbt publish
を実行します。
まとめ
ZIO Lambdaを使ってScalaネイティブバイナリをAWS Lambdaで動かすことができました。既存アプリとプロジェクトが別れたことで依存ライブラリの不安定要因を取り除くことができました。またインフラ費用を抑えつつ、ネイティブバイナリによる低メモリ使用量と実行速度を手に入れることができました。AWS LambdaやZIOに馴染みがない方は、ぜひこのサンプルを通して関数型プログラミングのパワーを体験してみてください。
We are hiring !!
エムスリーでは、ScalaやZIOなどを使って医療業界に貢献していくことに興味がある仲間を募集しています!
まずはカジュアル面談から、以下URLよりご応募をお待ちしています。