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

# 「色々な方法」って何？

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

1. 普通にJVM上で動かす
- Scala.jsとして書いてnode.js上で動かす
- ScalaNativeとして書いてネイティブコードのバイナリを動かす
- GraalVMのnative-imageでネイティブコードのバイナリに変換して動かす
- AmmoniteでScalaスクリプトを動かす

AWS LambdaでScala製プログラムを動かす場合1, 2は結構前からできましたが、
この前出たCustom Runtimeを使えば3,4(,5[^1])の方法でも動かせるのでは、
と思ったので試してみました

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

# 各方法毎の詳細
## 0. 前提
<details><summary>Lambdaで動作させるメインの処理</summary><div>

```scala
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
  }
}
```
</div></details>

上記の処理に、パラメータとして`130`を渡して実行

## 1. 普通にJVM上で動かす
普通なので詳細は割愛します。
普通に[公式ドキュメント](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/java-programming-model.html)等を参照してください。

ちなみに、コールドスタート(512MB)で1998.51 ms(メイン処理部分で495ms)かかりました。
(昔試したときはJVMの初期化だけで8秒位かかってた(多分)ので、それに比べるとかなり速くなってる気がする)

## 2. Scala.jsとして書いてnode.js上で動かす
方法をざっくり言うと

1. Scala.jsでCommonJSモジュールとして出力
- node.js用エントリポイントのjavascriptからrequire()して呼び出す
- zipに固めてデプロイ

といった感じです。

ポイントとしては、
build.sbtでは
`scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) }`
を記述し、
`scalaJSUseMainModuleInitializer := true`
を記述**しない**ようにします。
また、Scala.js側のエントリポイントでは
クラスに`@JSExportTopLevel`を付与し、
呼び出し対象のメソッドに`@JSExport`を付与します。

<details><summary>build.sbt</summary><div>

```scala
enablePlugins(ScalaJSPlugin)

name := "scalajs-lambda-sample"

version := "1.0"

scalaVersion := "2.12.8"

scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.CommonJSModule) }
```
</div></details>

<details><summary>Scala.js側のエントリポイント</summary><div>

```scala
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
  }
}
```
</div></details>

<details><summary>Node.js側のエントリポイント</summary><div>

```javascript
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);
}
```
</div></details>

ちなみに、コールドスタート(128MB)で450.78 ms(メイン処理部分で380ms)かかりました。

## 3. ScalaNativeとして書いてネイティブコードのバイナリを動かす
方法をざっくり言うと

1. [ScalaNative](https://scala-native.readthedocs.io/en/v0.3.8/)で実行するバイナリをビルドする
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]。

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

<details><summary>build.sbt</summary><div>

```scala
scalaVersion := "2.11.12"

nativeLinkStubs := true

enablePlugins(ScalaNativePlugin)

nativeMode := "release"
```
</div></details>

<details><summary>ScalaNativeのエントリポイント</summary><div>

```scala
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)))
    }
  }
}
```
</div></details>

<details><summary>bootstrap</summary><div>

```shell
#!/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
```
</div></details>

ちなみに、コールドスタート(128MB)で455.55 ms(メイン処理部分で59ms)かかりました。

なお、今回はScalaNativeで生成したバイナリ側の処理結果をシェルスクリプトに受け渡すうまい方法が思いつかなかった[^3]ため、「処理結果を一時ファイルに出力し、それをシェルスクリプト側で読み込む」ようにしていますが、それが処理時間を長くする原因だと思われるので、ちゃんと使うときはbootstrapのwhile内の処理を全部ScalaNative側で実施する等の対策が必要になると思われます。

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

## 4. GraalVMのnative-imageでネイティブコードのバイナリに変換して動かす
基本的に、ScalaNativeのバイナリの代わりにGraalVMのnative-imageで変換したバイナリを使用するだけです。
また、こちらではデプロイするzipにlibunwind.so.8等を含める必要はありません。

ScalaでのGraalVMのnative-imageについては[こちら](https://qiita.com/petitviolet/items/0ccdf3d376b482f13c43)の記事で詳しく説明されているので、そちらを参照してください。

※なお、GraalVMのnative-imageはあくまでJavaとして正しいfat-jarをネイティブコードに変換するため、下記コードは(ローカル環境等の)JVMで普通に実行するときと同じです。

<details><summary>build.sbt</summary><div>

```scala
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")
```
</div></details>

<details><summary>GraalVMのエントリポイント</summary><div>

```scala
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)))
    }
  }
}
```
</div></details>

<details><summary>bootstrap</summary><div>

```shell
#!/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
```
</div></details>

ちなみに、コールドスタート(128MB)で566.00 ms(メイン処理部分で1ms)かかりました。

# まとめ
ScalaのコードをAWS Lambdaで動かす際に様々な方法が選択できるようになりました。
特にScalaNative / GraalVMのnative-image と Custom Runtime の組み合わせををうまく活用できれば捗りそうですね。
とはいえ現時点ではScalaNativeもGraalVMのnative-imageも発展途上なので、今すぐ商用環境で利用するのは難しいかもしれないです。