2018年11月以前にClojureをAWS Lambdaで使おうと思ったら
動作環境の選択肢として、
- ClojureをJavaランタイム上で走らせる
- ClojurescriptをNode.jsランタイム上で走らせる
の2つでした。Node.jsから子プロセスの実行という形で多言語での実行をサポートする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のターゲットとして指定できたり、レイヤーでライブラリを共有するなど、興味深いものがあるのですが、ここではCustom Runtimeに話を絞ります。
Custom Runtimeによって、AWS Lambda側で用意されていないランタイム環境でもLambdaを動作できるようになりました。この環境を活用する要件は、bootstrap
という名の実行可能ファイルを含んだzipファイルのアップロードです。bootstrap
はそれ自身がランタイムを生成するバイナリでも良いし、ランタイムを生成する別のバイナリを呼び出すシェルスクリプトでも構いません。ランタイムのコードは以下の要件を満たす必要があります。
初期化
- 設定の取得。
-
_HANDLER
環境変数からハンドラ関数名を取得します。通常はfile.methodName
の形式です。 -
LAMBDA_TASK_ROOT
環境変数から関数のコードが置かれているパスを取得します。 -
AWS_LAMBDA_RUNTIME_API
環境変数からRuntime APIのホスト名とポート番号を取得します。
-
- 関数の初期化。ハンドラ関数をロードし、データベースコネクションの取得などのスタティックリソースの初期化処理を行います。このリソースは以降繰り返される関数の実行時に利用することができます。
- エラー処理。もし初期化時にエラーが起こったら、APIを呼び即時終了します。
##タスク処理
- イベントの取得。next invocation APIを呼び次のイベントを取得します。レスポンスで関数を呼び出したイベントデータを含むJSONオブジェクトがBodyとして返ってきます。
- トレーシングヘッダの伝播。
Lambda-Runtime-Trace-Id
ヘッダに含まれているAWS X-Ray Tracingの値を、_X_AMZN_TRACE_ID
環境変数にセットします。 - コンテキストオブジェクトの生成。環境変数とレスポンスのヘッダから、コンテキストオブジェクトを生成します。
- ハンドラ関数の実行。イベントとコンテキストを引数として渡して関数を実行します。
- ハンドラからの返り値をinvocation response APIで送ります。
- エラーが起こったら、invocation error APIでエラー内容を送信します。
- 後処理。不要になったリソースや、データを他のサービスに送ったり、次のイベントを処理するための準備などを行います。
ランタイムを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
関数は使わないことにします。
ハンドラ関数はとりあえずプレースホルダーを作っておきます。
(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を用いてプロジェクトを定義しています。
{;; 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のバグで、: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向けのバイナリを生成できません。
{
...
: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を作成します。
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ルートがある問題を回避すべく、OpenJDKのcacertをコピーするためです。また、GraalVMが終了しない問題を回避するため、native-image
コマンドに--no-server
オプションをつけて実行するのですが、サーバのように振る舞わせるため、CMD
に終了しないコマンドを適宜実行させて常駐プロセス化しています。
$ docker build -f Dockerfile -t graal-build-img .
でDockerイメージを生成しておきます。
ビルド時にはこのイメージを走らせておく必要があるため、シェルスクリプトを用意し、下記のように必要があれば起動するようにしておきます。
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ビルドサーバのコンテナ内にコピーします。
$ docker cp target/aws-lambda-graalvm-clojure-1.0.0-SNAPSHOT-standalone.jar graal-builder:server.jar
native-image
コマンドを起動します。
$ 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の制限に抵触した際、コンパイル時ではなく実行時に例外をスローするように振る舞いを変更します。これにより、制限に抵触するコードを含んでいていも、それをランタイムで呼び出さなければエラーにはならないので、clojureからevalなどの関数を予め削除する必要がなくなります。Clojureコードをコンパイルする際には必須のオプションになります。
EnableURLProtocols
は、生成されるバイナリににhttp
, https
プロトコルのサポートを含めるためのものです。標準ではバイナリサイズを最小にするため、これらのプロトコルのサポートが含まれていません。今回はLambda APIを呼び出すため必要となります。
GraalVMのネイティブイメージ生成はメモリ消費が激しいので、大きなuberjarを処理する際は、J-Xmx
, J-Xms
の値を上げる必要があるかもしれません。また、Docker for Macを使用している場合は、十分にメモリが割り当てられているかも確認してください。
MacbookPro 2018, 2.7GHz Core i7 16GBで、生成に1分14秒ほどかかりました。時間がかかっている場合、メモリ不足でGCが頻発している可能性があります。
--rerun-class-initialization-at-runtime
はビルド時と実行時の両方で初期化が必要なクラス名をカンマ区切りのリストでとるオプションです。例えば、org.httpkit.client.SslContextFactoryにはstatic
ブロックがあるので、これをランタイム時にも初期化する必要があります。
Zipファイルの生成
$ 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の実行に必要なので、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です。爆速ですね!