LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

WebAssembly Beyond the Browser: WASI, Kubernetes Sidecars, Spin, and the Future of Serverless

webassemblywasmwasikubernetesserverlessspinfermyon

WebAssembly started as a way to run near-native code in browsers. Then something interesting happened: the same properties that make it valuable in browsers — sandboxed execution, language-agnostic compilation target, deterministic behavior, tiny footprint — turned out to be exactly what server-side infrastructure needed too. If containers solved the “it works on my machine” problem, WebAssembly aims to solve it more completely: a single binary that runs identically on any architecture, any OS, in any environment, with a startup time measured in microseconds rather than seconds.

This guide covers WASI (the system interface that makes server-side Wasm useful), running Wasm workloads in Kubernetes, the Spin framework for building Wasm microservices, and the broader trajectory toward Wasm as a universal compute unit.

Why Server-Side WebAssembly?

Docker solved environment reproducibility, but containers still carry significant weight: a base OS image, a libc, a language runtime, and your application. A minimal Go container might be 10MB. A Python or Node container is typically 100-500MB. Startup takes seconds. The sandbox is at the process/namespace level — a compromised container process still has access to the full Linux syscall surface.

WebAssembly modules are different in every dimension:

Container WebAssembly
Size 10MB–1GB 100KB–10MB
Cold start 100ms–10s <1ms
Isolation Linux namespaces Capability-based sandbox
Architecture Platform-specific Any → wasm32
Memory Shared kernel Isolated linear memory
Syscall surface Full Linux ABI Explicit WASI imports only

Solomon Hykes (Docker co-founder) said it well in 2019: “If WASM+WASI existed in 2008, we wouldn’t have needed to create Docker.”

The tradeoff: Wasm is more restrictive. You can’t run arbitrary Linux software — only code compiled to Wasm. The ecosystem is still maturing. But for greenfield services, edge functions, plugins, and untrusted code execution, the benefits are compelling.

WASI: The System Interface

A Wasm module in the browser gets JavaScript APIs. A Wasm module on the server needs something different — filesystem access, network sockets, clocks, environment variables. WASI (WebAssembly System Interface) is the standardized interface that provides these capabilities.

The key principle: capability-based security. A Wasm module can only do what it’s explicitly granted at instantiation time. If you don’t give it a network socket, it can’t make network connections — not because of iptables rules or seccomp filters, but because the interface literally doesn’t exist from the module’s perspective.

WASI Preview 2 and the Component Model

The Wasm ecosystem is currently transitioning from WASI Preview 1 (the original, POSIX-inspired interface) to WASI Preview 2, built on the Component Model.

The Component Model is a big deal. It defines:

  • Interfaces (WIT — Wasm Interface Types): strongly typed API definitions that work across languages
  • Components: Wasm modules that export and import WIT interfaces
  • Composition: components can be linked together — a Rust component can call a Python component, with the runtime handling type marshaling
// example.wit — a WIT interface definition
package example:hello;

interface greet {
    // Function that takes a string and returns a string
    greet-person: func(name: string) -> string;
}

world greeter {
    export greet;
}

This interface can be implemented in any language that targets Wasm, and called from any language. No protobuf, no gRPC, no serialization overhead — just direct calls between components.

Compiling to WebAssembly

Rust (First-Class Support)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// src/lib.rs — a simple HTTP handler compiled to Wasm
use spin_sdk::http::{IntoResponse, Request, Response};
use spin_sdk::http_component;

#[http_component]
fn handle_request(req: Request) -> anyhow::Result<impl IntoResponse> {
    let name = req.query().get("name").unwrap_or("World");
    Ok(Response::builder()
        .status(200)
        .header("Content-Type", "text/plain")
        .body(format!("Hello, {}!", name))
        .build())
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Cargo.toml
[lib]
crate-type = ["cdylib"]    # Compile as dynamic library for Wasm

[dependencies]
spin-sdk = "3.0"
anyhow = "1"

[profile.release]
opt-level = "s"            # Optimize for size
lto = true
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Install the Wasm target
rustup target add wasm32-wasip1
rustup target add wasm32-unknown-unknown

# Compile
cargo build --target wasm32-wasip1 --release

# Output: target/wasm32-wasip1/release/myhandler.wasm
ls -lh target/wasm32-wasip1/release/myhandler.wasm
# -rw-r--r-- 1 user group 412K myhandler.wasm  ← tiny!

Go (TinyGo for Wasm)

Standard Go compiles to Wasm but produces large binaries due to the runtime. TinyGo produces much smaller modules:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// main.go
package main

import (
    "fmt"
    "net/http"
    spinhttp "github.com/fermyon/spin/sdk/go/v2/http"
)

func init() {
    spinhttp.Handle(func(w http.ResponseWriter, r *http.Request) {
        name := r.URL.Query().Get("name")
        if name == "" {
            name = "World"
        }
        fmt.Fprintf(w, "Hello, %s!", name)
    })
}

func main() {}
1
2
3
4
5
6
# Install TinyGo
wget https://github.com/tinygo-org/tinygo/releases/latest/download/tinygo_linux_amd64.tar.gz
tar -xzf tinygo_linux_amd64.tar.gz

# Compile
tinygo build -target=wasip1 -buildmode=c-shared -o handler.wasm .

JavaScript/TypeScript (via ComponentizeJS)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// handler.ts
export function handleRequest(req: Request): Response {
    const url = new URL(req.uri);
    const name = url.searchParams.get("name") ?? "World";
    return {
        status: 200,
        headers: [["content-type", "text/plain"]],
        body: `Hello, ${name}!`,
    };
}
1
2
3
4
npx @bytecodealliance/componentize-js handler.ts \
  --wit-path wit/ \
  --world-name handler \
  --output handler.wasm

Python (via componentize-py)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# app.py
import spin_http

def handle_request(req):
    name = req.params.get("name", "World")
    return spin_http.Response(
        200,
        {"content-type": "text/plain"},
        bytes(f"Hello, {name}!", "utf-8")
    )
1
2
3
pip install componentize-py
componentize-py --wit-path wit/ --world handler \
  componentize app -o handler.wasm

Running Wasm Locally with Wasmtime

Wasmtime is the reference WASM runtime from the Bytecode Alliance:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Install wasmtime
curl https://wasmtime.dev/install.sh -sSf | bash

# Run a simple Wasm module
wasmtime hello.wasm

# Grant filesystem access (capability-based)
wasmtime --dir /tmp hello.wasm

# Grant only a specific directory mapping
wasmtime --dir /host/data::/data hello.wasm   # /data inside Wasm maps to /host/data outside

# Set environment variables
wasmtime --env KEY=value hello.wasm

# Run a WASI component
wasmtime run --wasm component-model hello-component.wasm

# Limit memory (in pages, 1 page = 64KB)
wasmtime --max-wasm-stack 1048576 hello.wasm

Spin: A Framework for Wasm Microservices

Spin by Fermyon is the most developer-friendly framework for building server-side Wasm applications. It handles the HTTP trigger, routing, key-value storage, SQL databases, and outbound HTTP — all through a configuration file and component interface.

Install Spin

1
2
3
4
curl -fsSL https://developer.fermyon.com/downloads/install.sh | bash
sudo mv spin /usr/local/bin/

spin --version

Your First Spin App

1
2
3
4
5
6
7
8
9
# Create a new Rust HTTP app
spin new -t http-rust my-api
cd my-api

# Project structure:
# ├── spin.toml        ← App manifest
# ├── Cargo.toml
# └── src/
#     └── lib.rs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# spin.toml — the application manifest
spin_manifest_version = 2

[application]
name = "my-api"
version = "0.1.0"
description = "A Wasm microservice"

[[trigger.http]]
route = "/..."
component = "my-api"

[component.my-api]
source = "target/wasm32-wasip1/release/my_api.wasm"
allowed_outbound_hosts = ["https://api.github.com"]   # Explicit allowlist

[component.my-api.build]
command = "cargo build --target wasm32-wasip1 --release"
watch = ["src/**/*.rs", "Cargo.toml"]

A Real Multi-Route API

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// src/lib.rs — a more complete HTTP API
use anyhow::Result;
use serde::{Deserialize, Serialize};
use spin_sdk::{
    http::{IntoResponse, Method, Params, Request, Response, Router},
    http_component,
    key_value::Store,
};

#[derive(Serialize, Deserialize, Debug)]
struct Item {
    id: String,
    name: String,
    value: i64,
}

#[http_component]
fn handle(req: Request) -> Response {
    let mut router = Router::new();
    router.get("/items",          get_items);
    router.get("/items/:id",      get_item);
    router.post("/items",         create_item);
    router.delete("/items/:id",   delete_item);
    router.handle(req)
}

fn get_items(req: Request, _params: Params) -> Result<impl IntoResponse> {
    let store = Store::open_default()?;
    let keys = store.get_keys()?;
    let items: Vec<Item> = keys.iter()
        .filter_map(|k| {
            store.get(k).ok()?.map(|v| serde_json::from_slice(&v).ok()).flatten()
        })
        .collect();

    Ok(Response::builder()
        .status(200)
        .header("Content-Type", "application/json")
        .body(serde_json::to_vec(&items)?)
        .build())
}

fn get_item(_req: Request, params: Params) -> Result<impl IntoResponse> {
    let id = params.get("id").unwrap();
    let store = Store::open_default()?;

    match store.get(id)? {
        Some(bytes) => {
            let item: Item = serde_json::from_slice(&bytes)?;
            Ok(Response::builder()
                .status(200)
                .header("Content-Type", "application/json")
                .body(serde_json::to_vec(&item)?)
                .build())
        }
        None => Ok(Response::builder()
            .status(404)
            .body("Not found")
            .build()),
    }
}

fn create_item(req: Request, _params: Params) -> Result<impl IntoResponse> {
    let item: Item = serde_json::from_slice(req.body())?;
    let store = Store::open_default()?;
    store.set(&item.id, serde_json::to_vec(&item)?.as_slice())?;

    Ok(Response::builder()
        .status(201)
        .header("Content-Type", "application/json")
        .body(serde_json::to_vec(&item)?)
        .build())
}

fn delete_item(_req: Request, params: Params) -> Result<impl IntoResponse> {
    let id = params.get("id").unwrap();
    let store = Store::open_default()?;
    store.delete(id)?;
    Ok(Response::builder().status(204).build())
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Build and run locally
spin build
spin up

# Test
curl http://127.0.0.1:3000/items
curl -X POST http://127.0.0.1:3000/items \
  -H "Content-Type: application/json" \
  -d '{"id":"1","name":"Widget","value":42}'
curl http://127.0.0.1:3000/items/1

Outbound HTTP and Database Access

Spin controls outbound connections through its component config — a Wasm module can only reach hosts you explicitly allow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[component.my-api]
source = "target/wasm32-wasip1/release/my_api.wasm"
# Explicit allowlist — any unlisted host is blocked at the Wasm level
allowed_outbound_hosts = [
    "https://api.github.com",
    "https://api.stripe.com",
    "postgres://db.internal:5432",
]

[component.my-api.variables]
db_url = { required = true }
stripe_key = { required = true }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Outbound HTTP (only to allowed hosts)
use spin_sdk::http::send;

async fn fetch_github_user(username: &str) -> Result<String> {
    let response = send(
        Request::builder()
            .method(Method::Get)
            .uri(format!("https://api.github.com/users/{}", username))
            .header("User-Agent", "my-spin-app/1.0")
            .build()
    ).await?;
    Ok(String::from_utf8(response.into_body())?)
}

// PostgreSQL via Spin's pg interface
use spin_sdk::pg::{Connection, ParameterValue};

fn query_database(db_url: &str) -> Result<Vec<Item>> {
    let conn = Connection::open(db_url)?;
    let results = conn.execute(
        "SELECT id, name, value FROM items WHERE active = $1",
        &[ParameterValue::Boolean(true)],
    )?;
    // Map rows to structs...
}

Deploying to Fermyon Cloud

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Login to Fermyon Cloud (free tier available)
spin login

# Deploy
spin deploy

# Output:
# Uploading my-api version 0.1.0...
# Deploying...
# Waiting for application to become ready...
# Available Routes:
#   my-api: https://my-api-xyz.fermyon.app (wildcard)

Wasm in Kubernetes

Running Wasm workloads alongside container workloads in Kubernetes is production-ready. The approach: a containerd shim that speaks the container runtime interface (CRI) but runs Wasm modules instead of containers.

SpinKube: Spin on Kubernetes

SpinKube is the official project for running Spin apps on Kubernetes using containerd-wasm-shims.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Install the Spin containerd shim on your nodes
# (typically done via node bootstrap or DaemonSet)

# Add SpinKube operator
helm repo add spinkube https://spinkube.dev/helm-charts
helm repo update

helm upgrade --install spin-operator \
  --namespace spin-operator --create-namespace \
  --wait spinkube/spin-operator

# Install the SpinAppExecutor
kubectl apply -f https://github.com/spinkube/spin-operator/releases/latest/download/spin-operator.crds.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# SpinApp: deploy a Spin application to Kubernetes
apiVersion: core.spinkube.dev/v1alpha1
kind: SpinApp
metadata:
  name: my-api
  namespace: production
spec:
  image: ghcr.io/myorg/my-api:v0.1.0   # OCI image containing the .wasm
  replicas: 3
  executor: containerd-shim-spin        # Use the Spin shim
  resources:
    limits:
      cpu: 100m          # Tiny — Wasm is efficient
      memory: 64Mi
    requests:
      cpu: 10m
      memory: 16Mi
  variables:
    - name: db_url
      valueFrom:
        secretKeyRef:
          name: db-credentials
          key: url
1
2
3
4
5
6
7
8
9
# Build and push the OCI image
spin registry push ghcr.io/myorg/my-api:v0.1.0

# Apply the SpinApp
kubectl apply -f spinapp.yaml

# Check status
kubectl get spinapp -n production
kubectl get pods -n production   # Each pod runs a Wasm module, not a container

Wasm as Kubernetes Sidecar

A compelling pattern: run Wasm modules as sidecars for language-agnostic middleware — rate limiting, auth, request transformation — without the overhead of a full sidecar container:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# Pod with a Wasm sidecar for request transformation
apiVersion: v1
kind: Pod
metadata:
  name: app-with-wasm-sidecar
spec:
  runtimeClassName: wasmtime    # Use the Wasm runtime class

  initContainers:
    - name: load-wasm
      image: busybox
      command: [sh, -c, "cp /wasm/*.wasm /shared/"]
      volumeMounts:
        - name: wasm-modules
          mountPath: /wasm
        - name: shared
          mountPath: /shared

  containers:
    - name: app
      image: myapp:latest
      env:
        - name: SIDECAR_PORT
          value: "8081"

    - name: auth-sidecar
      image: scratch
      # The shim reads the wasm binary and runs it
      command: ["/shared/auth-middleware.wasm"]
      volumeMounts:
        - name: shared
          mountPath: /shared
      resources:
        limits:
          memory: 16Mi    # Wasm sidecars are extremely lightweight
          cpu: 10m

  volumes:
    - name: wasm-modules
      configMap:
        name: wasm-modules
    - name: shared
      emptyDir: {}

WasmEdge for Node-Level Execution

WasmEdge is a CNCF project providing a lightweight, high-performance Wasm runtime that integrates with containerd:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Install WasmEdge containerd shim on nodes
curl -sSf https://raw.githubusercontent.com/WasmEdge/WasmEdge/master/utils/install.sh | bash -s -- -e all

# Configure containerd to use the WasmEdge shim
cat >> /etc/containerd/config.toml <<'EOF'
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.wasmedge]
  runtime_type = "io.containerd.wasmedge.v1"
EOF

systemctl restart containerd

# Create a RuntimeClass
kubectl apply -f - <<EOF
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: wasmedge
handler: wasmedge
EOF

Wasm Component Composition

One of the most powerful features of the Component Model: combining components written in different languages:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Install wasm-tools
cargo install wasm-tools

# Compose: a Rust HTTP handler that calls a Python ML inference component
wasm-tools compose \
  -d python-inference.wasm \
  -o composed-app.wasm \
  rust-http-handler.wasm

# The composed component links at the Wasm level —
# no serialization, no network hop between components

This enables polyglot microservices without the overhead: your Python ML model and your Rust HTTP layer share linear memory directly, with calls compiled to direct function invocations.

Wasm for Plugins and Extensibility

Wasm is ideal for user-provided plugins that need isolation — the plugin can’t escape its sandbox:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Host: embed wasmtime to run untrusted plugin code
use wasmtime::{Engine, Linker, Module, Store};
use wasmtime_wasi::WasiCtxBuilder;

fn run_plugin(wasm_bytes: &[u8], input: &str) -> anyhow::Result<String> {
    let engine = Engine::default();
    let mut linker = Linker::new(&engine);
    wasmtime_wasi::add_to_linker_sync(&mut linker, |s| s)?;

    // The plugin gets NO filesystem access, NO network, NO environment
    let wasi = WasiCtxBuilder::new()
        .inherit_stdio()
        // Explicitly grant nothing else
        .build();

    let mut store = Store::new(&engine, wasi);
    let module = Module::from_binary(&engine, wasm_bytes)?;
    let instance = linker.instantiate(&mut store, &module)?;

    // Call the plugin's exported function
    let process = instance.get_typed_func::<(), ()>(&mut store, "process")?;
    process.call(&mut store, ())?;

    Ok("done".to_string())
}

Real-world uses of this pattern:

  • Envoy/Istio: Wasm filters for custom request processing
  • Kafka: Wasm transforms for stream processing (Redpanda supports this)
  • CDN edge functions: Fastly Compute, Cloudflare Workers both run Wasm
  • Database stored procedures: SingleStore, Redpanda use Wasm for extensibility
  • Game modding: safe user scripts in game engines

The Serverless Edge Case

The cold start problem is existential for serverless — Lambda’s 100ms–1s cold start makes it unusable for latency-sensitive applications. Wasm’s <1ms cold start changes the economics:

AWS Lambda (Node.js cold start): ~400ms
AWS Lambda (Go cold start): ~100ms
Cloudflare Workers (V8 isolate): ~5ms
Fastly Compute (Wasm): <1ms
Spin on Fermyon Cloud: <1ms

This isn’t a marginal improvement — it’s three orders of magnitude. Wasm serverless functions can handle requests that would be completely inappropriate for traditional serverless:

1
2
3
4
5
6
// This function starts in <1ms — no warm-up needed
// Could handle 100k req/s per instance with minimal resources
#[http_component]
fn handle(req: Request) -> Response {
    // Auth, rate limiting, transformation — all in <1ms cold start
}

Cloudflare runs ~30 million Wasm Workers globally. Fastly’s Compute platform processes billions of requests at the edge using Wasm. This isn’t experimental — it’s production internet infrastructure.

Current Limitations

Be clear-eyed about where Wasm on the server isn’t ready yet:

Threading: WASI threads (wasi-threads) is still experimental. Single-threaded Wasm is the norm. For CPU-bound parallel work, you spawn multiple instances rather than using threads.

Long-running processes: Wasm is excellent for request-response. For long-running background jobs with complex state, containers are still more natural.

Ecosystem completeness: not every library has a Wasm-compatible version. Anything requiring Linux-specific syscalls or FFI to native libraries won’t work without porting. The wasm32-wasip1 ecosystem is smaller than native.

Debugging: tooling is improving rapidly (wasmtime’s debugging support, DWARF info in Wasm), but it’s not as mature as native debugging.

Networking: WASI sockets (wasi-sockets) landed in Preview 2 but support varies by runtime. Raw socket programming is possible but not universal.

Quick Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Install toolchain
rustup target add wasm32-wasip1
curl https://wasmtime.dev/install.sh -sSf | bash
curl -fsSL https://developer.fermyon.com/downloads/install.sh | bash

# Compile Rust to Wasm
cargo build --target wasm32-wasip1 --release

# Run with wasmtime
wasmtime target/wasm32-wasip1/release/app.wasm
wasmtime --dir /tmp app.wasm              # Grant /tmp access
wasmtime --env KEY=val app.wasm           # Set env var

# Spin workflow
spin new -t http-rust my-app              # New app
spin build                                # Build
spin up                                   # Run locally
spin deploy                               # Deploy to Fermyon Cloud
spin registry push ghcr.io/org/app:v1    # Push OCI image

# Component model tools
cargo install wasm-tools
wasm-tools validate app.wasm             # Validate Wasm binary
wasm-tools print app.wasm               # Disassemble to WAT
wasm-tools compose -d dep.wasm -o out.wasm app.wasm  # Compose components

# Kubernetes (SpinKube)
kubectl apply -f spinapp.yaml
kubectl get spinapp
kubectl describe spinapp my-app

The trajectory is clear. Wasm is moving from browser curiosity to legitimate server-side compute primitive. It won’t replace containers for general-purpose workloads any time soon — the ecosystem gap is real. But for edge functions, serverless APIs, plugins, and security-sensitive sandboxing, Wasm already offers a better answer than anything else available. The engineers building on it now are the ones who’ll have the muscle memory when the ecosystem fully matures.

Comments