SwiftでVercelのServerless Functionsを書く
Vercelには api/
ディレクトリにスクリプトを入れてデプロイすると自動でWeb APIにしてくれるServerless Functions という機能がある。
この機能はデフォルトでNode.js, Go, Python, Ruby が対応しているが、ユーザー独自にFunctionのruntime(実行環境)を追加できる仕組みがある。
Deno, PHP, Rust なんかは既に作っている人がいたがSwiftは見つからなかったので自作した。
全体像
Serverless Functions の実体はVercel経由でデプロイされるAWS Lambda関数になっている。このLambda関数を構築するためのnpmパッケージがruntimeという名称になっていて、ユーザーがVercelのプロジェクト設定で指定できる。
// これを目指す
{
"functions": {
"api/hello.swift": {
"runtime": "vercel-swift@0.1.0"
}
}
}
このパッケージのトップレベルに定義した build(opts: BuildOptions)
という関数を実装することでVercelのインフラ上でBuild時に任意のNode.jsスクリプト実行可能になるというプラグイン機構になっている。
Lambdaは簡単にいうと「必要なファイル」+ runtime(Lambdaの) + 「呼び出し関数名」を揃えてアップロードするとデプロイできるサービスで、VercelのServerless Functionsの場合は @vercel/build-utils
というモジュールにLambdaを作成する関数 createLambda()
がある。
Rust版から抜粋
//https://github.com/vercel-community/rust/blob/a9495a0f0d882a36ea165f1629fcc79c30bc3108/src/index.ts
const lambda = await createLambda({
files: {
...extraFiles,
[bootstrap]: new FileFsRef({ mode: 0o755, fsPath: bin })
},
handler: bootstrap,
runtime: 'provided'
});
return { output: lambda };
}
Rust版では bootstrap
という実行ファイルにコードをコンパイルして呼び出しプログラムとして指定している。
provided
というのは AWS Lambda のカスタムランタイム の Amazon Linux を使う時の指定になる。Lambda ランタイム でJavaや.NETなどはサポートされているが、RustやSwiftなどの言語は自分で動作環境を作る必要がある。
なので
- Lambda関数で実行可能なプログラムを作成する
- npmモジュールを作って上記をVercelプロジェクトからアップロードできるようにする
- 自分のプロジェクトの
vercel.json
に追加してデプロイする
という手順で実現できる
Lambda関数を作成
サーバーサイドSwiftを使ってLambdaで動作する単体プログラムをまず作ってみる。Swift AWS Lambda Runtime というパッケージが公開されていて、これを使うことで簡単に作成できる。
// swift-tools-version:5.5
import PackageDescription
let package = Package(
name: "vercel-swift-example",
platforms: [
.macOS(.v12),
],
products: [
.executable(name: "MyLambda", targets: ["MyLambda"]),
],
dependencies: [
.package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "main")
],
targets: [
.executableTarget(
name: "MyLambda",
dependencies: [
.product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"),
],
path: "."
),
]
)
Lambdaに渡ってきたEventの body をそのままJSONでデコードして返す関数。
import AWSLambdaRuntime
struct Request: Codable {
let body: String
}
struct Response: Codable {
var statusCode: Int = 200
let body: String
}
@main
struct MyLambda: LambdaHandler {
typealias Event = Request
typealias Output = Response
init(context: LambdaInitializationContext) async throws {
// setup your resources that you want to reuse for every invocation here.
}
func handle(_ event: Request, context: LambdaContext) async throws -> Response {
Response(body: String(event.body))
}
}
LambdaのEventのbodyとHTTPリクエストのbodyは別ものであることに注意。Lambdaの関数の戻り値として {statusCode, body, encode}
というインターフェイスを返すことで、Vercel内部で呼び出された関数がHTTPレスポンスに変換される。
この2ファイルを任意のVercelプロジェクトに配置し swift build
でコンパイルできることを確認。
$ swift build -c release
$ ./.build/release/MyLambda
2022-07-30T18:55:09+0700 info Lambda : lambda runtime starting with LambdaConfiguration
General(logLevel: info))
Lifecycle(id: 334297639284791, maxTimes: 0, stopSignal: TERM)
RuntimeEngine(ip: 127.0.0.1, port: 7000, requestTimeout: nil
2022-07-30T18:55:09+0700 error Lambda : lifecycleIteration=0 could not fetch work from lambda runtime engine: connection reset (error set): Connection refused (errno: 61)
2022-07-30T18:55:09+0700 error Lambda : lifecycleIteration=0 lambda invocation sequence completed with error: connection reset (error set): Connection refused (errno: 61)
2022-07-30T18:55:09+0700 info Lambda : shutdown completed
これ単体で実行してもとくに結果は得られなくてVercelのサーバーを通す必要がある。
npmモジュールを作成
Vercelのサーバーを通すのはデプロイをしないでの vercel dev
コマンドでローカルで実行できる。ただこの時に vercel.json
にビルドに使うnpmモジュールを指定する必要がある。
ローカルで開発する時は以下のように use
を指定することで、npm publishする前に使うことができる。
$ mkdir vercel-swift && cd vercel-swift
$ npm init
{
"builds": [{ "src": "api/**/*.swift", "use": "/path/to/vercel-swift/" }]
}
import {createLambda, FileFsRef} from "@vercel/build-utils";
export const version = 3;
export async function build() {
console.log("vercel-swift Building...");
const lambda = await createLambda({
files: {'bootstrap': new FileFsRef({ mode: 0o755, fsPath: '.build/release/MyLambda' })},
handler: 'bootstrap',
runtime: 'provided',
})
return {
output: lambda,
};
}
これだけで /api/**/*.swift
にリクエストが来たら MyLambda を実行するServerless Functionになる。
$ vercel dev
Vercel CLI 27.2.0
> Creating initial build
{ builder: { version: 3, build: [AsyncFunction: build] } }
vercel-swift Building...
> Success! Build completed
> Ready! Available at http://localhost:3000
$ curl http://localhost:3000/api/hello.swift -s | jq .
{
"method": "GET",
"host": "localhost:3000",
"path": "/api/hello.swift",
"headers": {
"host": "localhost:3000",
"user-agent": "curl/7.84.0",
"accept": "*/*",
"connection": "close",
"x-real-ip": "::ffff:127.0.0.1",
"x-vercel-deployment-url": "localhost:3000",
"x-vercel-forwarded-for": "::ffff:127.0.0.1",
"x-vercel-id": "dev1::dev1::vvuh8-165978797946842-68d88c58e25e",
"x-forwarded-host": "localhost:3000",
"x-forwarded-proto": "http",
"x-forwarded-for": "::ffff:127.0.0.1"
},
"encoding": "base64",
"body": ""
}
デプロイしてVercel環境で確認
これをVercel上で確認できるようにするのだけど .build/release/MyLambda
は自分のローカルでコンパイルしたものなので、Vercelのインフラで実行される時は存在しない。
Vercelのビルド時にswift build
を行いバイナリが作成されるようにする。これにも npmモジュールのコードで対応できる。execaというライブラリを使い愚直にSwiftのドキュメントにあったコマンドで実行するようにした。
import {commandSync} from "execa";
export async function build() {
console.log("vercel-swift Building...");
await commandSync(
`curl https://download.swift.org/experimental-use-only/repo/amazonlinux/releases/2/swiftlang.repo -o /etc/yum.repos.d/swiftlang.repo`,
{ shell: true, stdio: 'inherit' }
);
await commandSync(
`amazon-linux-extras install epel`,
{ shell: true, stdio: 'inherit' }
);
await commandSync(
`yum install swiftlang`,
{ shell: true, stdio: 'inherit' }
);
await commandSync(
`swift build -c release --static-swift-stdlib`,
{ shell: true, stdio: 'inherit' }
);
const lambda = await createLambda({
files: {'bootstrap': new FileFsRef({ mode: 0o755, fsPath: '.build/release/MyLambda' })},
handler: 'bootstrap',
runtime: 'provided',
})
return {
output: lambda,
};
}
VercelのLambda環境で動的にライブラリを参照することができなかったので swift build -c release --static-swift-stdlib
と静的ライブラリ形式にして回避した。
デプロイする時はVercelのインフラ上でビルドが実行されるので、npmモジュールをGitHubから取得できるようにしておく。
{
"builds": [{ "src": "api/**/*.swift", "use": "https://github.com/laiso/vercel-swift/" }]
}
デプロイする(余談だけど引数なしのvercelコマンドが vercel deploy
のエイリアスになっていて、引数なしでヘルプを見ようと実行していつもびっくりする)
vercel
成功
Appendix
- npmモジュールのビルドスクリプトでプロジェクトにあるファイルによって動的にビルドを切り替えることで応用する
- Vaporに対応したかったらビルドスクリプト内でSwiftコードを検知してコンパイルするSwiftコードを動的に書き換えるとか
Discussion