JavaとLambdaとNative Image
この記事はatWare Advent Calendar 2020の10日目です。
サーバーレスって言葉は好きではないけど、AWS Lambdaはとても良いサービスだと思います。
Node.jsやPythonで関数を実装することが多い印象だけど、Javaで作るのもかんたんです。
package example;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import java.util.List;
public class Accumulator implements RequestHandler<List<Integer>, Integer>{
@Override
public Integer handleRequest(List<Integer> event, Context context)
{
return event.stream().mapToInt(Integer::intValue).sum();
}
}
だいたいこんな感じ。(https://github.com/h-keisuke/AccumulatorLambda)
比較対象にAWS Lambdaで最も人気がある(気がする)Pythonで同様の関数を作ってみます。
def lambda_handler(event, context):
return sum(event)
こんな感じ。(https://github.com/h-keisuke/accumulator-python)
随分かんたんですね。(もうPythonでよくない?)
Native Image
上で作った2つの関数ですがAWS Lambdaコンソールで見るとコードサイズにおよそ6倍の差があります。このままではパフォーマンスに大きな差が出てしまうに違いない(計測してない)。
ちなみに、コールドスタートの時間をCloudWatchで確認したらJava版が初期化に360.75 msに対してPython版は122.72 msでした。
REPORT RequestId: 9ed95e3f-9eab-4366-91da-b9bb9168f990 Duration: 131.44 ms Billed Duration: 132 ms Memory Size: 512 MB Max Memory Used: 91 MB Init Duration: 360.75 ms XRAY TraceId: 1-5fcf317e-14694a0365cc0f565c721aea SegmentId: 5afa5af22cf1e410 Sampled: true
REPORT RequestId: b1ca2e88-134b-4e9f-bd95-35adbf0678a2 Duration: 1.56 ms Billed Duration: 2 ms Memory Size: 128 MB Max Memory Used: 52 MB Init Duration: 122.72 ms XRAY TraceId: 1-5fcf437c-5fee5c32491014390f7af12c SegmentId: 563b6c541fabbd9d Sampled: true
これらの問題を解決するために、GraalVMのNative Imageを試したいと思います。
AWS Lambdaの関数には実行可能なbootstrapを用意してあげれば何でも使えます。これを利用してGraalVMで作ったNative ImageをAWS Lambdaの関数として使用します。
AWS Lambda のカスタムランタイム
bootstrap
bootstrapは関数が初期化されるときに呼び出され、以降はループ処理でイベントを待ち受けます。
チュートリアルのbootstrapの例を見るととても単純です。
HTTP GET でイベントを待ち受けて、それをハンドラーで処理して、結果をHTTP POSTで返却しているだけです。
この例を参考にしてハンドラーの標準入力にイベントを渡して、標準出力から結果を取得するようにします。
#!/bin/sh
set -euo pipefail
# Initialization - load function handler
HANDLER="$LAMBDA_TASK_ROOT/$_HANDLER"
# Processing
while true
do
HEADERS="$(mktemp)"
# Get an event. The HTTP request will block until one is received
EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
# Extract request ID by scraping response headers received above
REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
# Run the handler function from the script
RESPONSE=$(echo $EVENT_DATA | $HANDLER )
# Send the response
curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" -d "$RESPONSE"
done
ハンドラー
次はJavaでハンドラーを書いていきます。
bootstrapで書いたように単純に標準入力から値を取得して標準出力に結果を返すようにします。
package example;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.List;
import java.util.stream.Collectors;
public class Accumulator{
private static final ObjectMapper mapper = new ObjectMapper();
private static final TypeFactory factory = mapper.getTypeFactory();
private static final CollectionType mapType = factory.constructCollectionType(List.class, Integer.class);
public static void main(String[] args) throws IOException {
try ( final var in = new BufferedReader(new InputStreamReader(System.in))) {
final var jsonString = in.lines().collect(Collectors.joining(""));
final List<Integer> list = mapper.readValue(jsonString, mapType);
System.out.println(list.stream().mapToInt(Integer::intValue).sum());
}
}
}
(AWS感が無くなったが🤔)
ビルドスクリプト
ところでこれから作るのはネイティブ実行バイナリーなので実行環境と同じ環境で作らないといけないと思わん?
GraalVMのnative-imageは現在のところクロスコンパイルは提供されていません(たぶん)。
なので、Dockerを利用してamazon linux用のイメージを作成することにします。しました。
#!/bin/bash -x
set -eo pipefail
./gradlew clean shadowJar
WORKDIR="$(pwd)/workdir"
mkdir -p "$WORKDIR"
cp build/libs/AccumulatorNative-all.jar "$WORKDIR"
cp src/main/script/bootstrap "$WORKDIR"
COMMAND=$(cat <<-EOF
yum update -y && yum install -y gcc glibc-devel zlib-devel tar gzip && \
curl -sL https://github.com/graalvm/graalvm-ce-builds/releases/download/vm-20.3.0/graalvm-ce-java11-linux-amd64-20.3.0.tar.gz --output - | tar zx && \
./graalvm-ce-java11-20.3.0/bin/gu install native-image && \
./graalvm-ce-java11-20.3.0/bin/native-image --no-fallback -jar AccumulatorNative-all.jar accumulator-native
EOF
)
docker run -v "$WORKDIR":/workdir -w /workdir --rm amazonlinux:2 sh -c "$COMMAND"
cd "$WORKDIR"
chmod 755 bootstrap accumulator-native
zip accumulator-native.zip bootstrap accumulator-native
(良い子はDockerfileを書こうね)
最終的に出来上がったソースがこちらになります。https://github.com/h-keisuke/AccumulatorNative
あとはAWSにデプロイするだけ。(デプロイ周りはソースを見てください。)
コードサイズ
無事にコードサイズがかなり減りました。それでもPythonとの差はかなりありますね。
Init Duration
初期化に掛かる時間は 360.75 ms → 33.26 msとかなり短縮されました。
まあ、bootstrapが実行されるだけだからね...
REPORT RequestId: 32adc92b-6fc9-4267-8533-5a47dde33355 Duration: 418.19 ms Billed Duration: 452 ms Memory Size: 512 MB Max Memory Used: 57 MB Init Duration: 33.26 ms XRAY TraceId: 1-5fd0a19c-3e7a190f158c12bb1c60eae0 SegmentId: 4100748863765c2c Sampled: true
因みに、Max Memory Used も 91 MB から57 MB に改善されています。
パフォーマンス計測
折角なのでそれぞれの実行時間を簡単に計測して見ました。
方法は、ローカルからaws lambda invoke
で100回呼び出して、その結果をCloudWatchメトリックスで確認しました。
平均 | 最大 | 最小 | |
---|---|---|---|
native | 55.02 ms | 69.57 ms | 43.51 ms |
java | 2.58 ms | 131.98 ms | 0.92 ms |
Python | 1.07 ms | 5.43 ms | 0.74ms |
めっちゃ遅いが?🤔🤔🤔
もうPythonで良くない?
まとめ
しらべてみました。
結果遅くなりました。
いかがでしたか?
いずれもう少し詰めていきたいですね。
デーモン化するとか
Discussion