現代のクラウドネイティブな開発においてはいわゆるFaaS(Function as a Service)が欠かせない存在となりつつある。AWSではAWS Lambdaとして、Google CloudではCloud Functionsとして提供されるこのサービスは、小さなコード片をマシンのデプロイを行うことなしに単体で実行できるようにする。実行環境はサービス側で完全に制御され、われわれは実行したいコードだけ押し付ければよい。
また、Scalaにおける強力な非同期実行フレームワークであるCats EffectはScalaの強力な型システムに高性能な非同期処理スケジューリング機能を付与するものであり、JVM環境はもちろん、Javascript環境においてもScala.jsの力を利用して動作することができる、大変強力なフレームワークだ *1。このフレームワークは非同期処理をIO
というオブジェクトとしてパッケージし、プログラマがIO
を組み合わせ、ランタイムがこれを賢く実行するという手法で高効率な非同期処理を達成する。
しかしながらCats EffectはそのままだとFaaSと相性が悪い。Cats Effectのランタイムはアプリケーションの最初に起動し、そして最後に終了することを想定して作られており、FaaSのようにただメソッドを呼び出して終わりといったシチュエーションでは、IO
実行の面倒をプログラマが見なければならない。具体的には、スケジューラの起動、シグナルのハンドリング、graceful shutdownなどを行わなければならない。これらは本来はIOApp
というオブジェクトがやってくれるはずだ。でもIOApp
はアプリケーションとして最初に呼び出されて最後まで居ることを想定したオブジェクトであるため、処理が完了するとsys.exit
を呼び出してしまう。このため、ランタイムが起動し続けることを前提としたFaaSとの相性が悪いのだ。
このため、Cats Effectを開発しているTypelevelは、このような環境でも容易にCats Effectを実行するために、Cats EffectをFaaS上で実行するための基盤であるFeralを提供している。これを利用することで、ユーザは本来やりたかったIO
を記述していくという仕事に集中できるようになる。IO
をどう実行してリクエストを返すのかといった仕事はFeralが全てやってくれる。
この記事ではFeralの初歩的な利用方法を紹介し、Cats EffectのIO
をAWS Lambda上のNodeランタイムで動作させる方法を解説する。
Feralとは
Feralは、前述した通りCats EffectをFaaS基盤に適合させるライブラリであり、また追加の便利なsbtコマンドを提供するプラグインである。
既にCats Effectで書かれたコードに対してFeralを利用することで、AWS LambdaなどのFaaS環境でそのコードがうまく動作できるようにし、リクエストとレスポンスの変換といった面倒を見てくれる。
FeralはTypelevelが開発しており、分散トレーシングライブラリであるNatchezやHTTPサーバ/クライアントライブラリであるhttp4sに最初から(out-of-the-box)対応している。とりあえずFeralを入れておけば万事FaaSは良い感じになる、そんなフレームワークだ。
現時点ではFeralはAWS Lambdaのみにフォーカスして対応しているが、順次他のクラウドサービスプロバイダにもサポートを広げるとしている。
執筆時点でFeralはv0.3.0がリリースされており、それなりに活発に開発されている。
ちなみにferalというのは野良という意味だ。家となるサーバがない環境でCatsが動作するわけだから野良猫だね、というダジャレになっている。
今回作成する関数
今回はFeralの標準的な使い方を紹介するだけに留めるため、以下のような非常に簡単な関数を作ることにする:
- 関数の引数として
{"message":"文字列"}
というJSON文字列を受け取る。 - 実行時にログに
Lambda started on Feral!
という文字列を表示する。 - 関数のレスポンスとして空のJSONを返す。
実行環境
FeralはJVMにもScala.jsにも対応している。しかし今回はScala.jsで開発する。一般にこういうFaaSでは起動時間が有利なスクリプト言語、とりわけNode.jsが実行環境として選ばれることが多い。JVMは実行時のパフォーマンスに優れるが起動時のレイテンシがかなり重いためだ。
そしてコンパイルしたJavascriptはAWS LambdaのNode.js 20.xランタイムで実行することにした。
実装
ではここからは実装していこう。手順としてはそれほど複雑ではない。誰でも真似できる。
プロジェクトの作成
まずプロジェクトを作成しよう。
FeralのScala.js版実装はsbtのプラグインとして提供されるため、sbtプロジェクトをセットアップする。
% mkdir feral-exercise % sbt new scala/scala3.g8 -o . [info] welcome to sbt 1.7.1 (GraalVM Community Java 19.0.2) [info] loading global plugins from /home/windymelt/.sbt/1.0/plugins [info] set current project to new (in build file:/tmp/sbt_210177e7/new/) A template to demonstrate a minimal Scala 3 application name [Scala 3 Project Template]: feral exercise Template applied in .
ライブラリのインストール
Scala.jsでFeralを利用するにはsbtプラグインといくつかのライブラリを利用することになる。順に入れていこう。
まずproject/plugins.sbt
を作成し、以下のように書き込む:
addSbtPlugin("org.typelevel" %% "sbt-feral-lambda" % "0.2.2") addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.16.0")
バージョンは執筆時点での最新なので、必要に応じて最新のバージョンを選んでほしい。
次にbuild.sbt
を開き、Scala.jsとFeralを利用するための設定を行う:
val scala3Version = "3.4.2" // 執筆時点での最新 val circeVersion = "0.14.1" // JSONライブラリのバージョン。執筆時点での最新 lazy val root = project .in(file(".")) .enablePlugins(LambdaJSPlugin) // FeralをScala.jsで使うよという設定 .settings( name := "feral exercise", version := "0.1.0-SNAPSHOT", scalaVersion := scala3Version, // FeralのHTTPまわりはhttp4sというライブラリでやるよという設定 libraryDependencies += "org.typelevel" %%% "feral-lambda-http4s" % "0.2.2", // リクエストとレスポンスのJSONをエンコード・デコードするためのJSONライブラリ libraryDependencies ++= Seq( "io.circe" %% "circe-core", "io.circe" %% "circe-generic", "io.circe" %% "circe-parser" ).map(_ % circeVersion), )
これでプロジェクトの準備は完了。
関数の定義
Feralでは、FaaS用関数定義はobject
を定義することで行う*2。しかしその前に、入出力で利用するケースクラスを用意しよう。
今回は引数としてParam
を定義する:
case class Param(message: String) derives io.circe.Decoder
Feralはio.circe.Decoder
が定義されているオブジェクトであればなんでも引数として受けることができ、そしてio.circe.Encoder
が定義されているオブジェクトであればなんでもレスポンスとして返すことができる。
今回はScala 3のderives
機能を利用して自動的にDecoder
を作ってもらった。詳細は割愛するが、JSONライブラリのCirceと組み合わせると自動的にJSONエンコーダやデコーダを作ってくれる。
満を持して関数の本体を作ろう。
import cats.effect.{*, given} import feral.lambda.{*, given} object lambda extends IOLambda.Simple[Param, Unit] { def apply(event: Param, context: Context[IO], init: Init): IO[Option[Unit]] = { for { _ <- IO.println("Lambda started on Feral!") _ <- IO.println(s"got message: [${event.message}]") } yield Some(()) } }
まず、object
はferal.lambda.IOLambda
をextends
する必要がある。今回はいくつかの複雑な要素を省略したIOLambda.Simple
を利用する。
IOLambda.Simple
は2つの型引数を取る。1つ目は入力の型、2つ目はレスポンスの型である。前述の通り、それぞれDecoder
とEncoder
が必要になる。今回はParam
を入力として利用し、Unit
を出力として使う。Circeでは、Unit
はデフォルトでは{}
に変換されるのでEncoder[Unit]
を用意する必要がない。
次に、IOLambda.Simple
ではapply
関数を実装する必要がある。これはIDEに任せれば勝手に型定義をやってくれるのでそんなに考えることはないが、event
には入力が、context
にはLambdaの実行環境の情報が、Init
にはランタイム起動時に初期化する(今回は省略)情報が入る。Init
はランタイムが停止するまで再作成されず保持され続ける。
また、apply
はIO[Option[出力の型]]
を返す必要がある。None
を返した場合は成功するがnullを返す振舞いになっている。
今回はこれで関数は完了だが、IO
であればなんでも実行できるのでより複雑な処理もこなせる。
パッケージング
Lambda用のjsファイルを生成するにはsbt
上でnpmPackage
を実行する。
sbt:feral exercise> npmPackage [info] Wrote README.md to /home/windymelt/src/github.com/windymelt/feral-exercise/target/scala-3.4.2/npm-package/README.md [info] compiling 1 Scala source to /home/windymelt/src/github.com/windymelt/feral-exercise/target/scala-3.4.2/classes ... [info] Wrote package.json File At - /home/windymelt/src/github.com/windymelt/feral-exercise/target/scala-3.4.2/npm-package/package.json [info] Full optimizing /home/windymelt/src/github.com/windymelt/feral-exercise/target/scala-3.4.2/feral-exercise-opt [info] Closure: 0 error(s), 0 warning(s) [info] Wrote /home/windymelt/src/github.com/windymelt/feral-exercise/target/scala-3.4.2/feral-exercise-opt.js to /home/windymelt/src/github.com/windymelt/feral-exercise/target/scala-3.4.2/npm-package/index.js [success] Total time: 10 s, completed 2024/08/04 23:27:59
するとtarget/scala-(Scalaバージョン)/npm-package
以下にファイルが生成される。
デプロイ
今回はAWS LambdaにZIP形式でアップロードするため、いったんZIPファイルを生成する。
% cd target/scala-3.4.2/npm-package % zip -r ../../../lambda.zip index.js* updating: index.js (deflated 73%) updating: index.js.map (deflated 75%)
ZIPファイルができたらAWS LambdaでLambda関数を作成する。
FeralのページにはNode.js 18で利用しろと書いてあるけれど、今のところ20で利用しても問題がなかったので20とした。アーキテクチャはお好きなほうで。
ZIPファイルをアップロードする。
このままだとLambdaランタイムがどのハンドラを実行したらよいか分からないので、ランタイム設定を行う。ハンドラ名はindex.オブジェクト名
に設定する。 この場合はindex.lambda
を設定しよう。
テスト
Lambdaコンソールからテストイベントを送信してみよう。入力として定義通りmessage
フィールドを持つオブジェクトを渡そう。
テスト
ボタンを押下するとわれわれが定義したIO
が実行されて結果が返ってくる。
まとめ
この記事ではFeralを用いてCats Effect 3をAWS Lambda環境で簡単に実行する方法を紹介した。この記事では触れなかったがFeralはAWSの各種のイベントの型定義を最初から用意してくれていたり、分散トレーシングの仕組みを持っていたりと何かと気が利く存在だ。興味が湧いた人は公式のサンプルコードを読んでみてほしい。また、FeralはTypelevelによって開発されているため、他のTypelevelファミリのライブラリと非常に相性が良い。例えばfs2やSkunkともうまく協働できることだろう。