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 のカスタムランタイムで関数を動かしました

Swift Package Manager プロジェクト にて CPP(C++) の関数を Swift から呼び出す

f:id:mike_neck:20180609045321p:plain

一つ前のエントリーの続き。

C++ で作られた関数を Swift から呼び出す方法。 巷には C++ の関数を Swift から呼び出す方法に関する記事が溢れているが、

  • XCode 前提で Swift Package Manager プロジェクトについて触れてない
  • SPM 使っててもちょっと古い
    • Bridging-Header.h を使う方法しかない

ということで、自分で書くことにした。

なお、使っている Swift Package Manager は 4.2.1 。ここに書いた方法は macOS X Mojave と Ubuntu 16.04(Docker の swift:4.2.1 イメージ) で動作を確認した。


まず、プロジェクトの構造を作る。

.
├── Package.swift
├── Sources
│   ├── CppWrapper
│   │   ├── Cpp
│   │   │   ├── cpp_lib.cpp
│   │   │   └── cpp_lib.hpp
│   │   ├── cpp_bridge.cpp
│   │   └── include
│   │       └── cpp_bridge.hpp
│   ├── CppBridge
│   │   └── include
│   ├── CppLib
│   │   └── include
│   └── SwiftCppApp
│       ├── Bridging-Header.h
│       └── main.swift
└── Tests
    └── LinuxMain.swift

Package.swift は次のようになる。

// swift-tools-version:4.2
import PackageDescription

let package = Package(
        name: "SwiftCppApp",
        products: [
            .executable(name: "SwiftCppApp", targets: ["SwiftCppApp"]),
            .library(name: "CppWrapper", targets: ["CppWrapper"]),
        ],
        dependencies: [
        ],
        targets: [
            .target(name: "CppWrapper"),
            .target(name: "SwiftCppApp", dependencies: ["CppWrapper"]),
        ],
        cLanguageStandard: .c11,
        cxxLanguageStandard: .cxx11
)

まず、 C++ のコードを用意する。これはとある ライブラリーが C++ しか提供されていなくて、それを呼び出すために一旦 C++ で書いた場合などを想定している。 この C++ のファイルは CppWrapper の直下ではなく、 Cpp というさらに下のディレクトリーに置くところがポイント。 CppWrapper 直下に置くと、 <string> ヘッダーが見つからないというエラーが出てくる。

cpp_lib.hpp

#ifndef CPP_LIB_HPP
#define CPP_LIB_HPP

#include <string>

void show(std::string const& message);

#endif //CPP_LIB_HPP

cpp_lib.cpp

同じディレクトリーにおくので、ヘッダーは #include "cpp_lib.hpp" とクオーテーションで囲んだ形式で取り込む

#include "cpp_lib.hpp"
#include <string>
#include <iostream>

void show(std::string const& message)
{
    std::cout << message << std::endl;
}

次は C++ のラッパー部分だが、 C で読めるように翻訳する C++ で書かれたレイヤー。

cpp_bridge.hpp

#ifndef CPP_BRIDGE_HPP
#define CPP_BRIDGE_HPP

#ifdef __cplusplus
extern "C"
#endif
void show_message(char const* message);

#endif //CPP_BRIDGE_HPP

cpp_bridge.cpp

先程の cpp_lib.hppinclude ディレクトリーにないので、相対パスで取り込みをおこなう。

#include <cpp_bridge.hpp>

#include <string>
#include "Cpp/cpp_lib.hpp"

#ifdef __cplusplus
extern "C"
#endif
void show_message(char const* message) {
    std::string msg(message);
    show(msg);
}

extern "C" によって、 Swift には C のインターフェースとして見えるようになります(多分)。


後は C の関数を Swift から呼び出すコードを書けばコードは完成する。

import Foundation
import CppWrapper

let string = "hello"
var message = string

if var msg = message.cString(using: .utf8) {
show_message(&msg);
}

ビルドする際に、オプションをいくつか指定しないと失敗する。

必要なオプションは次のコマンドの通り

swift build -Xcxx -std=c++11

これを入れることで、 C++ を使っていることが認識される。

CPP(C++) の関数を C 言語から呼び出す方法

C 言語を業務でやったことがほとんどない + CPP は完全に触ったことがないので、いろいろググりつつやってみた。

f:id:mike_neck:20180609045321p:plain

最終的に Swift から C++ で定義されているオブジェクトの関数を呼び出すところまでが目標。


まず、次のような C++ の関数が定義されているものとする。

cpp_lib.h

#ifndef CPP_LIB_H
#define CPP_LIB_H

#include <string>

void show(std::string const& message);

#endif //CPP_LIB_H

cpp_lib.cpp

#include "cpp_lib.h"
#include <string>
#include <iostream>

void show(std::string const& message)
{
  std::cout << message << std::endl;
}

これは単純に std::string を受け取って、 標準出力に出力する C++ の関数。


あってるかどうかわからないが、 C の文字列をこの関数に渡すには 一度 char の配列を std::string に変換する層が必要になる。

cpp_bridge.h

#ifndef CPP_BRIDGE_H
#define CPP_BRIDGE_H

#ifdef __cplusplus
extern "C"
#endif
void show_bridge(char const* message);

#endif // CPP_BRIDGE_H

cpp_bridge.cpp

#include "cpp_bridge.h"
#include "cpp_lib.h"

#include <string>

#ifdef __cplusplus
extern "C"
#endif
void show_bridge(char const* message)
{
  std::string msg(message);
  show(msg);
}

C++コンパイルした関数は次の記事によるとシグネチャーが異なるらしいので、 extern "C" を付ける必要がある。

d.hatena.ne.jp


最後に これを C のアプリケーションを書く。

#include "cpp_bridge.h"

int main(int argc, char *argv[])
{
  char* message = "hello";
  show_bridge(message);
}

次にコンパイルする。コンパイラーは clang-3.8 を使う。

clang++-3.8 -c -Wall -o .build/cpp_lib.o cpp_lib.cpp
clang++-3.8 -c -Wall -o .build/cpp_bridge.o cpp_bridge.cpp
clang-3.8 -c -Wall -o .build/main.o main.c
clang++-3.8 -Wall -o .build/main .build/main.o .build/cpp_bridge.o .build/cpp_lib.o

Amazon 製 Server-side Swift フレームワーク smoke-framework について

f:id:mike_neck:20180609045321p:plain

これは Qiita のアドベントカレンダー 2018 の初日のエントリーです。

特に Swift コミュニティに何の貢献もしていないのですが、空いてたのでついポチッと登録してしまいました。

qiita.com


2018 年の 10 月 4 日に Prime Video のエンジニアの Simon さんのツイートが僕のTLに流れてきました。

Amazon が Swift 製の軽量サーバーサイドフレームワーク smoke-framework を作ったとのこと。

早速ためしてみたかったのですが、閃の軌跡というゲームをやるために忙しくて、ずっと試せていませんでした。 ほぼ2ヶ月経過して日本語の紹介記事等がなさそうなので、紹介してみることにしました。


smoke-framework は Swift-NIO の上に作られた軽量 Web フレームワークです。 JSON でのリクエスト-レスポンスの機能だけを提供しているだけのシンプルなものとなっています。

github.com


ここでは例として POST されたメッセージをそのまま返す Echo HTTP アプリケーションを作ってみます。

インストール

Swift Package Manager を使います。 swift package init でプロジェクトを初期化します。

mkdir ExampleResponseApp
cd ExampleResponseApp
swift package init --type executable

この後に、 Package.swift が次の通りになるように dependencies/targets/products に追記します。

// swift-tools-version:4.2

import PackageDescription

let package = Package(
    name: "ExampleResponseApp",
    products: [
        .executable(name: "ExampleResponseApp", targets: ["ExampleResponseApp"]),
    ],
    dependencies: [
        .package(url: "https://github.com/amzn/smoke-framework.git", .upToNextMajor(from: "0.6.0")),
    ],
    targets: [
        .target(name: "ExampleResponseApp", dependencies: ["SmokeOperationsHTTP1"]),
        .testTarget(name: "ExampleResponseAppTests", dependencies: ["ExampleResponseApp"]),
    ]
)

アプリケーション

このフレームワークでアプリケーションを作る場合、以下の3ステップで作れます。

  1. オペレーション関数を記述する
  2. パスとオペレーション関数を結びつけたハンドラーセレクターを作る
  3. ハンドラーセレクターをサーバーにわたす

以下のプログラムは次のモジュールをインポートしておきます。なお、 LoggerAPI は smoke-framework が依存している IBM-Swift の LoggerAPI です。

import SmokeHTTP1
import SmokeOperationsHTTP1
import SmokeOperations
import NIOHTTP1
import LoggerAPI

オペレーション関数

オペレーション関数として次の型を満たす関数を作ります

(InputType, ContextType) throws -> OutputType

オペレーション関数に挙げられている型は特定のプロトコルを満たしている必要があります。README には書かれていますがチュートリアルの部分には書いていないため、最初戸惑いました。

ContextType

ContextType はアプリケーションを実行する際の様々な情報を持つデータで、ユーザーは任意の型を作れます。

とりあえずは、このような struct を作ってみます

struct MyAppContext {}
InputTypeOutputType

次に、 InputTypeOutputType ですが、こちらは ValidatableCodable プロトコルを満たす必要があります。 ValidatableCodable プロトコルの定義は次のようになっています。

public typealias ValidatableCodable = Validatable & Codable

public protocol Validatable {
  func validate() throws
}

これを満たすように InputTypeOutputType を作ります。なお、ここにある ErrorResponse 型については次で述べます。

struct Message {
  let text: String
}

extension Message: ValidatableCodable {
  func validate() throws {
    if self.text.isEmpty {
      throw ErrorResponse.badRequest
    }
  }
}
エラーの型

投げるエラーの型は ErrorIdentifiableByDescription プロトコルを満たす必要があります。これの定義は次のとおりです。

typealias ErrorIdentifiableByDescription = Swift.Error & CustomStringConvertible

これを満たすように ErrorResponse を書いてみます。

enum ErrorResponse {
  case badRequest
}

extension ErrorResponse: ErrorIdentifiableByDescription {
  var description: String {
    return "bad request"
  }

  var statusCode: Int {
    return 400
  }
}
オペレーション関数を組み立て

上記の条件を満たした後、オペレーション関数を組み立てます。届いたメッセージをそのまま返す単純な関数です。

func echo(message: Message, context: MyAppContext) throws -> Message {
  Log.info("message: \(message.text)")
  return message
}

ハンドラーセレクターを作る

作成したオペレーション関数とパスのマッピングを作ります。まずは次のような型エイリアスをつくります。

public typealias HandlerSelector =
    StandardSmokeHTTP1HandlerSelector<MyAppContext, JSONPayloadHTTP1OperationDelegate>

StandardSmokeHTTP1HandlerSelector には addHandlerForUri(_ uri: httpMethod: operation: allowedErrors: operationDelegate:) という関数が生えているので、この関数にパスとオペレーション関数を渡します。

func handlerSelector() -> HandlerSelector {
  var selector = HandlerSelector()
  selector.addHandlerForUri(
      "/example",
      httpMethod: .POST,
      operation: echo,
      allowedErrors: [(ErrorResponse.badRequest, ErrorResponse.badRequest.statusCode)],
      operationDelegate: nil)
  return selector
}

アプリケーション組み立て

アプリケーションを組み立てます。先程準備したコンテキストオブジェクトとハンドラーセレクターを SmokeHTTP1Server の class 関数 startAsOperationServer に渡します。

do {
  try SmokeHTTP1Server.startAsOperationServer(
      withHandlerSelector: handlerSelector(),
      andContext: MyAppContext(),
      defaultOperationDelegate: JSONPayloadHTTP1OperationDelegate())
} catch {
  Log.error("failed to start server: \(error)")
}

これをそのまま実行してもよいのですが、デフォルトの LoggerAPI はログを出力しません。そこで、次のような Logger をつくって、 Logger に登録します。

struct StandardOutLogger: Logger {
  func log(_ type: LoggerMessageType, msg: String, functionName: String, lineNum: Int, fileName: String) {
    if isLogging(type) {
      print("\(type) (\(fileName) - \(functionName) -\(lineNum)) - \(msg)")
    }
  }
  func isLogging(_ level: LoggerMessageType) -> Bool {
    switch level {
    case .info, .error, .warning: return true
    default: return false
    }
  }
}

アプリケーションを実行

これでアプリケーションの完成です。次のコマンドでアプリケーションを実行してみます。

swift run ExampleResponseApp

次のようなログが出てきて、ポート 8080 でサーバーが起動していることがわかります。ポートの選択の仕方が Java っぽいです。

INFO (/Users/user/ExampleResponseApp/.build/checkouts/smoke-framework.git-5627467287507921261/Sources/SmokeOperationsHTTP1/SmokeHTTP1Server+startAsOperationServer.swift - startAsOperationServer(withHandlerSelector:andContext:defaultOperationDelegate:andPort:invocationStrategy:) -49) - Server starting on port 8080...
INFO (/Users/user/ExampleResponseApp/.build/checkouts/smoke-framework.git-5627467287507921261/Sources/SmokeOperationsHTTP1/SmokeHTTP1Server+startAsOperationServer.swift - startAsOperationServer(withHandlerSelector:andContext:defaultOperationDelegate:andPort:invocationStrategy:) -53) - Server started on port 8080...

curl で動作確認してみます。このようなレスポンスが返ってくるのではないかと思います。

$ curl -i http://localhost:8080/example -d '{"text":"hello, smoke-framework!"}'
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 40

{
  "text" : "hello, smoke-framework!"
}

比較的簡単に Swift で web アプリケーションを書けるので、普段は iOS を触っている方も、モックの HTTP サーバーを書くのに使ってみてはいかがでしょうか?


smoke-frmaework シリーズ

現在 GitHub の amzn のレポジトリーに smoke を冠したレポジトリーは smoke-framework を含めて 4 つほどあります。

  • smoke-framework
  • smoke-http
    • Swift-NIO ベースの http client
  • smoke-aws
    • smoke-http ベースの aws sdk
  • smoke-aws-credentials
    • ECS で使うことが前提っぽい短時間の aws クレデンシャル
  • smoke-dynamoDB
    • Swift で dynamoDB を扱いやすくするためのライブラリー

これらのレポジトリーが 10 月中旬ころからできており、また smoke-aws のコミットログを見るとモデルクラスの生成を aws-sdk-go から行っているようです。割とすごい速さで充実していってるような印象を持ちました。


というわけで、 Amazon 製の smoke-framework の紹介でした。

明日は @fumiyasac さんが UIについて書いてくれるそうです。






余談話

2018/11/30 に開催された 中央線Meetup 用にデモとして、 Azure で smoke-framework アプリケーションをビルドしてみたのですが、このようなログが出てきました。

2018-11-25T12:30:56.2632921Z ==============================================================================
2018-11-25T12:30:56.2633017Z Task         : Command Line
2018-11-25T12:30:56.2633076Z Description  : Run a command line script using cmd.exe on Windows and bash on macOS and Linux.
2018-11-25T12:30:56.2633167Z Version      : 2.142.2
2018-11-25T12:30:56.2633216Z Author       : Microsoft Corporation
2018-11-25T12:30:56.2633314Z Help         : [More Information](https://go.microsoft.com/fwlink/?LinkID=613735)
2018-11-25T12:30:56.2633377Z ==============================================================================
2018-11-25T12:30:57.1610232Z Generating script.
2018-11-25T12:30:57.1634662Z Script contents:
2018-11-25T12:30:57.1635377Z swift build --configuration release --product ExampleResponseApp
2018-11-25T12:30:57.1666791Z [command]/bin/bash --noprofile --norc /__w/_temp/5c9a12a6-7144-410f-b5ff-96bb137424b9.sh
2018-11-25T12:30:57.5146866Z Fetching https://github.com/apple/swift-nio.git
2018-11-25T12:30:57.5159040Z Fetching https://github.com/IBM-Swift/LoggerAPI.git
2018-11-25T12:30:57.5173837Z Fetching https://github.com/apple/swift-nio-zlib-support.git
2018-11-25T12:30:58.5142300Z Fetching https://github.com/amzn/smoke-framework.git
2018-11-25T12:31:03.6976063Z Completed resolution in 6.19s

おわかりいただけただろうか一つのログの中に

の巨大な4つの会社の名前が出ているのである。