Lambdaカクテル

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

Invite link for Scalaわいわいランド

Feral: Cats EffectとScala.jsを使って高性能なAWS Lambda Functionを迅速に作ろう

現代のクラウドネイティブな開発においてはいわゆるFaaS(Function as a Service)が欠かせない存在となりつつある。AWSではAWS Lambdaとして、Google CloudではCloud Functionsとして提供されるこのサービスは、小さなコード片をマシンのデプロイを行うことなしに単体で実行できるようにする。実行環境はサービス側で完全に制御され、われわれは実行したいコードだけ押し付ければよい。

aws.amazon.com

また、Scalaにおける強力な非同期実行フレームワークであるCats EffectはScalaの強力な型システムに高性能な非同期処理スケジューリング機能を付与するものであり、JVM環境はもちろん、Javascript環境においてもScala.jsの力を利用して動作することができる、大変強力なフレームワークだ *1。このフレームワークは非同期処理をIOというオブジェクトとしてパッケージし、プログラマがIOを組み合わせ、ランタイムがこれを賢く実行するという手法で高効率な非同期処理を達成する。

typelevel.org

しかしながら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コマンドを提供するプラグインである。

github.com

既に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が動作するわけだから野良猫だね、というダジャレになっている。

eow.alc.co.jp

今回作成する関数

今回は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(())
  }
}

まず、objectferal.lambda.IOLambdaextendsする必要がある。今回はいくつかの複雑な要素を省略したIOLambda.Simpleを利用する。

IOLambda.Simpleは2つの型引数を取る。1つ目は入力の型、2つ目はレスポンスの型である。前述の通り、それぞれDecoderEncoderが必要になる。今回はParamを入力として利用し、Unitを出力として使う。Circeでは、Unitはデフォルトでは{}に変換されるのでEncoder[Unit]を用意する必要がない。

次に、IOLambda.Simpleではapply関数を実装する必要がある。これはIDEに任せれば勝手に型定義をやってくれるのでそんなに考えることはないが、eventには入力が、contextにはLambdaの実行環境の情報が、Initにはランタイム起動時に初期化する(今回は省略)情報が入る。Initはランタイムが停止するまで再作成されず保持され続ける。

また、applyIO[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ともうまく協働できることだろう。

*1:ちなみにScala Native環境でも動作する

*2:JVM版ではclassを使う

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