Scala
Scala.js
AWSLambda
ScalaNative
graalvm

Scalaで書いたプログラムを色々な方法でビルドしてAWS Lambdaで動かしてみた


「色々な方法」って何?

時は2018年末、Scalaで書いたプログラムを動かす方法はいつの間にか色々増えました。


  1. 普通にJVM上で動かす

  2. Scala.jsとして書いてnode.js上で動かす

  3. ScalaNativeとして書いてネイティブコードのバイナリを動かす

  4. GraalVMのnative-imageでネイティブコードのバイナリに変換して動かす

  5. 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上で動かす

方法をざっくり言うと


  1. Scala.jsでCommonJSモジュールとして出力

  2. node.js用エントリポイントのjavascriptからrequire()して呼び出す

  3. 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として書いてネイティブコードのバイナリを動かす

方法をざっくり言うと



  1. ScalaNativeで実行するバイナリをビルドする

  2. AWS LambdaのCustom Runtime用のbootstrapを用意する

  3. 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も発展途上なので、今すぐ商用環境で利用するのは難しいかもしれないです。





  1. あえてLambda上でAmmonite経由でScalaスクリプトを動かすメリットが見出せなかったので試してません 



  2. これは正しいやり方でない気もするが、他の解決方法が分からなかったので 



  3. プログラム側の標準出力を処理結果として扱うと、ログ出力等が不可能になる