Wasm を利用したフレームワーク Spin について（前編）

## はじめに

WebAssembly(Wasm)は元々ブラウザで実行するものでしたが、[WASI](https://wasi.dev/)等のサーバーサイドで実行するための取り組みも進んでいます。
この記事ではサーバーサイドでの活用例の一つとしてSpinというフレームワークを紹介します。

## Spinの概要

[Spin](https://github.com/fermyon/spin)はFermyon社が開発しているWebAssemblyを使用してマイクロサービスをビルド・実行するためのフレームワークで、以下の特徴があります。

- Spin SDKを使用して実装したハンドラをWebAssembly形式でコンパイルしてイベントドリブンで実行する(WebAssemblyを使ったFaaS)
- ランタイムはWasmtimeを利用
- イベントは、HTTPアプリケーションを作成できる「HTTPトリガー」とRedis Pub/Subのメッセージを処理できる「Redisトリガー」の2種類をサポート
- 複数のプログラミング言語でマイクロサービス（ハンドラ）を実装できる
    - Rust、TypeScript、Python、Go(TinyGo)
    - 各言語のSDKによってサポートされる機能は異なる(Rustが最も機能豊富)
- ハンドラ内では、Outbound HTTP、Outbound Redis、MySQL、SQLite、Key-Valueストア等のAPIを利用可能 ※言語によってサポート状況は異なる
- 各ハンドラはサンドボックスで実行される（外部への通信はホワイトリスト形式で明示的に許可する必要がある)
- AWS Lambda等の既存のFaaSに比べてコールドスタートが高速(数ms)

WebAssemblyの「多言語対応」「セキュア」「起動が高速」といった特徴はFaaSと相性がよく、これらの特徴を生かしたFaaS実行基盤と言えます。
2023年3月に初のstableバージョンv1.0.0がリリースされ、2023年11月にはv2.0.0がリリースされています。

**※ この記事の実行例では執筆時点の最新版であるspin v2.0.1を主に使用しています。(一部v1.4.1を使用)**

## 使用方法

まずはSpinのイメージを掴むために、アプリケーションの作成方法と各環境へのデプロイ方法を紹介します。

### プロジェクト作成〜ローカル実行

[`spin`コマンドをインストール](https://developer.fermyon.com/spin/install)し、以下の3ステップでSpinアプリケーションをローカルで起動できます。

1. `spin new`でプロジェクトの雛形を作成
1. `spin build`でWebAssemblyモジュールにコンパイル
1. `spin up`でローカルで起動

:computer: 実行例

```shell
# spin newを実行し、テンプレートからアプリケーションの種類を選択して雛形を作成する
$ spin new
Pick a template to start your application with:
  http-c (HTTP request handler using C and the Zig toolchain)
  http-empty (HTTP application with no components)
  http-go (HTTP request handler using (Tiny)Go)
  http-grain (HTTP request handler using Grain)
  http-js (HTTP request handler using Javascript)
  http-php (HTTP request handler using PHP)
  http-py (HTTP request handler using Python)
> http-rust (HTTP request handler using Rust)
  http-swift (HTTP request handler using SwiftWasm)
  http-ts (HTTP request handler using Typescript)
  http-zig (HTTP request handler using Zig)
  redirect (Redirects a HTTP route)
  redis-go (Redis message handler using (Tiny)Go)
  redis-rust (Redis message handler using Rust)
  static-fileserver (Serves static files from an asset directory)
Pick a template to start your application with: http-rust (HTTP request handler using Rust)
Enter a name for your new application: hello-http-rust
Description: 
HTTP path: /...

# 以下のようなプロジェクトの雛形が生成される
$ tree hello-http-rust/
hello-http-rust/
├── Cargo.toml
├── spin.toml
└── src
    └── lib.rs

1 directory, 3 files

# プロジェクトディレクトリに移動
$ cd hello-http-rust/

# spin buildでWebAssemblyバイナリ(*.wasm)にコンパイル
$ spin build
Building component hello-http-rust with `cargo build --target wasm32-wasi --release`
    Updating crates.io index
    Updating git repository `https://github.com/fermyon/spin`
   Compiling proc-macro2 v1.0.70
   ...
   Compiling wit-bindgen v0.13.1
   Compiling hello-http-rust v0.1.0 (/Users/takuhiro/spin-projects/v2/hello-http-rust)
    Finished release [optimized] target(s) in 15.36s
Finished building all Spin components

# Wasmバイナリが作成されていることを確認
$ ls
build/                  deps/                   examples/               hello_http_rust.d       hello_http_rust.wasm*   incremental/

# spin upでローカルで起動
$ spin up
Logging component stdio to ".spin/logs/"

Serving http://127.0.0.1:3000
Available Routes:
  hello-http-rust: http://127.0.0.1:3000 (wildcard)

----
# 別ターミナルで動作確認
$ curl http://127.0.0.1:3000
Hello, Fermyon
```

Spinでは以下のような`spin.toml`でアプリケーションの設定を管理するので簡単にみておきます。

```spin.toml
spin_manifest_version = 2

[application]
name = "hello-http-rust"
version = "0.1.0"
authors = ["takuhiro <*****@****>"]
description = ""

[[trigger.http]]
route = "/..."
component = "hello-http-rust"

[component.hello-http-rust]
source = "target/wasm32-wasi/release/hello_http_rust.wasm"
allowed_outbound_hosts = []
[component.hello-http-rust.build]
command = "cargo build --target wasm32-wasi --release"
watch = ["src/**/*.rs", "Cargo.toml"]
```

`[[trigger.http]]`でHTTPトリガーのパスと実行するコンポーネントのマッピングを指定します。
`[component.hello-spin2-rust]`は実行するWasmバイナリのパスや、ACL等のWasm実行時の設定を記載します。
`[component.hello-spin2-rust.build]`はWasmバイナリの作成コマンドを指定します。ここはプログラミング言語ごとに異なる部分です。

生成されたソースコードもみておきます。

```src/lib.rs
use spin_sdk::http::{IntoResponse, Request};
use spin_sdk::http_component;

/// A simple Spin HTTP component.
#[http_component]
fn handle_hello_http_rust(req: Request) -> anyhow::Result<impl IntoResponse> {
    println!("Handling request to {:?}", req.header("spin-full-url"));
    Ok(http::Response::builder()
        .status(200)
        .header("content-type", "text/plain")
        .body("Hello, Fermyon")?)
}
```

Spin SDKを使用してHTTPのハンドラを実装していることがわかります。ここではRustですが、他の言語も同じような構成です。

### Fermyon Cloudで実行

作成したSpinアプリケーションは、Fermyon社が提供しているFermyon CloudにCLIで簡単にデプロイできるようになっています。
ただし、[Fermyon CloudはまだOpen Beta](https://developer.fermyon.com/cloud/index#open-beta)でSLAは提供されていません。また、今後非互換の変更が入る可能性があります。
プランによってアプリケーション数とリクエスト実行数に制限が設けられています。（調べた限りSpin自体にはQuota制御の機能はないのでFermyon Cloud側で制限しているようです）

Fermyon Cloudには以下の2ステップでSpinアプリケーションをデプロイします。

1. `spin login`でFermyon Cloudにログイン ※事前にサインアップが必要
1. `spin deploy`でFermyon Cloudにアプリケーションをデプロイ

:computer: 実行例

```sh
# spin loginでログイン
$ spin login

Copy your one-time code:

*****

...and open the authorization page in your browser:

https://cloud.fermyon.com/device-authorization

Waiting for device authorization...
Device authorized!

# spin deployでFermyon Cloudにデプロイ
$ spin deploy
Uploading hello-http-rust version 0.1.0 to Fermyon Cloud...
Deploying...
Waiting for application to become ready................. ready
Available Routes:
  hello-http-rust: https://hello-http-rust-mmuv****.fermyon.app (wildcard)

# 動作確認
$ curl https://hello-http-rust-mmuv****.fermyon.app
Hello, Fermyon
```

### Fermyon Platformで実行

マネージドなFermyon Cloudの他に、self-hostedな環境を構築するための[Fermyon Platform(Installer)](https://www.fermyon.com/platform)がOSSで提供されています。
Fermyon Platformは以下のOSSで構成されています。

- [Nomad](https://www.nomadproject.io/): Spinアプリケーションのスケジューラ
- [Consul](https://www.consul.io/): サービスディスカバリ
- [Traefik](https://traefik.io/): HTTPプロキシ
- [Bindle](https://github.com/deislabs/bindle): Spinアプリケーションのパッケージ管理
- [Hippo](https://github.com/deislabs/hippo): Web UI

AWS等のパブリッククラウドやローカル環境で動かすための[インストーラ](https://github.com/fermyon/installer)が用意されていて、Terraformやシェルスクリプトで構築できるようになっています。

ただし、このインストーラに関しては執筆時点で依存関係の[Spinのバージョンがv0.6.0](https://github.com/fermyon/installer/blob/main/share/terraform/dependencies.yaml#L22)と古いままでした。
[こちらのブログ記事](https://www.felipecruz.es/how-i-built-my-own-wasm-cloud-with-fermyon-platform/)で言及されていましたが、OSS版のFermyon Platformについては現時点でFermyon Cloudと機能に差分があり、今後どの程度追従していくかも不透明とのことでした。
> In this tweet, I asked the CEO of Fermyon, Matt Butcher, if there're future plans for the Fermyon Platform to deliver feature-parity and design in comparison with Fermyon Cloud. In his answer, Matt reckons the platform is currently lagging behind in some features and is uncertain about the direction in which it will go, and whether it will ever catch up with its cloud offering.

まだGitHub Releaseも作成されていない状況なので、今の時点で実行環境としてそのまま使うのは難しそうです。

### K8sクラスタで実行

Spinアプリケーションは[Kubernetesクラスタへのデプロイもサポート](https://developer.fermyon.com/spin/kubernetes)しています。
ここではローカル環境のK8sクラスタで実行する方法を説明しますが、[AzureのAKSでもプレビュー版でSpinアプリケーションが実行できる](https://learn.microsoft.com/ja-jp/azure/aks/use-wasi-node-pools)ようです。

K8sで動かす場合は以下の制約があります。
- Podでは常時HTTPリスナーが起動しているのでFermyon Cloudに比べるとオーバーヘッドがある
- 同一ノード上でのコンテナとWasmモジュールの同時起動はできるが、同一Podではコンテナと同時には起動できない

#### クラスタ管理者側での準備
Spinアプリケーションを実行するために、事前にクラスタ管理者側でSpin用の[RuntimeClass](https://kubernetes.io/ja/docs/concepts/containers/runtime-class/)と[containerd shim](https://github.com/deislabs/containerd-wasm-shims)をセットアップしておく必要があります。

※ Helmでspin-containerd-shim-installerが提供されていますが、Spin v2.0.1にまだ対応していなかったので、ここだけ[Spin v1.4.1](https://github.com/fermyon/spin/releases/tag/v1.4.1)で確認しています。

:computer: 実行例

```sh
# kindでクラスタを作成
$ kind create cluster
Creating cluster "kind" ...
 ✓ Ensuring node image (kindest/node:v1.27.3) 🖼 
...

# helmでspin-containerd-shim-installerをデプロイ
$ helm install fermyon-spin oci://ghcr.io/fermyon/charts/spin-containerd-shim-installer --version 0.8.0
Pulled: ghcr.io/fermyon/charts/spin-containerd-shim-installer:0.8.0
...
To read the logs use:
  
  $ kubectl logs daemonset/fermyon-spin-spin-containerd-shim-installer -c installer --timestamps=true --prefix=true -f

# インストーラのログを確認
$ kubectl logs daemonset/fermyon-spin-spin-containerd-shim-installer -c installer --timestamps=true --prefix=true -f
...
[pod/fermyon-spin-spin-containerd-shim-installer-6265s/installer] 2023-09-04T02:44:27.437938627Z copying the shim to the node's bin directory '/host/bin'
[pod/fermyon-spin-spin-containerd-shim-installer-6265s/installer] 2023-09-04T02:44:27.476392169Z adding spin runtime 'io.containerd.spin.v1' to /host/etc/containerd/config.toml
[pod/fermyon-spin-spin-containerd-shim-installer-6265s/installer] 2023-09-04T02:44:27.481147794Z committed changes to containerd config
[pod/fermyon-spin-spin-containerd-shim-installer-6265s/installer] 2023-09-04T02:44:27.481155294Z restarting containerd

# wasmtime-spin-v1というRuntimeClassがデプロイされたことを確認（PodのruntimeClassNameで指定する）
$ kubectl get runtimeclass wasmtime-spin-v1 -o yaml
apiVersion: node.k8s.io/v1
handler: spin
kind: RuntimeClass
metadata:
  annotations:
    meta.helm.sh/release-name: fermyon-spin
    meta.helm.sh/release-namespace: default
  creationTimestamp: "2023-09-04T02:44:20Z"
  labels:
    app.kubernetes.io/managed-by: Helm
  name: wasmtime-spin-v1
  resourceVersion: "532"
  uid: 615e5489-3d0f-4bc0-b920-755c9527ed5d

# ノードのcontainerdの設定にspinが追加されたことを確認
$ docker exec -it kind-control-plane bash
root@kind-control-plane:/# cat /etc/containerd/config.toml | grep spin
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.spin]
runtime_type = "io.containerd.spin.v1"

# ノードにcontainerd-shim-spin-v1がインストールされたことを確認
root@kind-control-plane:/# which containerd-shim-spin-v1
/usr/local/bin/containerd-shim-spin-v1
```

:memo: RuntimeClassによるランタイムの紐づけが分かりづらいので補足します
1. `Pod.spec.runtimeClassName`でRuntimeClassのnameを指定
    - e.g. `runtimeClassName: wasmtime-spin-v1` 
1. `RuntimeClass.handler`に基づいてcontainerdのプラグイン`[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.${HANDLER_NAME}]`が使用される（[参考](https://kubernetes.io/docs/concepts/containers/runtime-class/#hahahugoshortcodes3hbhb)）
    - e.g. `[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.spin]` 
1. containerdのconfig.tomlのruntime_typeから[containerdの命名規約](https://github.com/containerd/containerd/blob/main/runtime/v2/README.md#binary-naming)に従ってshimのバイナリを実行する
    - `containerd-shim-spin-v1`


#### アプリケーション開発者側でやること

spinにはプラグインというサブコマンドを拡張できる仕組みがあり、K8s関連機能はプラグインとして提供されています。

1. `spin plugin install ...`でK8s用プラグインをインストール
1. `spin k8s scaffold`でDockerfileとK8s用マニフェストを生成
1. `spin k8s build`でコンテナイメージを作成
    - :memo: MacのDocker Desktopの場合、`Features in development`->`Beta features`->`Use containerd for pulling and storing images`を有効化する必要がある
1. `spin k8s push`でコンテナイメージをOCIレジストリ（ghcr.io等）にプッシュ
1. `spin k8s deploy`でK8sクラスタにデプロイ

:computer: 実行例

```sh
# spinコマンドでk8sプラグインをインストール
$ spin plugin install -u https://raw.githubusercontent.com/chrismatteson/spin-plugin-k8s/main/k8s.json
Are you sure you want to install plugin 'k8s' with license Apache-2.0 from https://raw.githubusercontent.com/chrismatteson/spin-plugin-k8s/main/k8s.tar.gz? yes
Plugin 'k8s' was installed successfully!

Description:
        A Fermyon Spin plugin for Kubernetes

Homepage:
        https://github.com/chrismatteson/spin-plugin-k8s

# Dockerfileとk8sマニフェストを生成
$ spin k8s scaffold ghcr.io/takuhiro
Dockerfile Created
deploy.yaml created

# 生成されたDockerfileとマニフェストを確認
$ cat Dockerfile
FROM scratch
COPY ./spin.toml ./spin.toml
COPY ./main.wasm ./main.wasm

$ cat deploy.yaml 
apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-http-go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: hello-http-go
  template:
    metadata:
      labels:
        app: hello-http-go
    spec:
      runtimeClassName: wasmtime-spin-v1
      containers:
        - name: hello-http-go
          image: ghcr.io/takuhiro/hello-http-go:0.1.0
          command: ["/"]
---
apiVersion: v1
kind: Service
metadata:
  name: hello-http-go
spec:
  type: LoadBalancer
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
  selector:
    app: hello-http-go
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: hello-http-go
  annotations:
    ingress.kubernetes.io/ssl-redirect: "false"
    kubernetes.io/ingress.class: traefik
spec:
  rules:
    - http:
        paths:
          - path: /hello-http-go
            pathType: Prefix
            backend:
              service:
                name: hello-http-go
                port:
                  number: 80

# イメージビルド
$ spin k8s build
[+] Building 0.0s (6/6) FINISHED                                                                                                                                                                                   docker:desktop-linux
 => [internal] load .dockerignore                                                                                                                                                                                                  0.0s
...
Image built

# イメージプッシュ
$ spin k8s push
ghcr.io/takuhiro/hello-http-go:0.1.0
b6c1e23f4c52: Pushed 
...
Image pushed

$ spin k8s deploy
deployment.apps/hello-http-go created
service/hello-http-go created
Warning: annotation "kubernetes.io/ingress.class" is deprecated, please use 'spec.ingressClassName' instead
ingress.networking.k8s.io/hello-http-go created

# 動作確認のためにServiceにポートフォワード
$ kubectl port-forward svc/hello-http-go 8080:80
Forwarding from 127.0.0.1:8080 -> 80
Forwarding from [::1]:8080 -> 80

==============
# 別ターミナルで動作確認
$ curl http://localhost:8080
Hello Fermyon!
```

## まとめ

この記事では、WebAssemblyを使ったフレームワークであるSpinの概要と、使用イメージを掴むためにアプリケーションの作成・実行方法について紹介しました。
後編ではWasm外部とのデータのやり取りをどうやっているか等のもう少し踏み込んだ内容について紹介します。