Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Scala 3は普通にAWS Lambdaで実行できる

そういえばScalaをAWS Lambdaで実行するのどうするんだっけ、と思った。実はScalaをLambdaで動かしたことは無い気がするので調べておいた。今回はJava 17を前提に動作させる。

今回書いたサンプルプロジェクトはこちら。

github.com

tl;dr

  • sbt-assemblyを使ってUber JARを生成する
  • AWS LambdaをJavaランタイムでセットアップする
  • エントリポイントを指定する
  • JARをアップロードする
  • 動く

AWS LambdaのJavaランタイム

けっこう前から、AWS LambdaはJavaランタイムをサポートするようになっている。

docs.aws.amazon.com

aws.amazon.com

JavaをコンパイルしたJARファイルをアップロードすると、それを実行してくれるようになっている。

ScalaもJVM言語なのでJARファイルを生成をできるし、Javaランタイムからすれば違いは分からないので、JARをアップロードしてエントリポイントを指示すると普通に実行できる。

sbt-assembly

Scala、JARときたらsbt-assemblyを使うのがほぼ定番の選択肢だ。sbt-assemblyは、sbtプロジェクトのScalaソフトウェアを1つの実行可能なJARファイル(UberJARという)にビルドしてくれるプラグインだ。

github.com

sbt-assemblyを使ってプロジェクトをビルドするには、以下の2つを行えば最低限の設定ができる:

  • sbt-assemblyプラグインを利用することを宣言する
  • 1つのJARファイル内でファイル名の衝突が起こった場合どうするか指定する

それぞれ見ていこう。

sbt-assemblyへの依存性の宣言

project/plugins.sbtがなければ作成し、以下のように依存性を指定する。sbt-assemblyは、執筆時点での最新版を利用している。

addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.1")

ファイル名衝突時の設定

ふつうScalaやJavaは、クラスごとにクラスファイルというものが生成されるが、JARファイルはこれを一手にパッケージングするフォーマットだ。するとファイル名が衝突してしまうことがたまにある(logbackの設定などがよく衝突する)。JARを生成するためにはこうした衝突が発生したときにどうするかを設定しなければならない。

build.sbtに以下のように設定しよう:

ThisBuild / assemblyMergeStrategy := {
  case PathList("META-INF", xs @ _*) => MergeStrategy.discard
  case x                             => MergeStrategy.first
}

META-INFファイルが衝突したら両方とも削除し、それ以外の場合は先にあったファイルを優先する。

その他のsbtの設定

これで最低限Uber JARを生成する準備はできたが、Lambda固有の設定もしておこう。

これは公式ブログに書いてあったことなので今も必要なのかは不明だが、実行時のランタイムとなるJavaのバージョンをコンパイルオプションで指定しておく。

build.sbtの先頭らへんに以下のように記述する:

javacOptions ++= Seq("-source", "17", "-target", "17", "-Xlint")

最終的なbuild.sbt

最終的にbuild.sbtは以下のようになる:

val scala3Version = "3.2.0"

javacOptions ++= Seq("-source", "17", "-target", "17", "-Xlint")

lazy val root = project
  .in(file("."))
  .settings(
    name := "aws-lambda-scala-exercise",
    version := "0.1.0-SNAPSHOT",
    scalaVersion := scala3Version,
    retrieveManaged := true,
    libraryDependencies += "com.amazonaws" % "aws-lambda-java-core" % "1.2.2",
    libraryDependencies += "com.amazonaws" % "aws-lambda-java-events" % "3.11.2"
  )

ThisBuild / assemblyMergeStrategy := {
  case PathList("META-INF", xs @ _*) => MergeStrategy.discard
  case x                             => MergeStrategy.first
}

aws-lambda-java-coreaws-lambda-java-eventsは、Lambdaで各種のイベントを受け取るときの型を定義しているライブラリだ。今回は使わなかったが、参考文献を参照して活用してほしい。

コード本体

さて、今回は単純にHello Worldを行うだけのプログラムを実行してみよう。src/main/scala/com/github/windymelt/lambdaexercise/Main.scala *1 に以下のコードを書いた:

package com.github.windymelt.lambdaexercise

object Main {
@main def hello: Unit =
  println("Hello world!")
  println(msg)

def msg = "I was compiled by Scala 3. :)"
}

ビルド

コードを書いたら、sbt assemblyを実行してJARファイルを生成する。target/scala-3.2.0/aws-lambda-scala-exercise-assembly-0.1.0-SNAPSHOT.jarが生成されるはずだ。

AWS Lambda側の設定

まずはLambda関数を作成しよう。

アーキテクチャはお好みでよいが、ランタイムは現時点で最新のJava 17に設定する。

次に設定を開いてエントリポイントとなるメソッドを指定する。今回はcom.ginthub.windymelt.lambdaexercise.Main::helloがエントリポイントだ。

次に生成されたJARファイルをアップロードする。今回はそれほどデカくないが、デカくなりすぎた場合はS3を検討しよう。

これで準備は完了だ。テストタブから適当なダミーデータを使ってテストを実行すると、数秒の後で成功するはずだ:

初回はクラスロード等に少し時間がかかるため600msかかってしまった。ランタイムが活きている間は、次回以降の実行は非常に高速だ:

1msちょっとで動作した。JITによる最適化もかかるだろうから、速度の面ではあまり心配はなさそう。数十分経過するとランタイムは停止してまた元の状態に戻る。

まとめと感想

  • Scala 3のUber JARを使ってAWS LambdaでScala 3の簡単なサンプルを実行した。
  • ライブラリを使ってイベントを処理できることを紹介した。
  • 初回実行は少し時間がかかるが二回目からは高速になることを紹介した。

JARをあげればそのまま動くというのがかなり良くて、例えばコンテナを挟まなければならなかったりすると起動時間がより不利になっていたかもしれない。JARのカジュアルさは大きいと思う。JVMをそのまま使えるから、GraalVMやScala Nativeの変な地雷を踏み抜くということがなく、普通に動いてくれる。JVMだから普通に手元で動くようなコードが動作することを想定してよくて、例えばcatsやhttp4sなども使える。今後SmithyによってAWSの機能を呼び出すといったことも可能かもしれない。

とはいえScalaのUberJARはふくらみがちなので、実用上はS3を経由してアップロードすることになるだろうと思う。Dockerイメージよりはマシだと思う。

参考文献

github.com

Javaを使ったLambdaのサンプルが掲載されている。要するにHandlerの型を合わせて、Lambdaのコンソールからそのメソッドをエントリポイントとして指定すればよい。

追記

Lambdaを動かすにはコンテナランタイムというのもある。こちらはECSを使ってカスタムランタイムを動かすというちょっと大掛りな手法だが、Lambdaのアーティファクトサイズが250MiBであるという制限(Scalaはけっこう当たりそう)を回避できるという利点がある。そのかわりカスタムランタイムを書くことになるのでちょっと面倒。

初動を爆速にしなければならず、しかも呼ばれる間隔が広くてランタイムが止まってしまう、といった場合にはGraalVM Native Imageの利用も検討するとよさそう。

zenn.dev

*1:sbt new scala/scala3.g8のテンプレートで出てくるやつとほぼ同じ内容

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?