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やPython、Ruby、Java、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
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.swift
でAWSLambdaEvents
の依存も追加。
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を見ると、作成されたスタックが表示される。
適当に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のエコシステムが、少しずつでも整っていくのは興味深い。