「色々な方法」って何?
時は2018年末、Scalaで書いたプログラムを動かす方法はいつの間にか色々増えました。
- 普通にJVM上で動かす
- Scala.jsとして書いてnode.js上で動かす
- ScalaNativeとして書いてネイティブコードのバイナリを動かす
- GraalVMのnative-imageでネイティブコードのバイナリに変換して動かす
- AmmoniteでScalaスクリプトを動かす
AWS LambdaでScala製プログラムを動かす場合1, 2は結構前からできましたが、
この前出たCustom Runtimeを使えば3,4(,51)の方法でも動かせるのでは、
と思ったので試してみました
各方法毎の詳細
0. 前提
Lambdaで動作させるメインの処理
object Logic {
def fibonacci(n: BigInt): (BigInt, Map[BigInt, BigInt]) = {
def f(nn: BigInt, memo: scala.collection.mutable.Map[BigInt, BigInt]): BigInt = {
nn match {
case BigIntSupport(0) => 0
case BigIntSupport(1) => 1
case i if i >= 2 =>
memo.getOrElse(i, {
val fibi = f(i - 1, memo) + f(i - 2, memo)
memo.put(i, fibi)
fibi
})
case _ => throw new IllegalArgumentException("must be equal or more than 0")
}
}
val memo = scala.collection.mutable.Map[BigInt, BigInt]()
val result = f(n, memo)
(result, memo.toMap)
}
object BigIntSupport {
def unapply(arg: BigInt): Option[Int] = if(arg.isValidInt) Some(arg.intValue()) else None
}
}
上記の処理に、パラメータとして130
を渡して実行
1. 普通にJVM上で動かす
普通なので詳細は割愛します。
普通に公式ドキュメント等を参照してください。
ちなみに、コールドスタート(512MB)で1998.51 ms(メイン処理部分で495ms)かかりました。
(昔試したときはJVMの初期化だけで8秒位かかってた(多分)ので、それに比べるとかなり速くなってる気がする)
2. Scala.jsとして書いてnode.js上で動かす
方法をざっくり言うと
- Scala.jsでCommonJSモジュールとして出力
- node.js用エントリポイントのjavascriptからrequire()して呼び出す
- zipに固めてデプロイ
といった感じです。
ポイントとしては、
build.sbtでは
scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) }
を記述し、
scalaJSUseMainModuleInitializer := true
を記述しないようにします。
また、Scala.js側のエントリポイントでは
クラスに@JSExportTopLevel
を付与し、
呼び出し対象のメソッドに@JSExport
を付与します。
build.sbt
enablePlugins(ScalaJSPlugin)
name := "scalajs-lambda-sample"
version := "1.0"
scalaVersion := "2.12.8"
scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) }
Scala.js側のエントリポイント
package jp.ne.khddmks
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}
import scala.util.{Failure, Success, Try}
@JSExportTopLevel("Main")
class Main {
@JSExport
def entrypoint(param: String): String = {
val n = Try(param.toInt) match {
case Success(value) => value
case Failure(_) => throw new IllegalArgumentException("require numeric param")
}
val result = logic(n)
result.toString
}
def logic(n: BigInt): BigInt = {
val start = System.currentTimeMillis()
val (fib, memo) = Logic.fibonacci(n)
println(s"""fibonacci($n) : $fib""")
println(s"""memo : ${memo.toList.sortBy(_._1).map { case (k, v) => s"$k -> $v" }.mkString(", ")}""")
println(s"""total time : [${System.currentTimeMillis() - start}]""")
fib
}
}
Node.js側のエントリポイント
const Main = require("./scalajs-sample-2-opt").Main();
exports.myHandler = function(event, context, callback) {
const param = event.param;
if (typeof param !== "string" || param === "") {
callback("param required");
return;
}
const result = Main.entrypoint(param);
callback(null, result);
}
ちなみに、コールドスタート(128MB)で450.78 ms(メイン処理部分で380ms)かかりました。
3. ScalaNativeとして書いてネイティブコードのバイナリを動かす
方法をざっくり言うと
- ScalaNativeで実行するバイナリをビルドする
- AWS LambdaのCustom Runtime用のbootstrapを用意する
- zipに固めてデプロイ
といった感じです。
ポイントとしては
build.sbtでは
nativeMode := "release"
を記述します。
(性能に差が出てくるため。開発中はsbt上でset nativeMode := "debug"
を指定した方がいい)
また、デプロイするzipには、bootstrap, ScalaNativeで生成したバイナリに加え、
libunwind.so.8, libunwind-x86_64.so.8
を含めます2。
build.sbt
scalaVersion := "2.11.12"
nativeLinkStubs := true
enablePlugins(ScalaNativePlugin)
nativeMode := "release"
ScalaNativeのエントリポイント
package jp.ne.khddmks
import scala.util.{Failure, Try}
import scala.util.control.NonFatal
import java.io.{PrintWriter => PW}
import java.io.{BufferedWriter => BW}
import java.io.{FileWriter => FW}
object Main {
def main(args: Array[String]): Unit = {
val oParam = for {
arg <- args.headOption
param <- Try(arg.toInt).toOption
resultFilePath <- args.tail.headOption
} yield (param, resultFilePath)
val (param, resultFilePath) = oParam match {
case Some(p) => p
case None => throw new IllegalArgumentException("require numeric param and result file path")
}
val result = logic(param)
using(new PW(new BW(new FW(resultFilePath))))(_.close()) { bw =>
bw.write(result.toString)
}
}
def logic(n: BigInt): BigInt = {
val start = System.currentTimeMillis()
val (fib, memo) = Logic.fibonacci(n)
println(s"""fibonacci($n) : $fib""")
println(s"""memo : ${memo.toList.sortBy(_._1).map { case (k, v) => s"$k -> $v" }.mkString(", ")}""")
println(s"""total time : [${System.currentTimeMillis() - start}]""")
fib
}
def using[A, B](res: => A)(closer: A => Unit)(body: A => B): Try[B] = {
var r: Option[A] = None
try {
r = Some(res)
r.fold[Try[B]](Failure(new RuntimeException("error occurred when resource opened")))(rr => Try(body(rr)))
} catch {
case NonFatal(t) => Failure(t)
} finally {
Try(r.foreach(rr => closer(rr)))
}
}
}
bootstrap
#!/bin/sh
set -euo pipefail
# Execution file path
EXEC_BIN_NAME="$(echo $_HANDLER | cut -d. -f1)"
EXEC_BIN_PATH=$LAMBDA_TASK_ROOT/${EXEC_BIN_NAME}
# Processing
while true; do
HEADERS="$(mktemp)"
# Get an event
EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
# Execute
RESULT_FILE="$(mktemp "/tmp/${EXEC_BIN_NAME}_$(date "+%Y-%m-%d_%H-%M-%S_%N")_XXXXXX")"
$EXEC_BIN_PATH "$EVENT_DATA" "$RESULT_FILE"
RESPONSE=$(cat $RESULT_FILE)
# Send the response
curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" -d "$RESPONSE"
done
ちなみに、コールドスタート(128MB)で455.55 ms(メイン処理部分で59ms)かかりました。
なお、今回はScalaNativeで生成したバイナリ側の処理結果をシェルスクリプトに受け渡すうまい方法が思いつかなかった3ため、「処理結果を一時ファイルに出力し、それをシェルスクリプト側で読み込む」ようにしていますが、それが処理時間を長くする原因だと思われるので、ちゃんと使うときはbootstrapのwhile内の処理を全部ScalaNative側で実施する等の対策が必要になると思われます。
4. GraalVMのnative-imageでネイティブコードのバイナリに変換して動かす
基本的に、ScalaNativeのバイナリの代わりにGraalVMのnative-imageで変換したバイナリを使用するだけです。
また、こちらではデプロイするzipにlibunwind.so.8等を含める必要はありません。
ScalaでのGraalVMのnative-imageについてはこちらの記事で詳しく説明されているので、そちらを参照してください。
※なお、GraalVMのnative-imageはあくまでJavaとして正しいfat-jarをネイティブコードに変換するため、下記コードは(ローカル環境等の)JVMで普通に実行するときと同じです。
build.sbt
name := "graalvm-native-sample"
version := "1.0"
scalaVersion := "2.12.8"
assemblyJarName in assembly := "graalvm-native.jar"
mainClass in assembly := Some("jp.ne.khddmks.Main")
GraalVMのエントリポイント
package jp.ne.khddmks
import scala.util.{Failure, Try}
import scala.util.control.NonFatal
import java.io.{PrintWriter => PW}
import java.io.{BufferedWriter => BW}
import java.io.{FileWriter => FW}
object Main {
def main(args: Array[String]): Unit = {
val oParam = for {
arg <- args.headOption
param <- Try(arg.toInt).toOption
resultFilePath <- args.tail.headOption
} yield (param, resultFilePath)
val (param, resultFilePath) = oParam match {
case Some(p) => p
case None => throw new IllegalArgumentException("require numeric param and result file path")
}
val result = logic(param)
using(new PW(new BW(new FW(resultFilePath))))(_.close()) { bw =>
bw.write(result.toString)
}
}
def logic(n: BigInt): BigInt = {
val start = System.currentTimeMillis()
val (fib, memo) = Logic.fibonacci(n)
println(s"""fibonacci($n) : $fib""")
println(s"""memo : ${memo.toList.sortBy(_._1).map { case (k, v) => s"$k -> $v" }.mkString(", ")}""")
println(s"""total time : [${System.currentTimeMillis() - start}]""")
fib
}
def using[A, B](res: => A)(closer: A => Unit)(body: A => B): Try[B] = {
var r: Option[A] = None
try {
r = Some(res)
r.fold[Try[B]](Failure(new RuntimeException("error occurred when resource opened")))(rr => Try(body(rr)))
} catch {
case NonFatal(t) => Failure(t)
} finally {
Try(r.foreach(rr => closer(rr)))
}
}
}
bootstrap
#!/bin/sh
set -euo pipefail
# Execution file path
EXEC_BIN_NAME="$(echo $_HANDLER | cut -d. -f1)"
EXEC_BIN_PATH=$LAMBDA_TASK_ROOT/${EXEC_BIN_NAME}
# Processing
while true; do
HEADERS="$(mktemp)"
# Get an event
EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
# Execute
RESULT_FILE="$(mktemp "/tmp/${EXEC_BIN_NAME}_$(date "+%Y-%m-%d_%H-%M-%S_%N")_XXXXXX")"
$EXEC_BIN_PATH "$EVENT_DATA" "$RESULT_FILE"
RESPONSE=$(cat $RESULT_FILE)
# Send the response
curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" -d "$RESPONSE"
done
ちなみに、コールドスタート(128MB)で566.00 ms(メイン処理部分で1ms)かかりました。
まとめ
ScalaのコードをAWS Lambdaで動かす際に様々な方法が選択できるようになりました。
特にScalaNative / GraalVMのnative-image と Custom Runtime の組み合わせををうまく活用できれば捗りそうですね。
とはいえ現時点ではScalaNativeもGraalVMのnative-imageも発展途上なので、今すぐ商用環境で利用するのは難しいかもしれないです。