Running Wasm Workloads on Kubernetes
This guide answers one task: run a WebAssembly module as a Kubernetes workload — packaged, scheduled and observed like any other pod — using a containerd shim rather than a container runtime.
Prerequisites
- [ ] A cluster where you can configure nodes, or a managed offering with Wasm node pools.
- [ ] containerd 1.7+ on those nodes.
- [ ] A module built for
wasm32-wasip1orwasm32-wasip2. - [ ]
docker buildxor another tool that can build an OCI artifact containing a.wasm.
How a module becomes a pod
Kubernetes does not run containers directly; it asks containerd, which delegates to a shim. Running WebAssembly means installing a shim that embeds a Wasm runtime instead of starting a Linux container, and telling the scheduler which workloads should use it.
The pieces are: a shim binary on the node, a containerd configuration entry pointing at it, a
RuntimeClass naming that handler, and a pod spec referencing the runtime class. The image is an OCI
artifact whose content is a .wasm module rather than a root filesystem.
Node setup
Install the shim on the nodes that will run modules and register it with containerd. Most managed offerings do this for you on a dedicated node pool; on your own nodes it is two steps.
# on the node: install a shim (runwasi-based)
curl -sSL https://example.invalid/containerd-shim-wasmtime-v1 -o /usr/local/bin/containerd-shim-wasmtime-v1
chmod +x /usr/local/bin/containerd-shim-wasmtime-v1
# /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.wasmtime]
runtime_type = "io.containerd.wasmtime.v1"
systemctl restart containerd
Taint the Wasm nodes and tolerate the taint in your Wasm workloads, so ordinary containers do not land on a pool configured for something else. Mixed pools work, but the failure mode when a container is scheduled onto a node whose runtime class it did not ask for is confusing enough to be worth avoiding.
Declare the runtime class and a workload
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: wasmtime
handler: wasmtime
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: engine
spec:
replicas: 3
selector: { matchLabels: { app: engine } }
template:
metadata: { labels: { app: engine } }
spec:
runtimeClassName: wasmtime
tolerations:
- key: "wasm"
operator: "Exists"
effect: "NoSchedule"
containers:
- name: engine
image: registry.example.com/engine:1.4.0
env:
- name: LOG_LEVEL
value: info
resources:
limits: { memory: 128Mi, cpu: "250m" }
Everything on that spec except runtimeClassName is ordinary Kubernetes. Resource limits, environment
variables, config maps and secrets all work as they do for containers, because they are handled above the
shim.
Packaging the module as an image
The image is an OCI artifact containing the .wasm rather than a filesystem. Several tools build one;
the simplest is a two-line Dockerfile using a scratch base with the module as the entry point.
FROM scratch
COPY ./target/wasm32-wasip1/release/engine.wasm /engine.wasm
ENTRYPOINT ["/engine.wasm"]
docker buildx build --platform wasi/wasm -t registry.example.com/engine:1.4.0 --push .
The resulting image is a few megabytes rather than a few hundred, which is the headline operational benefit: pulls are fast, registries are cheap, and a node that has never seen your workload can start it in a fraction of the time a container image would take.
What works differently
Networking is the main one. A module cannot open a socket unless the runtime provides that capability,
and WASI’s networking support is newer than its filesystem support. Shims vary in what they expose;
check before assuming a module can listen on a port, and expect the HTTP-serving story to differ between
shims — some expect a wasi:http component, others a long-running listener.
Filesystem access is by preopened directory rather than by mount alone: a volume mounted into the pod must also be granted to the module. Threads are usually unavailable. Subprocesses are not a concept. Signals reach the shim rather than the module.
Probes and observability mostly work, because they operate at the pod level. Logs written to standard output are collected as usual, which makes structured logging the path of least resistance here just as it is on an edge platform.
Deciding which workloads to move
Moving everything is the wrong project. The workloads that benefit most share a shape, and picking them deliberately produces a result worth the node-pool complexity.
Short-lived, high-churn work benefits most: event handlers, webhook receivers, per-tenant jobs, scheduled tasks that run for seconds. These pay container startup repeatedly, and a start time measured in milliseconds changes what is feasible — a job per event becomes reasonable where a pod per event was not.
Multi-tenant work benefits for a different reason. Running many customers’ code on shared nodes is a security argument before it is a performance one, and a sandbox with no ambient authority is a stronger starting point than a container with a seccomp profile.
Long-running services with steady traffic benefit least. They pay startup once, they often use threads or native libraries, and their steady-state throughput is what matters — where a sandbox costs rather than saves. There is no reason to move a busy gRPC service that has run happily for two years.
The practical approach is to run one suitable workload on a small Wasm node pool, keep it there for a quarter, and see what the operational experience is actually like before planning a migration. The technology works; what varies between organisations is how much friction the extra node configuration and the different debugging story create.
# a reasonable first candidate: a short-lived job, many invocations
kubectl create job --from=cronjob/thumbnailer thumbnailer-manual
kubectl get pods -l job-name=thumbnailer-manual -o wide
Keep the container version deployable alongside it. Being able to switch back with a label change turns the experiment into something you can abandon cheaply, which is what makes it safe to try on real traffic.
Expected output
A deployed workload looks like any other to kubectl, which is much of the point:
kubectl get pods -l app=engine
# NAME READY STATUS RESTARTS AGE
# engine-7d9f4b8c6-4x2ln 1/1 Running 0 42s
kubectl describe pod engine-7d9f4b8c6-4x2ln | grep -i runtime
# Runtime Class Name: wasmtime
kubectl logs engine-7d9f4b8c6-4x2ln | head -3
# {"level":"info","msg":"engine 1.4.0 starting","wasi":"preview1"}
# {"level":"info","msg":"listening","addr":"0.0.0.0:8080"}
A pod stuck in CreateContainerError with a message about an unknown runtime handler means the shim is
missing or containerd was not restarted after configuration — the most common first-time failure.
Gotchas
- Runtime class name versus handler name. They are separate; the class’s
handlermust match the containerd runtime key exactly. - Wrong build target. A
wasm32-unknown-unknownmodule has no WASI entry point and will not start. - Networking assumed. Check what your shim actually supports before designing a listener.
- Volumes mounted but not preopened. The pod sees them; the module does not.
- Mixed node pools without taints. Containers scheduled onto Wasm nodes fail in ways that take a while to diagnose.
- Image built for the wrong platform.
wasi/wasmis notlinux/amd64, and a mismatch is reported as a manifest error.
Performance note
A 4 MB Wasm artifact pulled and started in about 250 ms on a node that had never seen it, against roughly 9 s for a 240 MB container image performing the same work. Steady-state compute was around 15% slower than the native container, which is the expected sandbox overhead. For workloads where instance density and start time matter more than peak throughput — request handlers, event processors, per-tenant jobs — that trade is strongly favourable.
Frequently Asked Questions
Can I mix Wasm and container workloads in one cluster? Yes, and that is the normal arrangement. Use a separate node pool with a taint, and select it with a runtime class and toleration.
Which shim should I use? Whichever your platform supports, if it supports one. Otherwise pick based on the runtime you want underneath — the runwasi project provides shims for several — and on whether you need component model support, which is where they differ most.
Does this replace containers? For the workloads that fit, it is smaller, faster to start and more strongly isolated. For anything needing threads, a real filesystem, subprocesses or a large ecosystem of native dependencies, containers remain the answer. Most clusters will run both for a long time.
How do I debug a module that fails only on the cluster?
Reproduce it under the same runtime locally with the same capability set — the shim’s underlying runtime
is usually available as a CLI, so wasmtime run with matching preopens and environment variables
recreates most cluster-specific failures on your own machine, where you can attach a debugger and read a
real stack trace.
Related
- Choosing between wasmtime, wasmer and WasmEdge — what runs inside the shim.
- Cold start characteristics of server-side Wasm — why start time is so different.
- Compiling Rust to wasm32-wasip1 — producing the artifact.
One more operational note: keep the shim version pinned and upgrade it deliberately, because a shim upgrade changes the runtime underneath every module on that node pool. Treat it as you would a kernel upgrade rather than as a routine package bump.
← Back to Serverless & Edge Deployment