mike-neckのブログ

Java or Groovy or Swift or Golang

AWS Lambda のカスタムランタイムにて Java のカスタムランタイムで関数を動かす

これは Java Advent Calendar 2018 の 7 日目のエントリーです。

f:id:mike_neck:20181205013811p:plain


Java をラムダで動かす

Lambda SDK を使って、チュートリアルどおりに作成します。以上。


そうではないですね。


今回やりたいのは AWS Lambda のカスタムランタイムで Java のカスタムランタイムで動く関数を動かすことです。これを会社の同僚に言ったところ、「おまえは何を言っているんだ」というような顔をされました。

f:id:mike_neck:20181205010007j:plain
おまえは何を言っているんだ


大事なことなのでもう一度言いますが、「 AWS のカスタムランタイムで Java のカスタムランタイムを動かしたい!」、これが今回のエントリーの目標です。


Java 9 のモジュールシステムと jlink によって、アプリケーションが利用する必要最低限のモジュールだけを選別したカスタムランタイムイメージが作れるようになりました。カスタムランタイムはフットプリントを軽くできるので、ロードする時間も短くなることから多分起動時間も短くなるはずです。そして、ラムダのような呼び出されてから起動を開始するようなモデルに、カスタムランタイムはマッチしているわけで、これを使わないわけにはいきません。


ランタイムの作り方

AWS のドキュメントの以下のページを読むと、大まかな作り方がわかります。また、「AWS Lambda カスタムランタイム」で検索すると、様々な人がすでに挑戦していますので、参考にされるとよいと思います。

docs.aws.amazon.com

docs.aws.amazon.com

ポイントとしては、以下のとおりです。

  • bootstrap という名前のスクリプト(あるいは実行バイナリー)が呼び出される
  • 以下の処理をループで実行する
    • API エンドポイント http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/nextGET でアクセスして、リクエストID と リクエスペイロード(たいていはjson形式)を取得する
    • 関数を呼び出す
    • API エンドポイント http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/リクエストID/responsePOST でレスポンスを返す
    • API エンドポイント http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/リクエストID/errorPOST でエラーを返す

これをざっくり1ファイル/クラスで書くとこんな感じになります。ただし、今回はエラー処理を入れていません。

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

public class LambdaApp {

  @SuppressWarnings("InfiniteLoopStatement")
  public static void main(String[] args) {
    System.out.println("Start lambda");
    final String awsLambdaRuntimeApi = System.getenv("AWS_LAMBDA_RUNTIME_API");
    if (awsLambdaRuntimeApi == null) {
      System.out.println("error AWS_LAMBDA_RUNTIME_API is not available.");
      System.exit(1);
    }
    System.out.println(awsLambdaRuntimeApi);
    final HttpClient client = HttpClient.newHttpClient();
    System.out.println("client prepared.");
    while (true) {
      final URI uri = URI.create("http://" + awsLambdaRuntimeApi + "/2018-06-01/runtime/invocation/next");
      System.out.println("uri : " + uri);
      final HttpRequest getEvent = HttpRequest.newBuilder(uri).GET().build();
      try {
        final HttpResponse<String> response =
            client.send(getEvent, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
        final String requestId =
            response.headers().firstValue("Lambda-Runtime-Aws-Request-Id").orElseThrow();
        final String body = response.body();
        System.out.println(body);
        final String payload = "{\"receive\":" + body + "}";
        final URI resultUrl =
            URI.create(
                "http://"
                    + awsLambdaRuntimeApi
                    + "/2018-06-01/runtime/invocation/"
                    + requestId
                    + "/response");
        final HttpRequest request =
            HttpRequest.newBuilder(resultUrl)
                .POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8))
                .build();
        final HttpResponse<String> result =
            client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
        System.out.println(result.statusCode());
        System.out.println(result.body());
      } catch (InterruptedException | IOException e) {
        e.printStackTrace();
      }
    }
  }
}

このクラスを含むプロジェクトには次のような module-info.java をつけました。

module lambda.example {
  requires java.base;
  requires java.net.http;
}

さて、このプログラムで使っているのは上記のモジュールだけですので、 jlink コマンドでそれらを指定して、カスタムランタイムを作ります。

jlink --compress=2 \
    --module-path ${JAVA_HOME}/jmods \
    --add-modules java.base,java.net.http \
    --output lambda-custom-java-runtime

これでカスタムランタイムができました。

ちなみに lambda-custom-java-runtime ディレクトリーの大きさを見てみると 28 MB くらいに収まっているようです。

f:id:mike_neck:20181206235140p:plain
Java カスタムランタイム は 28MB

比較として、比較対象としては適切ではありませんが、通常の JDK の jmods の大きさは 77 MB くらいあるようです。

f:id:mike_neck:20181206235450p:plain
jmods は全部で 77 MB

このアプリケーションを起動するために次のような bootstrap をシェルで組みます。

#!/usr/bin/env bash

JAVA=./lambda-custom-java-runtime/bin/java

${JAVA} -p java-custom-runtime.jar -m lambda.example

たぶん、これで動くはずですが、普段は Mac を使っている僕はここから先が大変です。


ビルド

Mac でビルドしたクラスファイルは特に問題がありませんが、Mac 用の java では Linux を使っている Lambda 上では動かせません。したがって、ビルドは Linux 上でやることになります。

今回は最初 Docker でやろうとしたのですが、(typo していてうまく動かせなかったため) Amazon Linux 上でビルドしようと考え、 ec2 に立てた Amazon Linux 上でビルドしました。

その際に、次のコマンドでカスタムランタイムを作ります。

MODULES=$(jdeps --list-deps build/libs/java-custom-runtime.jar | tr "\n" "," | tr -d [:space:])

jlink --compress=2 \
    --module-path ${JAVA_HOME}/jmods \
    --add-modules ${MODULES} \
    --output build/mod/lambda-custom-java-runtime

リリース/デプロイ

次のように、成果物をあつめて、 zip で固めます。

bootstrap
java-custom-runtime.jar
lambda-custom-java-runtime
zip lambda.zip bootstrap java-custom-runtime.jar
zip -r lambda.zip lambda-custom-java-runtime

あとは、 aws コマンドを叩くだけです。

aws lambda create-function \
    --function-name java11-custom-runtime \
    --runtime provided \
    --role 適切なロールを見繕って指定 \
    --zip-file fileb://lambda.zip \
    --handler hogehoge #今回は汎用的な仕組みではないので、ハンドラーの名前は適当

今回は単なるオウム返しするだけの関数なのでテストデータもデフォルトの json を使います

f:id:mike_neck:20181205012527p:plain
適当なデータ

次の通り実行されました

f:id:mike_neck:20181205012656p:plain


以上 Java のカスタムランタイムにて Java のカスタムランタイムで関数を動かしました