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-wasip1 or wasm32-wasip2.
  • [ ] docker buildx or 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.

Where the Wasm shim slots in A pod names a runtime class, which maps to a containerd handler, which is a shim embedding a WebAssembly runtime. Everything above that layer — scheduling, secrets, networking, observability — is unchanged. pod spec runtimeClassName: wasm RuntimeClass handler: spin / wasmtime containerd picks the shim shim + runtime runs the module Scheduling, secrets, config maps, service discovery and log collection are unchanged — the substitution happens below everything Kubernetes users normally touch. What changes is what runs inside: a sandbox with declared capabilities rather than a Linux process with a namespace.

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.

Why operations teams notice the difference A container image carries a base filesystem and a runtime; a Wasm artifact carries only the module. The smaller pull and faster start change how quickly a node can take on new work. container workload image 180–900 MB pull + unpack: seconds to minutes process start: 100 ms–2 s full OS surface inside Wasm workload artifact 1–20 MB pull: well under a second start: single-digit milliseconds capabilities only, no OS surface The tradeoff is what fits: anything needing threads, subprocesses, raw sockets or a real filesystem is still a container.

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.

How the cluster runs a module A runtime class routes the pod to a shim that executes the module directly. Scheduling, networking and configuration stay exactly as they are for containers. pod spec runtimeClassName set containerd shim routes to the runtime Wasm runtime no OS image at all pod running a few megabytes The win is image size and start time; a module image is megabytes where a base image is hundreds. Not every node needs the shim — label the ones that have it and let the scheduler place accordingly. Anything the module needs from the host must come through WASI; there is no shell and no package manager.

Gotchas

  • Runtime class name versus handler name. They are separate; the class’s handler must match the containerd runtime key exactly.
  • Wrong build target. A wasm32-unknown-unknown module 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/wasm is not linux/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.

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