ClojureをGraalVMでネイティブバイナリにしてAWS Lambdaカスタムランタイムで動かす

# 2018年11月以前にClojureをAWS Lambdaで使おうと思ったら
動作環境の選択肢として、

1. ClojureをJavaランタイム上で走らせる
2. ClojurescriptをNode.jsランタイム上で走らせる

の２つでした。Node.jsから子プロセスの実行という形で多言語での実行をサポートする[Apex](https://github.com/apex/apex)でもClojureの場合はJavaランタイムを利用しているので、本質的には1.と同様になると思います。

## 問題点
### Javaランタイムでの初期実行が遅い
Clojureの場合、Clojureランタイムの初期化に時間がかかるのでJavaの場合よりさらに不利です。

### 必要以上に大きなメモリを割り当てる必要がある
Labmdaはメモリに応じて大きなCPUパワーを割り当てるので、速度のために必要以上にメモリを割り当てなければなりません。

### Jarファイルサイズは50MBが上限
圧縮したzipやjarファイルのアップロード上限は50MB、展開時は250MBです。

# Custom Runtime
2018年11月のAWS re:Inventカンファレンスで、AWS Lambdaにいくつか新機能が追加された発表がありました。[LambdaをALBのターゲットとして指定できたり](https://aws.amazon.com/blogs/networking-and-content-delivery/lambda-functions-as-targets-for-application-load-balancers/)、[レイヤーでライブラリを共有する](https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html)など、興味深いものがあるのですが、ここではCustom Runtimeに話を絞ります。

Custom Runtimeによって、AWS Lambda側で用意されていないランタイム環境でもLambdaを動作できるようになりました。この環境を活用する要件は、`bootstrap`という名の実行可能ファイルを含んだzipファイルのアップロードです。`bootstrap`はそれ自身がランタイムを生成するバイナリでも良いし、ランタイムを生成する別のバイナリを呼び出すシェルスクリプトでも構いません。ランタイムのコードは以下の要件を満たす必要があります。

## 初期化
* 設定の取得。
  * `_HANDLER`環境変数からハンドラ関数名を取得します。通常は`file.methodName`の形式です。
  * `LAMBDA_TASK_ROOT`環境変数から関数のコードが置かれているパスを取得します。
  * `AWS_LAMBDA_RUNTIME_API`環境変数からRuntime APIのホスト名とポート番号を取得します。
* 関数の初期化。ハンドラ関数をロードし、データベースコネクションの取得などのスタティックリソースの初期化処理を行います。このリソースは以降繰り返される関数の実行時に利用することができます。
* エラー処理。もし初期化時にエラーが起こったら、[API](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-initerror)を呼び即時終了します。

##タスク処理
* イベントの取得。[next invocation](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-next) APIを呼び次のイベントを取得します。レスポンスで関数を呼び出したイベントデータを含むJSONオブジェクトがBodyとして返ってきます。
* トレーシングヘッダの伝播。`Lambda-Runtime-Trace-Id` ヘッダに含まれている[AWS X-Ray Tracing](https://aws.amazon.com/xray/)の値を、`_X_AMZN_TRACE_ID`環境変数にセットします。
* コンテキストオブジェクトの生成。環境変数とレスポンスのヘッダから、コンテキストオブジェクトを生成します。
* ハンドラ関数の実行。イベントとコンテキストを引数として渡して関数を実行します。
* ハンドラからの返り値を[invocation response](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-response) APIで送ります。
* エラーが起こったら、[invocation error](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-invokeerror) APIでエラー内容を送信します。
* 後処理。不要になったリソースや、データを他のサービスに送ったり、次のイベントを処理するための準備などを行います。

## ランタイムをClojureで実装する
それでは、上記の条件を満たすコードを書きましょう。

```clojure
(ns graalvm-lambda.core
  (:require [graalvm-lambda.handler :as h]
            [org.httpkit.client :as http])
  (:gen-class))

(defn -main
  [& args]
  (let [handler   (System/getenv "_HANDLER")
        task-root (System/getenv "LAMBDA_TASK_ROOT")
        runtime   (System/getenv "AWS_LAMBDA_RUNTIME_API")]
    (try
      (println "handler:" handler)
      (println "task-root:" task-root)
      (println "runtime:" runtime)
      ;; スタティックリソースなどの初期化処理はここで行える
      (catch Exception e
        @(http/post (format "http://%s/2018-06-01/runtime/init/error" runtime)
                    {:body (format "{\"errorMessage\" : \"%s\", \"errorType\" : \"%s\"}"
                                   (.getMessage e)
                                   (-> e .getClass .getName))})))
    (while true
      (let [request @(http/get (format "http://%s/2018-06-01/runtime/invocation/next" runtime))
            headers (:headers request)
            body    (:body request)
            {:keys [lambda-runtime-aws-request-id lambda-runtime-trace-id]} headers]
        (try
          (println "headers:" headers)
          (println "body:" body)
          @(http/post (format "http://%s/2018-06-01/runtime/invocation/%s/response"
                              runtime lambda-runtime-aws-request-id)
                      {:body (h/handle body headers)}) ;;ハンドラ関数の実行
          (catch Exception e
            @(http/post (format "http://%s/2018-06-01/runtime/invocation/%s/error"
                                runtime lambda-runtime-aws-request-id)
                        {:body (format "{\"errorMessage\" : \"%s\", \"errorType\" : \"%s\"}"
                                       (.getMessage e)
                                       (-> e .getClass .getName))})))))))
```
ハンドラはコンパイル時に決定したいので、`_HANDLER`関数は使わないことにします。

ハンドラ関数はとりあえずプレースホルダーを作っておきます。

```clojure 
(ns graalvm-lambda.handler
  (:require [cheshire.core :as j]))

(defn handle
  [in ctx]
  (println "in:" in)
  (println "ctx:" ctx)
  (j/generate-string {:headers {:content-type "text/html"}
                      :isBase64Encoded false
                      :body "Hello Graal World."
                      :statusCode 200}))
```

Lambdaのカスタムランタイムで動作させるには、このコードをLinuxのバイナリにネイティブコンパイルする必要があります。


# GraalVMでネイティブバイナリの生成

## Uberjarの生成
LambdaはAmazon Linux上で動作しますので、Graal VMに含まれている`native-image`コマンドを使って、Linux向けのバイナリを生成します。
前準備として、`native-image`コマンドへの入力となる、uberjarを作成します。今回はclojure標準の[Deps](https://clojure.org/guides/deps_and_cli)を用いてプロジェクトを定義しています。

```clojure
{;; Workaround for https://github.com/luchiniatwork/cambada/issues/15
 :mvn/repos {"clojars" {:url "https://repo.clojars.org"}
             "central" {:url "https://repo1.maven.org/maven2/"}}
 :paths ["src" "resource"] 
 :deps {org.clojure/clojure {:mvn/version "1.10.0-RC4"}
        http-kit            {:mvn/version "2.3.0"}
        cheshire            {:mvn/version "5.8.1"}}
 :aliases {:dev 
            {:extra-deps {cider/cider-nrepl       {:mvn/version "0.18.0"} 
                          cljfmt                  {:mvn/version "0.5.7"}
                          org.clojure/tools.nrepl {:mvn/version "0.2.12"}}
             :extra-paths ["dev/src"]}
            :uberjar
              {:extra-deps {luchiniatwork/cambada {:mvn/version "1.0.0"}}
               :main-opts ["-m" "cambada.uberjar"
                           "-m" "graalvm_lambda.core"]}}}

```
[cambadaのバグ](https://github.com/luchiniatwork/cambada/issues/15)で、`:mvn/repos`を明示的に指定しないとMavenアーティファクトのダウンロードに失敗します。

Uberjarを生成します。

```
$ clojure -A:uberjar
Building Clojure uberjar
Cleaning target
Creating target/classes
  Compiling graalvm-lambda.core
  Compiling graalvm-lambda.handler
Creating target/aws-lambda-graalvm-clojure-1.0.0-SNAPSHOT.jar
Updating pom.xml
Skipping paths: src
Creating target/aws-lambda-graalvm-clojure-1.0.0-SNAPSHOT-standalone.jar
  Including aws-lambda-graalvm-clojure-1.0.0-SNAPSHOT.jar
  Including clojure-1.10.0-RC4.jar
  Including core.specs.alpha-0.2.44.jar
  Including spec.alpha-0.2.176.jar
  Including jackson-dataformat-cbor-2.9.6.jar
  Including jackson-core-2.9.6.jar
  Including cheshire-5.8.1.jar
  Including tigris-0.1.1.jar
  Including jackson-dataformat-smile-2.9.6.jar
  Including http-kit-2.3.0.jar
Done!
```
`target`ディレクトリ内に、jarとuberjarが生成されます。

## native-imageのクロスコンパイル
cambadaで下記のエイリアスを定義し、`clojure -A:native-image`でネイティブイメージを生成することができますが、LambdaはLinux上で動作するため、MacOSではLinux向けのバイナリを生成できません。

```clojure
{
...
 :aliases {
  ...
            :native-image
              {:extra-deps {luchiniatwork/cambada {:mvn/version "1.0.0"}}
               :main-opts  ["-m" "cambada.native-image"
                            "-m" "graalvm_lambda.core"
                            "-O" "H:EnableURLProtocols=http,https"
                            "-O" "-rerun-class-initialization-at-runtime=org.httpkit.client.SslContextFactory"
                            "-O" "-rerun-class-initialization-at-runtime=org.httpkit.client.HttpClient"]}}}

```

そこで、まずGraal VMのDocker imageを用意します。下記のDockerfileを作成します。

```dockerfile
FROM oracle/graalvm-ce:1.0.0-rc10
RUN yum  install -y java-1.8.0-openjdk-headless \
	&& update-alternatives --set java $JAVA_HOME/bin/java \
        && mv $JAVA_HOME/jre/lib/security/cacerts $JAVA_HOME/jre/lib/security/cacerts.bak \
	&& ln -s /usr/lib/jvm/jre-1.8.0/lib/security/cacerts $JAVA_HOME/jre/lib/security/cacerts

CMD tail -f /dev/null
```

ここでOpen JDK1.8.0をインストールしているのは、[GraalVMに含まれていないCAルートがある](https://github.com/oracle/graal/issues/378)問題を回避すべく、OpenJDKのcacertをコピーするためです。また、[GraalVMが終了しない問題](https://github.com/oracle/graal/issues/702)を回避するため、`native-image`コマンドに`--no-server`オプションをつけて実行するのですが、サーバのように振る舞わせるため、`CMD`に終了しないコマンドを適宜実行させて常駐プロセス化しています。

```
$ docker build -f Dockerfile -t graal-build-img .
```
でDockerイメージを生成しておきます。

ビルド時にはこのイメージを走らせておく必要があるため、シェルスクリプトを用意し、下記のように必要があれば起動するようにしておきます。

```bash

if [ ! "$(docker ps -q -f 'name=graal-builder')" ]; then
  echo "Graalビルドサーバの起動"
  docker run --name graal-builder -dt graal-build-img:latest
else
  echo "Graalビルドサーバは既に実行中"
fi
```
uberjarをGraalビルドサーバのコンテナ内にコピーします。

```bash
$ docker cp target/aws-lambda-graalvm-clojure-1.0.0-SNAPSHOT-standalone.jar graal-builder:server.jar
```

`native-image`コマンドを起動します。

```bash
$ time docker exec graal-builder native-image \
                                 -H:+ReportUnsupportedElementsAtRuntime \
                                 -H:EnableURLProtocols=http,https \
                                 -J-Xmx3G -J-Xms3G \
                                 --no-server \
                                 -jar server.jar \
                                 --rerun-class-initialization-at-runtime=org.httpkit.client.SslContextFactory,org.httpkit.client.HttpClient
Container already running
[server:503]    classlist:   2,658.16 ms
[server:503]        (cap):     869.97 ms
[server:503]        setup:   2,312.66 ms
[server:503]   (typeflow):  18,678.95 ms
[server:503]    (objects):  15,021.61 ms
[server:503]   (features):     505.78 ms
[server:503]     analysis:  35,025.17 ms
[server:503]     universe:     690.72 ms
[server:503]      (parse):   3,999.59 ms
[server:503]     (inline):   3,609.24 ms
[server:503]    (compile):  23,760.50 ms
[server:503]      compile:  32,754.43 ms
[server:503]        image:   2,040.13 ms
[server:503]        write:     362.64 ms
[server:503]      [total]:  75,946.66 ms

real    1m16.584s
user    0m0.025s
sys     0m0.014s
```

`+ReportUnsupportedElementsAtRuntime`オプションは、[GraalがAOTする際に用いる閉じた世界を規定しているSubtrate VMの制限](https://github.com/oracle/graal/blob/master/substratevm/LIMITATIONS.md)に抵触した際、コンパイル時ではなく実行時に例外をスローするように振る舞いを変更します。これにより、制限に抵触するコードを含んでいていも、それをランタイムで呼び出さなければエラーにはならないので、clojureからevalなどの関数を予め削除する必要がなくなります。Clojureコードをコンパイルする際には必須のオプションになります。

`EnableURLProtocols`は、[生成されるバイナリにに`http`, `https`プロトコルのサポートを含めるためのもの](https://github.com/oracle/graal/issues/392)です。標準ではバイナリサイズを最小にするため、これらのプロトコルのサポートが含まれていません。今回はLambda APIを呼び出すため必要となります。

GraalVMのネイティブイメージ生成はメモリ消費が激しいので、大きなuberjarを処理する際は、`J-Xmx`, `J-Xms`の値を上げる必要があるかもしれません。また、Docker for Macを使用している場合は、十分にメモリが割り当てられているかも確認してください。

![Advanced_and_1__tmux.png](https://qiita-image-store.s3.amazonaws.com/0/38240/1ab11af1-447a-689b-6562-89bfd723c9d3.png)

MacbookPro 2018, 2.7GHz Core i7 16GBで、生成に1分14秒ほどかかりました。時間がかかっている場合、メモリ不足でGCが頻発している可能性があります。

`--rerun-class-initialization-at-runtime`は[ビルド時と実行時の両方で初期化が必要なクラス名をカンマ区切りのリストでとるオプション](https://github.com/oracle/graal/blob/7a44af07d4dc34684c72648859196ea6c5e8f027/substratevm/src/com.oracle.svm.hosted/src/com/oracle/svm/hosted/ClassInitializationFeature.java#L75)です。例えば、[org.httpkit.client.SslContextFactory](https://github.com/http-kit/http-kit/blob/8baf58d71e8236963ea78d0d6005c3d202d39cee/src/java/org/httpkit/client/SslContextFactory.java#L16)には`static`ブロックがあるので、これをランタイム時にも初期化する必要があります。


## Zipファイルの生成

```bash
$ docker cp graal-builder:server target/bootstrap
$ docker cp graal-builder:/opt/graalvm-ce-1.0.0-rc10/jre/lib/amd64/libsunec.so target/libsunec.so
$ zip -j target/bundle.zip target/bootstrap target/libsunec.so
```

出来上がった`server`というバイナリを、ホストOS側の`target`ディレクトリに`bootstrap`という名前でコピーします。`libsunec.so`も[httpsの実行に必要](https://blog.taylorwood.io/2018/10/04/graalvm-https.html)なので、GraalVM側からコピーしてきます。それらを`bundle.zip`ファイルにまとめて出来上がりです。S3にアップロードして、Lambdaコンソールから選択しましょう。この例の場合、`bootstrap`バイナリは21MBで、出来上がったzipファイルサイズは7.1MBでした。


## 実行結果

### コールドスタート
```
START RequestId: 105722a0-fc02-11e8-96ed-7f72d7f5cdae Version: $LATEST
headers: {:content-length 49, :content-type application/json, :date Sun, 09 Dec 2018 22:30:43 GMT, :lambda-runtime-aws-request-id 105722a0-fc02-11e8-96ed-7f72d7f5cdae, :lambda-runtime-deadline-ms 1544394653363, :lambda-runtime-invoked-function-arn arn:aws:lambda:ap-northeast-1:815754008393:function:graalvm-test, :lambda-runtime-trace-id Root=1-5c0d9792-769003779280087cfa885ed6;Parent=39b06aa26797856a;Sampled=0}
body: {"key1":"value1","key2":"value2","key3":"value3"}
END RequestId: 105722a0-fc02-11e8-96ed-7f72d7f5cdae
REPORT RequestId: 105722a0-fc02-11e8-96ed-7f72d7f5cdae	Init Duration: 133.03 ms	Duration: 35.02 ms	Billed Duration: 200 ms 	Memory Size: 128 MB	Max Memory Used: 46 MB	
```
初期化に133.03ms, 実行に35.02msかかっています。速いですね！またJavaランタイムではCPUとメモリサイズが連動していたため、パフォーマンスのために泣く泣く1.5GBくらいメモリサイズを割り当てていたのですが、ネイティブバイナリだと最大で46MBしか消費しておらず、128MB環境で動くのでお財布に優しいです。

### ホットスタート
```
START RequestId: 4be2a9f3-fc02-11e8-9796-3feb2d34dc36 Version: $LATEST
headers: nil
body: nil
headers: {:content-length 49, :content-type application/json, :date Sun, 09 Dec 2018 22:32:22 GMT, :lambda-runtime-aws-request-id 4be2a9f3-fc02-11e8-9796-3feb2d34dc36, :lambda-runtime-deadline-ms 1544394752855, :lambda-runtime-invoked-function-arn arn:aws:lambda:ap-northeast-1:815754008393:function:graalvm-test, :lambda-runtime-trace-id Root=1-5c0d97f6-b929b267bc04306cd4aa8c61;Parent=43da84e51679ff97;Sampled=0}
body: {"key1":"value1","key2":"value2","key3":"value3"}
END RequestId: 4be2a9f3-fc02-11e8-9796-3feb2d34dc36
REPORT RequestId: 4be2a9f3-fc02-11e8-9796-3feb2d34dc36	Duration: 2.99 ms	Billed Duration: 100 ms 	Memory Size: 128 MB	Max Memory Used: 46 MB
```
環境がすでに温まっていたら、実行時間はなんと2.99msです。爆速ですね！

## 参照リンク
* https://www.innoq.com/en/blog/native-clojure-and-graalvm/
* https://dev.solita.fi/2018/12/07/fast-starting-clojure-lambdas-using-graalvm.html
* https://blog.taylorwood.io/2018/10/04/graalvm-https.html