cockscomblog?

cockscomb on hatena blog

Swift AWS Lambda Runtimeを試す

2020年5月末に、 SwiftをAWS Lambdaで動作させるプロジェクトが発表された。swift-server/swift-aws-lambda-runtimeがそれである。ということで、AWS CDKでAPI GatewayとSwiftのLambda Handlerを作ってみた。

Lambda Runtime

AWS Lambdaは、AWSのFaaS。提供されているランタイムを使えば、ソースコードをアップロードするだけで、関数が実行できる。提供されているランタイムはNode.jsやPythonRubyJava、Go、.NET Coreである(徐々に拡充されていった)。そして2018年のre:Inventで、Lambda LayerとLambda Runtime APIが発表され、swift-aws-lambda-runtimeでは、このRuntime APIを利用している。

Lambda Runtime APIを乱暴に説明すると、こうだ。bootstrapという実行ファイルを用意しておくと、自動的にこれを起動してくれる。bootstrapは内部でイベントループを回す。イベントループの内部では、HTTPでイベントを取得し、それを処理して、結果をHTTPで送る、というのを繰り返す。

swift-aws-lambda-runtimeは、イベントループを回して、イベントを取得して結果を返す、というところをやってくれる。

Lambda

f:id:cockscomb:20200615001416p:plain
AWS Lambda

swift-aws-lambda-runtimeの主要な開発者であるFabian Fettさんのチュートリアルを参考に進める。

Getting started with Swift on AWS Lambda

まずはSwift Package Managerでpackageを作る。

$ swift package init --type executable

API Gatewayを使いたいので、Package.swiftAWSLambdaEventsの依存も追加。

import PackageDescription

let package = Package(
    name: "Handler",
    platforms: [
        .macOS(.v10_13),
    ],
    products: [
        .executable(name: "Handler", targets: ["Handler"]),
    ],
    dependencies: [
        .package(
            url: "https://github.com/swift-server/swift-aws-lambda-runtime.git",
            .upToNextMajor(from: "0.1.0")),
    ],
    targets: [
        .target(
            name: "Handler",
            dependencies: [
                .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"),
                .product(name: "AWSLambdaEvents", package: "swift-aws-lambda-runtime"),
            ]
        ),
    ]
)

API Gatewayのリクエストを受けて適当なJSONを返すのは、以下のようになる。

import AWSLambdaEvents
import AWSLambdaRuntime
import Foundation

struct Response: Codable {
    let message: String
}

Lambda.run {
    (
    context,
    request: APIGateway.Request,
    callback: @escaping (Result<APIGateway.Response, Error>) -> Void
    ) in
    context.logger.debug("\(request)")
    let encoder = JSONEncoder()
    do {
        let response = Response(message: "OK")
        let json = try encoder.encode(response)
        callback(
            .success(
                APIGateway.Response(
                    statusCode: .ok,
                    headers: ["Content-Type": "application/json"],
                    body: String(bytes: json, encoding: .utf8)
                )
            )
        )
    } catch {
        callback(.failure(error))
    }
}

Lambda Runtimeの説明で書いたようなイベントループとかそういうのは、すっかり抽象化されている。

あとはこれをbootstrapという実行ファイルにして、Lambdaにアップロードすればいい。

パッケージング

実行ファイルはAmazon Linux 2で作る。Dockerの公式なSwiftイメージに、Amazon Linux 2のものが用意されているので、今回はswift:5.2-amazonlinux2を使う。

チュートリアルを真似て、簡単なシェルスクリプトを用意する。

#!/bin/bash

set -eu

executable=$1

swift build --product $executable -c release

target=.build/lambda/$executable
rm -rf "$target"

mkdir -p "$target"
cp ".build/release/$executable" "$target/"
cp -Pv \
  /usr/lib/swift/linux/libBlocksRuntime.so \
  /usr/lib/swift/linux/libFoundation.so \
  /usr/lib/swift/linux/libFoundationNetworking.so \
  /usr/lib/swift/linux/libFoundationXML.so \
  /usr/lib/swift/linux/libdispatch.so \
  /usr/lib/swift/linux/libicudataswift.so \
  /usr/lib/swift/linux/libicudataswift.so.65 \
  /usr/lib/swift/linux/libicudataswift.so.65.1 \
  /usr/lib/swift/linux/libicui18nswift.so \
  /usr/lib/swift/linux/libicui18nswift.so.65 \
  /usr/lib/swift/linux/libicui18nswift.so.65.1 \
  /usr/lib/swift/linux/libicuucswift.so \
  /usr/lib/swift/linux/libicuucswift.so.65 \
  /usr/lib/swift/linux/libicuucswift.so.65.1 \
  /usr/lib/swift/linux/libswiftCore.so \
  /usr/lib/swift/linux/libswiftDispatch.so \
  /usr/lib/swift/linux/libswiftGlibc.so \
  "$target"
cd "$target"
ln -s "$executable" "bootstrap"
zip --symlinks lambda.zip *

swift buildして、Swiftのランタイムや標準ライブラリ(shared objectファイル)を集めてきて、実行ファイルをbootstrapという名前でsymlinkして、ZIPにまとめている。

$ docker run --rm -it swift:5.2-amazonlinux2 ls /usr/lib/swift/linux
libBlocksRuntime.so       libicui18nswift.so.65.1
libFoundation.so          libicuucswift.so
libFoundationNetworking.so    libicuucswift.so.65
libFoundationXML.so       libicuucswift.so.65.1
libXCTest.so              libswiftCore.so
lib_InternalSwiftSyntaxParser.so  libswiftDispatch.so
libdispatch.so            libswiftGlibc.so
libicudataswift.so        libswiftRemoteMirror.so
libicudataswift.so.65         libswiftSwiftOnoneSupport.so
libicudataswift.so.65.1       libswift_Differentiation.so
libicui18nswift.so        x86_64
libicui18nswift.so.65

あとはこれをDockerで動かす。

FROM swift:5.2-amazonlinux2

RUN yum -y update && yum -y install \
  zip

COPY build.sh /build.sh
WORKDIR /src
ENTRYPOINT ["/build.sh"]

ソースコード/srcにマウントするつもりなので、Dockerfileはこれだけ。

$ docker build --tag swift-lambda-builder .
$ docker run --rm --volume /Users/cockscomb/swift-lambda/handler:/src swift-lambda-builder Hanlder

あとはこういう感じで実行すると、マウントされたディレクトリ下に.build/lambda/Handler/lambda.zipができる。

AWS CDKを使う

パッケージングからデプロイの作業は退屈なので、Infrastructure as Codeって感じで、AWS CDKを使ってまとめてしまう。AWS CDKというのは、AWS CloudFormationをいい感じにしてくれるやつ。

CloudFormationは、YAMLとかで書かれたテンプレートをもとに、AWS上のリソース(ここではLambdaとか)を作成したり、更新したりしてくれる。CloudFormationで作ったリソースは、手で書き換えてはいけない。CloudFormationする時の単位をスタックと言う。

AWS CDKを使うと、CloudFormationのテンプレートをTypeScriptなどのプログラミング言語で書けるようになる。今回は使わないが、スタック間のリソースの依存関係もうまく表現できる。

CDKをセットアップしたら、以下のようなスタックを定義する。

import * as cdk from "@aws-cdk/core";
import * as lambda from "@aws-cdk/aws-lambda";
import * as apigateway from "@aws-cdk/aws-apigateway";

export class ApiGatewaySwiftStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const code: lambda.AssetCode = // TODO: ここをなんとかする

    const handler = new lambda.Function(this, "Handler", {
      code,
      handler: "Handler",
      runtime: lambda.Runtime.PROVIDED,
    });

    new apigateway.LambdaRestApi(this, "Api", {
      handler,
    });
  }
}

Lambda Functionを作って、ランタイムをProvidedにして、API Gatewayにくっつけている。AWS CDKのAPI Referenceに、各パッケージの主要な使い方が載っているので、とっつきやすい。

import "source-map-support/register";
import * as cdk from "@aws-cdk/core";
import { ApiGatewaySwiftStack } from "../lib/api-gateway-swift-stack";

const app = new cdk.App();
new ApiGatewaySwiftStack(app, "ApiGatewaySwiftStack", {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});

ここで作ったclass ApiGatewaySwiftStackを、appの側でこういう感じでnewしてやればOK。

// TODO: ここをなんとかする、と書いたところでさっきのlambda.zipを作ってやりたいのだけど、どうしたものかと思っていたら、オフィシャルの@aws-cdk/aws-lambda-nodejsパッケージで、Builder classを作ってDockerでなんかしているのを見つけたので、真似する。

import * as lambda from "@aws-cdk/aws-lambda";
import { spawnSync, SpawnSyncReturns } from "child_process";
import * as path from "path";

interface Options {
  dir: string;
  executable: string;
}

export class Builder {
  private static imageName: string = "swift-lambda-builder";

  constructor(private readonly options: Options) {}

  private docker(args: string[]): SpawnSyncReturns<string> {
    const returns = spawnSync("docker", args);
    if (returns.error) {
      throw returns.error;
    }
    if (returns.status !== 0) {
      throw new Error(
        `[Status ${
          returns.status
        }] stdout: ${returns.stdout?.toString().trim()}\n\n\nstderr: ${returns.stderr?.toString().trim()}`
      );
    }
    return returns;
  }

  public build(): lambda.AssetCode {
    this.docker(["build", "--tag", Builder.imageName, path.join(__dirname, "../builder")]);
    this.docker(["run", "--rm", "--volume", `${this.options.dir}:/src`, Builder.imageName, this.options.executable]);
    return lambda.Code.fromAsset(
      path.join(this.options.dir, "./.build/lambda/", this.options.executable, "lambda.zip")
    );
  }
}

こういうDocker CLIを呼び出すものを作って、さっきのところに埋める。

import * as cdk from "@aws-cdk/core";
import * as lambda from "@aws-cdk/aws-lambda";
import * as apigateway from "@aws-cdk/aws-apigateway";
import * as path from "path";
import { Builder } from "./builder";

export class ApiGatewaySwiftStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const code = new Builder({
      dir: path.join(__dirname, "../handler"),
      executable: "Handler",
    }).build();

    const handler = new lambda.Function(this, "Handler", {
      code,
      handler: "Handler",
      runtime: lambda.Runtime.PROVIDED,
    });

    new apigateway.LambdaRestApi(this, "Api", {
      handler,
    });
  }
}

あとはこれを使ってデプロイする。事前にAWS CLIの設定をしておくとよい。

$ npm run cdk deploy

AWSアカウントでまだCDKを使ったことがなければ、先に$ npm run cdk bootstrapが必要かもしれない。

うまくいくと、API GatewayのURLが出力されるだろう。AWS ConsoleのCloudFormationを見ると、作成されたスタックが表示される。

f:id:cockscomb:20200615001508p:plain
AWS CloudFormation

適当にcurlしてみると、実際に動いている様子がわかる。

$ curl --include https://xxx.execute-api.ap-northeast-1.amazonaws.com/prod/
HTTP/2 200 
content-type: application/json
content-length: 16
date: Sun, 14 Jun 2020 13:25:02 GMT
x-amzn-requestid: xxx
x-amz-apigw-id: xxx
x-amzn-trace-id: xxx
x-cache: Miss from cloudfront
via: 1.1 xxx.cloudfront.net (CloudFront)
x-amz-cf-pop: xxx
x-amz-cf-id: xxx

{"message":"OK"}

まとめ

上記の素朴なLambda Functionでは、コールドスタートとみられる場合で300から400 msの時間がかかった。一方でスタンバイからの実行では、2 ms程度だった。コールドスタートは、Lambda内部の初期化で150 ms、Lambda Functionの初期化が180 msというところ。それほど悪くはないと思う。メモリの消費は、メモリ容量を128 MBに設定したうちの51 MB。

コスト的には、Lambdaだけなら100万リクエストで1ドルかからないくらい、API Gatewayと合わせても5ドルくらいか。

ということで、SwiftでLambda Functionを作って、API Gateway経由で呼び出せるようにした。デプロイにはAWS CDKを使っている。完全なサンプルコードは以下。

実際にAWS LambdaをSwiftで開発するのかどうかというと、現時点では微妙なところである。しかしServer-side Swiftのエコシステムが、少しずつでも整っていくのは興味深い。