🌟

SwiftでVercelのServerless Functionsを書く

2022/07/30に公開約7,700字

Vercelには api/ ディレクトリにスクリプトを入れてデプロイすると自動でWeb APIにしてくれるServerless Functions という機能がある。

この機能はデフォルトでNode.js, Go, Python, Ruby が対応しているが、ユーザー独自にFunctionのruntime(実行環境)を追加できる仕組みがある。

https://github.com/vercel/vercel/blob/fc3fa61b593265ae0d1519761142bc79154746b5/DEVELOPING_A_RUNTIME.md

Deno, PHP, Rust なんかは既に作っている人がいたがSwiftは見つからなかったので自作した。

全体像

Serverless Functions の実体はVercel経由でデプロイされるAWS Lambda関数になっている。このLambda関数を構築するためのnpmパッケージがruntimeという名称になっていて、ユーザーがVercelのプロジェクト設定で指定できる。

vercel.json
// これを目指す
{
  "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などの言語は自分で動作環境を作る必要がある。

なので

  1. Lambda関数で実行可能なプログラムを作成する
  2. npmモジュールを作って上記をVercelプロジェクトからアップロードできるようにする
  3. 自分のプロジェクトの vercel.json に追加してデプロイする

という手順で実現できる

Lambda関数を作成

サーバーサイドSwiftを使ってLambdaで動作する単体プログラムをまず作ってみる。Swift AWS Lambda Runtime というパッケージが公開されていて、これを使うことで簡単に作成できる。

Package.swift
// 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でデコードして返す関数。

hello.swift
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/" }]
}
index.ts
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のドキュメントにあったコマンドで実行するようにした。

index.ts
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

ログインするとコメントできます