Skip to content
Hogin Hogin
Go back

SpinKube: running WebAssembly workloads on Kubernetes without a custom runtime

9 мин чтения

A pod running an ordinary container spends anywhere from hundreds of milliseconds to several seconds on startup — image pull, process boot, application runtime warm-up. For edge fan-out, short HTTP handlers, and load spikes that overhead adds up on every HPA scale-out event. SpinKube takes a different approach: a WebAssembly module gets scheduled by the same kubectl apply as a regular pod, but starts as a WASM instance — no extra layer, no new API, no different deploy path. Here’s how it works at the containerd level, and why it’s not “containers, but faster” — it’s a tool for a specific workload profile.

Table of contents

Open Table of contents

Why this matters in 2026

Server-side WebAssembly spent several years as a niche topic — first proofs of concept, then niche edge platforms with their own closed runtime. That changed on two fronts at once. Fermyon Spin — a framework and CLI for building WASM microservices — now handles roughly 75M RPS at the edge after Akamai’s acquisition of Fermyon, which is not a toy benchmark, it’s production load (source). At the same time, SpinKube landed in the CNCF Sandbox, and WASM modules are now scheduled directly through a containerd shim — built into the same path Kubernetes already uses to schedule regular containers, not bolted on through a sidecar or an external controller.

That shifts the question from “is server-side WASM worth trying at all” to “what share of my services already fit this profile today.” The answer isn’t “all of them,” and the rest of this post is about drawing that line honestly.

Architecture: one kubelet, two execution paths

The core idea behind SpinKube is that WASM doesn’t replace the node’s container runtime — it sits alongside it at the containerd shim level. containerd already has the right abstraction for this: the shim v2 API, which decides what process actually executes a workload. The default path is containerd-shim-runc-v2, which runs a Linux process in a namespace from an OCI image. containerd-shim-spin (built on the runwasi library) is a second, parallel path: it takes a .wasm component and spins up a Wasmtime instance for it instead of an OS process.

One kubelet, two pod execution paths — runc and containerd-shim-spin

The choice of path isn’t made by a separate scheduler or an annotation — it’s the standard Kubernetes runtimeClassName field in the pod spec. A RuntimeClass with handler: spin tells kubelet to use the Spin shim instead of runc for that pod. Everything else in Kubernetes — Service, HPA, NetworkPolicy, readiness probes — keeps working unchanged, because to controllers it’s still an ordinary Pod with an ordinary Running status.

The SpinKube operator and the SpinApp CRD

Provisioning nodes by hand and tracking low-level shim details isn’t practical — that’s what the SpinKube operator is for: it handles node provisioning and turns a declarative Spin application description into ordinary Kubernetes objects.

How a SpinApp becomes a pod: SpinApp CR → spin-operator → Deployment/Service

Installation is three Helm releases: kwasm-operator installs the shim binary on nodes (enabled per-node via the kwasm.sh/node=true annotation, no node image rebuild required), cert-manager is the operator’s usual webhook dependency, and spin-operator itself:

helm install kwasm-operator kwasm-operator \
  --repo https://kwasm.sh/kwasm-operator/ \
  --namespace kwasm --create-namespace \
  --set kwasmOperator.installerImage=ghcr.io/spinkube/containerd-shim-spin/node-installer:v0.21.0

kubectl annotate node --all kwasm.sh/node=true

helm install cert-manager cert-manager \
  --repo https://charts.jetstack.io \
  --namespace cert-manager --create-namespace \
  --set crds.enabled=true

helm install spin-operator \
  --namespace spin-operator --create-namespace --wait \
  oci://ghcr.io/spinkube/charts/spin-operator

kubectl apply -f - <<'EOF'
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: wasmtime-spin-v2
handler: spin
EOF

From there, the app is built with the spin CLI, packaged as an OCI artifact, and described with the SpinApp CRD:

spin new -t http-rust hello-spin
cd hello-spin
spin build
spin registry push ttl.sh/hello-spin:1h
apiVersion: core.spinkube.dev/v1alpha1
kind: SpinApp
metadata:
  name: hello-spin
spec:
  image: "ttl.sh/hello-spin:1h"
  executor: containerd-shim-spin
  replicas: 3
  resources:
    limits:
      cpu: "100m"
      memory: "128Mi"
    requests:
      cpu: "50m"
      memory: "32Mi"

kubectl apply -f spinapp.yaml — and the operator reconciles this into a Deployment with runtimeClassName: wasmtime-spin-v2 plus a Service in front of it. For the rest of the cluster — kubectl get pods, kubectl logs, HPA on CPU or custom metrics — there’s no difference from an ordinary container-backed service.

The honest limits of WASI 0.2

It’s worth resisting the marketing framing of “WASM is just faster containers.” The component model and WASI 0.2 (Preview 2) that Spin is built on are a sandbox with a deliberately narrow syscall surface, not a drop-in replacement for a Linux process:

In other words: if a service is a wrapper around a blocking C driver or a long-lived stateful process, WASM on Kubernetes won’t help it and shouldn’t be treated as a replacement.

Where WASM actually wins

The workload profile where single-digit-millisecond cold starts and a lighter sandbox produce a measurable effect:

There’s also a practical property worth calling out: WASM pods and ordinary container-backed Deployments coexist fine in the same cluster, even the same namespace. RuntimeClass is a pod-level field, not a cluster or namespace property, so migration isn’t all-or-nothing — you can move exactly the services that fit the profile above to Spin and leave the rest on runc.

What you need to compare startup and memory in practice

The simplest way to see the difference is to deploy a SpinApp next to an ordinary Deployment serving the same HTTP response, and run both through identical load.

Cold start: a container takes hundreds of ms to seconds, a WASM instance takes single-digit ms

kubectl scale deployment hello-container --replicas=0
kubectl scale spinapp hello-spin --replicas=0

kubectl scale deployment hello-container --replicas=1
kubectl get pod -l app=hello-container -o wide --watch
# ContainerCreating -> Running: usually hundreds of milliseconds
# to seconds, depending on whether the image is already cached on the node

kubectl scale spinapp hello-spin --replicas=1
kubectl get pod -l core.spinkube.dev/app-name=hello-spin -o wide --watch
# ContainerCreating -> Running: single-digit milliseconds after the first request

Load test — hey or k6 against both services with identical parameters:

hey -z 30s -c 50 http://hello-container.default.svc.cluster.local/
hey -z 30s -c 50 http://hello-spin.default.svc.cluster.local/

kubectl top pod -l app=hello-container
kubectl top pod -l core.spinkube.dev/app-name=hello-spin

Comparison table

DimensionOrdinary container (runc)WASM app (SpinKube)
Cold starthundreds of ms to secondssingle-digit ms
Idle memory footprinttens to hundreds of MB per processsingle-digit to tens of MB per instance
Threads / blocking I/Ofull OS supportlimited by WASI 0.2, no full threading
Network accessarbitrary socket()capabilities declared in the app manifest
Compatibility with existing codeany Linux binaryonly what builds for wasm32-wasi
Scheduling in Kubernetesstandard pathsame kubectl apply, RuntimeClass
Coexistence in a clusteryes, alongside regular pods in the same namespace
Best-fit profilelong-lived stateful servicesshort-lived stateless handlers

How to verify it’s working

kubectl get spinapps
# NAME         READY   IMAGE
# hello-spin   True    ttl.sh/hello-spin:1h

kubectl get pods -l core.spinkube.dev/app-name=hello-spin
# NAME                          READY   STATUS    RESTARTS   AGE
# hello-spin-6d8f9c7b6f-x2k9p   1/1     Running   0          4s

kubectl describe pod -l core.spinkube.dev/app-name=hello-spin | grep "Runtime Class"
# Runtime Class Name:  wasmtime-spin-v2

curl -s http://hello-spin.default.svc.cluster.local/ -w '\n%{time_total}s\n'

If a pod stays in ContainerCreating for more than a couple of seconds, it almost always means the shim isn’t installed on the node (check the kwasm.sh/node annotation and the kwasm-operator logs) or the RuntimeClass wasn’t applied before the pod was created.

Bottom line

SpinKube doesn’t replace containers — it adds a second execution path next to the familiar runc one, reachable through the same runtimeClassName field, the same kubectl apply, the same HPA. The win is real and measurable for short-lived, stateless, I/O-light handlers: edge functions, routing, lightweight business logic — anywhere single-digit-millisecond cold starts and a smaller WASI sandbox attack surface outweigh Preview 2’s constraints. For heavy stateful backends, code with native dependencies, or services that need full threading, WASM isn’t an alternative yet — and shouldn’t be forced into being one. The right strategy for 2026 isn’t migrating a whole cluster — it’s picking the services that already fit this profile and running them alongside everything else with no extra component in the architecture.


Share this post:

Previous Post
SPIFFE/SPIRE: cryptographic workload identity instead of static secrets
Next Post
ValidatingAdmissionPolicy: moving admission control into the API server with CEL