Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

2018年11月以前にClojureをAWS Lambdaで使おうと思ったら

動作環境の選択肢として、

  1. ClojureをJavaランタイム上で走らせる
  2. 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を使用している場合は、十分にメモリが割り当てられているかも確認してください。

Advanced_and_1__tmux.png

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.sohttpsの実行に必要なので、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です。爆速ですね!

参照リンク

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away