JavaとLambdaとNative Image

6 min read

この記事はatWare Advent Calendar 2020の10日目です。

サーバーレスって言葉は好きではないけど、AWS Lambdaはとても良いサービスだと思います。
Node.jsやPythonで関数を実装することが多い印象だけど、Javaで作るのもかんたんです。

Accumulator.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で同様の関数を作ってみます。

accumulator.py
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で返却しているだけです。
この例を参考にしてハンドラーの標準入力にイベントを渡して、標準出力から結果を取得するようにします。

bootstrap
#!/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で書いたように単純に標準入力から値を取得して標準出力に結果を返すようにします。

Accumulator.java
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用のイメージを作成することにします。しました。

2-build.sh
#!/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

めっちゃ遅いが?🤔🤔🤔

https://nowokay.hatenablog.com/entry/2019/06/27/045352
まじで?
もうPythonで良くない?

まとめ

しらべてみました。

結果遅くなりました。

いかがでしたか?

いずれもう少し詰めていきたいですね。
デーモン化するとか