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
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.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\n stderr: ${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 を見ると、作成されたスタックが表示される。
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のエコシステムが、少しずつでも整っていくのは興味深い。