Welcome
Welcome to "Containers Are Dead - Long Live WebAssembly"!
In this course, we will explore the concept of containers and how they are being replaced by WebAssembly. We will cover the basics of WebAssembly, including its architecture and how it can be used to create lightweight, portable, and efficient applications. We will also discuss the benefits of using WebAssembly over traditional container technologies, such as Docker and Kubernetes.
We assume you are familiar with the basics of Rust but we will provide brief explanations and references whenever we rely on advanced features.
Methodology
This course is based on the "learn by doing" principle.
You'll build up your knowledge in small, manageable steps. It has been designed to be interactive and hands-on.
Mainmatter developed this course
to be delivered in a classroom setting, over a whole day: each attendee advances
through the lessons at their own pace, with an experienced instructor providing
guidance, answering questions and diving deeper into the topics as needed.
If you're interested in attending one of our training sessions, or if you'd like to
bring this course to your company, please get in touch.
You can also follow the course on your own, but we recommend you find a friend or
a mentor to help you along the way should you get stuck. You can
also find solutions to all exercises in the
solutions branch of the GitHub repository.
Prerequisites
To follow this course, you must install:
If you have nix installed on your system, you can use the nix flake provided in the courses Git repository to install the required tools and skip ahead to the next section. Otherwise continue with the installation instructions below.
If Rust is already installed on your machine, make sure to update it to the latest version:
# If you installed Rust using `rustup`, the recommended way,
# you can update to the latest stable toolchain with:
rustup update stable
These commands should successfully run on your machine:
cargo --version
Don't start the course until you have these tools installed and working.
Structure
On the left side of the screen, you can see that the course is divided into sections.
To verify your understanding, each section is paired with an exercise that you need to solve.
You can find the exercises in the
companion GitHub repository.
Before starting the course, make sure to clone the repository to your local machine:
# If you have an SSH key set up with GitHub
git clone git@github.com:mainmatter/containers-are-dead.git
# Otherwise, use the HTTPS URL:
#
# git clone https://github.com/mainmatter/containers-are-dead.git
We recommend you work on a branch, so you can easily track your progress and pull updates from the main repository if needed:
cd containers-are-dead
git checkout -b my-solutions
All exercises are located in the exercises folder.
Each exercise is structured as a Rust package.
The package contains the exercise itself, instructions on what to do (in src/lib.rs), and a test suite to
automatically verify your solution.
wr, the workshop runner
To verify your solutions, we've also provided a tool to guide you through the course: the wr CLI, short for "workshop runner".
Install wr by following the instructions on its website.
Once you have wr installed, open a new terminal and navigate to the top-level folder of the repository.
Run the wr command to start the course:
wr
wr will verify the solution to the current exercise.
Don't move on to the next section until you've solved the exercise for the current one.
We recommend committing your solutions to Git as you progress through the course, so you can easily track your progress and "restart" from a known point if needed.
Enjoy the course!
Author
TODO
Don't jump ahead!
Complete the exercise for the previous section before you start this one.
It's located in exercises/01_basics/00_welcome, in the course GitHub's repository.
Use wr to start the course and verify your solutions.
Exercise Structure
All exercises in this course follow the same structure:
- a WebAssembly application written in Rust, in the root of the exercise directory
- a
testsdirectory that tests the WebAssembly module, you will have to modify the Rust code of the application to make the tests pass.
Project Structure
Let's have a look at the application for this section.
01_setup
├── src
│ └── lib.rs
├── tests
└── Cargo.toml
Cargo.toml
The manifest file, Cargo.toml, looks like this:
[package]
name = "setup"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
You will notice the crate-type attribute standing out compared to a regular Rust project. It tells the Rust compiler what final artifacts to produce. "rlib" is the default setting for crate-type
and instructs the compiler to emit a special Rust-flavored static library that is meant to be understood and consumed by other Rust projects. "cdylib" on the other hand instructs the compiler to emit a dynamic library with a C-compatible interface (C dynamic library). This setting is required for Rust to emit WebAssembly (.wasm) files. To learn why, keep on reading!
WebAssembly Binary Format
WebAssembly is a simple, portable abstract machine and an executable format. It is a low-level enough to allow languages like C/C++ and Rust to run with near-native performance while providing the strong isolation and sandboxing that is required to run untrusted third-party code on the Web.
It has gained popularity outside the browser in recent years due to its ability to run on a variety of platforms and its ability to be embedded in a variety of contexts. It is used anywhere between, operating systems, font files, database files, and - most relevant for today - serverless cloud hosting providers.
WebAssembly operates as a stack-based virtual machine that executes bytecode instructions that manipulate an implicit operand stack - function parameters are pushed onto this stack, operations consume values from the stack, and results are pushed back. This stack is managed by the host and not visible directly to the guest code running in the sandbox.
The VM enforces strong isolation through its linear memory model: each WebAssembly instance has access only to its own contiguous, bounded memory space, with no ability to access host memory directly. All interactions with the host environment must go through explicitly declared imports and exports. This design enables WebAssembly to run untrusted code safely.
This also explains why we had to instruct Rust to emit a dynamic library: All WebAssembly code is loaded and linked dynamically by the host. All WebAssembly modules are shared libraries!
Key Concepts
- Module: Represents a WebAssembly binary that has been compiled by the browser into executable machine code. A module is stateless and explicitly declares imports and exports just like a Rust module/crate does.
- Memory: A resizable memory region that contains the linear array of bytes read and written by WebAssembly's low-level memory access instructions. Essentially a
Vec<u8>. - Table: A resizable typed array of references (e.g., to functions) that could not otherwise be stored as raw bytes in Memory (for safety and portability reasons).
- Global: A global value, either mutable or immutable. These are used as global variables by code or e.g. to communicate configuration options from the host to instances.
- Instance: A Module paired with all the state it uses at runtime including a Memory, Table, and set of imported values. This instance is stateful and is similar to a shared library loaded into memory.
Inspect the WebAssembly Module
Head to the exercise for this section and compile it to WebAssembly. This can be done by running cargo build --target wasm32-unknown-unknown --release. Note you will need to make some - trivial - changes to the Rust code to make it compile.
You can then find the compiled .wasm file in your target folder under target/wasm32-unknown-unknown/release/setup.wasm. Open this website in your browser: https://webassembly.github.io/wabt/demo/wasm2wat/ and drag your .wasm file into the "editor" section. Take a look at the disassembly! It should look something like this:
(module $setup.wasm
(type $t0 (func (result i32)))
(func $it_works (export "it_works") (type $t0) (result i32)
(i32.const 1))
(table $T0 1 1 funcref)
(memory $memory (export "memory") 16)
(global $__stack_pointer (mut i32) (i32.const 1048576))
(global $__data_end (export "__data_end") i32 (i32.const 1048576))
(global $__heap_base (export "__heap_base") i32 (i32.const 1048576)))
Let's break down the WebAssembly Text (WAT) format you're seeing. WAT is the human-readable representation of WebAssembly bytecode, using S-expressions (similar to Lisp syntax) to represent the module structure.
Here's what each section of our compiled module does:
(module $setup.wasm ...): The root container for our WebAssembly module(type $t0 (func (result i32))): Defines a function signature type that takes no parameters and returns a 32-bit integer(func $it_works (export "it_works") ...): Our exported function that the host can call, returning the constant value1(table $T0 1 1 funcref): A table for function references (required by Rust, even if unused)(memory $memory (export "memory") 16): Linear memory of 16 pages (1MB total) that the host can access too (because of(export "memory")).(global $__stack_pointer ...): Mutable global for Rust's stack management(global $__data_end ...)and(global $__heap_base ...): Exported globals marking memory layout boundaries for Rust's allocator.
You may have noticed that - unlike traditional assembly languages - WebAssembly is strongly typed. Every value has a specific type i32, i64, f32, f64, or reference types), all functions declare their signatures (in the example above $it_works has function type $t0 which resolves to (func (result i32)) - a function accepting no arguments and returning one i32).
You may also have noticed the $__stack_pointer global and asked yourself why it exists, I thought the WebAssembly stack was implicit and managed by the host??
Key Differences from Traditional Assembly
Unlike traditional assembly languages, WebAssembly is strongly typed. Every value has a specific type (i32, i64, f32, f64, or reference types). Every instruction is typed as well, for example there is a i64.add and an i32.add instruction, attempting pass anything but two i64 values to an i64.add instruction will result in an error. This is enforced by the WebAssembly runtime ahead of time, programs that don't pass the validation step will not even begin execution. This prevents many classes of bugs that are common in native assembly, e.g. x86 is happy to interpret your f64 as an i64 and do integer arithmetic with it.
The Stack Pointer Global
WebAssembly technically has 2 different stacks:
- The operand stack where instructions pop and pushed values.
- The call stack where function-local variables are stored.
Together they allow almost all programs to be compiled to WebAssembly, with one exception: Some languages allow you to take the address of a stack allocated variable. This can't work since the WebAssembly stack is managed by the host and not directly accessible from WebAssembly code.
#![allow(unused)] fn main() { let x = 42; // this wouldn't work! println!("{}", &raw const x); }
Languages that allow this, like Rust, maintain their own small stack in WebAssembly linear memory for the values that a program needs to take the value of. The __stack_pointer global tracks the current position in this software-managed stack.
Functions
WebAssembly functions can only pass and return basic numeric types: i32, i64, f32, and f64. 1
Function Exports in Rust
To export a Rust function so it can be called by the WebAssembly host, use extern "C":
#![allow(unused)] fn main() { #[no_mangle] pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b } }
The extern "C" calling convention ensures the function uses C-style ABI, which allows other languages to call it correctly when importing the Wasm module. The #[no_mangle] attribute prevents Rust from changing the function name during compilation, making it accessible under its original name. This is important since all imports and exports in WebAssembly are referenced by name.
Passing Complex Data
Since WebAssembly can only pass numbers directly, complex data like strings must be passed as pointers and lengths:
#![allow(unused)] fn main() { #[no_mangle] pub extern "C" fn process_string(ptr: *const u8, len: usize) -> i32 { let slice = unsafe { std::slice::from_raw_parts(ptr, len) }; let string = std::str::from_utf8(slice).unwrap(); // Process the string... string.len() as i32 } }
Callers write string data to WebAssembly linear memory and passes the memory address (ptr) and byte length (len) as function parameters.
Exercise
Over the course of this workshop we will be building a "calculator-as-a-service" backend - compiled to WebAssembly - that will parse and evaluate arithmetic expressions of the form 42 * 8 / 16.2. We will be building up this service in small steps throughout the exercises.
The exercise for this section requires you to build a parser that transforms an input &str into a sequence of Tokens. Our parser should skip over any whitespace characters it encounters and should be able to parse the following tokens:
#![allow(unused)] fn main() { #[derive(Debug, PartialEq)] pub enum Token { /// Numbers: 1, or 4.5, or 100000000. Must fit within an f64. Number(f64), /// The `+` symbol Plus, /// The `-` symbol Minus, /// The `*` symbol Multiply, /// The `/` divide symbol Divide, /// The `(` symbol LeftParen, /// The `)` symbol RightParen, } }
You can use the .chars() iterator to obtain an iterator over characters in a Rust string and use that to process the incoming string into tokens.
If you feel confident and adventurous feel free to come up with your own implementations, but each chapter will include a few code snippets at the end as a starting point or inspiration so you don't get stuck.
Hint 01
You can use the .peekable() iterator combinator and its .next_if() method to skip over whitespace characters in the input:
#![allow(unused)] fn main() { match ch { // skip over whitespace ' ' | '\t' => { while self.chars.next_if(|c| *c == ' ' || *c == '\t').is_some() {} return self.next(); } // ... other cases } }
Hint 02
You can also use the .peekable() iterator combinator and its .next_if() method to collect all number tokens
into a string and then use the f64::from_str implementation to parse it into an f64.
#![allow(unused)] fn main() { match ch { // skip over whitespace ' ' | '\t' => { while self.chars.next_if(|c| *c == ' ' || *c == '\t').is_some() {} return self.next(); } // parse potentially multi-character number tokens c if c.is_numeric() => { let mut str = c.to_string(); while let Some(ch) = self.chars.next_if(|c| c.is_numeric() || *c == '.') { str.push(ch); } Token::Number(f64::from_str(&str).unwrap()) } // other cases ... } }
As always, for a full solution check out the solutions branch of the GitHub repository.
-
Reference types also exist but are not supported across all WebAssembly runtimes just yet. ↩
Components
In the previous section, you experienced the friction of passing strings to WebAssembly functions - requiring manual pointer/length pairs and unsafe memory operations. This fundamental limitation exists on purpose to keep the core WebAssembly specification simple, portable and easy to implement.
On top of this core specification the WebAssembly Component Model introduces a rich type system that supports complex data structures like strings, records, variants, lists, and options. Components can define interfaces using WebAssembly Interface Types (WIT), enabling type-safe communication between WebAssembly modules and their hosts without manual memory management.
Where core WebAssembly modules export functions with basic numeric parameters, components export interfaces with high-level types that are automatically marshaled by the runtime. Below you can see an example of such an interface:
package wasi:random@0.2.7;
/// WASI Random is a random data API.
///
/// It is intended to be portable at least between Unix-family platforms and
/// Windows.
@since(version = 0.2.0)
interface random {
// other functions omitted for brevity...
/// Return a cryptographically-secure random or pseudo-random `u64` value.
@since(version = 0.2.0)
get-random-u64: func() -> u64;
}
The above interface definition is an excerpt of the real wasi:random/random interface that allows WebAssembly access to cryptographically secure random numbers from the host. Notice couple important pieces:
package wasi:random@0.2.7;declares the namespace (wasi) and name (random) of the current package, as well as the current version (0.2.7).interfacedenotes a collection of functions and associated types and defines behaviour shareable with the outside world. You can think of it as atraitin Rust.get-random-u64: func() -> u64;declares that - to conform to therandominterface - one must export a function calledget-random-u64that takes no arguments and returns a singleu64.@since(version = 0.2.0)is a feature gate that indicated the annotated function is stable since package version0.2.0. The component model has a first-class story for evolving and updating interfaces.
To find out what "wasi" is, keep on reading!
WebAssembly System Interface (WASI)
WebAssembly System Interface (WASI) defines a set of standard interfaces for common system operations like file I/O, networking, and random number generation. As of the writing of this workshop the interfaces provided by WASI are the following:
wasi:clocksReading the current time and measuring elapsed time.wasi:randomObtaining pseudo-random, and cryptographically secure data.wasi:filesystemAccessing host filesystems. Has functions for opening, reading, and writing files, and for working with directories.wasi:socketsAdds TCP & UDP sockets and domain name lookup.wasi:cliCommand-Line Interface (CLI) environment. Provides Stdio, Command-line arguments, and the concept of amainfunction.wasi:httpSending and receiving HTTP requests and responses.
Today Rust applications can easily access most these interface by simply compiling to the wasm32-wasip2 target. Rust standard library types such as std::time::SystemTime, std::net::TcpStream, or std::process::Command will automatically make use of the WASI interfaces.
Wasm Component in Rust
Prerequisites
The Rust ecosystem has strong support for building WebAssembly Components. In addition to the wasm32-wasip2 target, most functionality is provided by the cargo-component crate. In the following we will use it to build a WebAssembly component in Rust.
First install the tool from crates.io:
cargo install cargo-component
Note: If you use
nix,cargo-componentis already provided by the dev shell flake in this repo!
Project Structure
Now, looking at the exercise for this section you will notice a couple new things.
03_components
├── src
│ └── lib.rs
├── wit
│ └── world.wit
├── tests
└── Cargo.toml
Let's go over the difference, starting with Cargo.toml:
Cargo.toml
[package]
name = "components"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[package.metadata.component]
package = "workshop:example"
[package.metadata.component.target]
path = "wit"
[package.metadata.component.target.dependencies]
"wasi:random" = "0.2.7"
We have added a few extra sections to the Cargo.toml file.
- The
[package.metadata.component]section specifies namespace and name of package we're building. This is equivalentpackage wasi:random@0.2.7;syntax we've seen above. [package.metadata.component.target]tellscargo componentabout the WIT file in thewitdirectory. We'll go over this next.[package.metadata.component.target.dependencies]declares that this module depends on thewasi:randomWASI package. This is required forcargo componentto automatically build bindings to this import for us.
./wit/world.wit
world.wit is a WebAssembly Interface Types declaration that defines the interface our component is going to export. This is the same syntax you have seen above:
package workshop:example;
/// An example world for the component to target.
world example {
import wasi:random/random@0.2.7;
export add-random: func(num: u64) -> u64;
}
This file is quite a bit simpler though, we simply declare we will be importing the wasi:random/random interface and exporting a function called add-random that takes in a u64 and returns a u64.
Rust Bindings
We can use cargo component to automatically generate type-safe bindings to our imports. Even better it will generate a Rust trait that requires our component to be adhere to the interface we promised above. Let's have a look!
First generate the bindings using this command:
cargo component bindings
It has generates a bindings.rs file in your src folder. This file looks pretty daunting but here is the gist:
- bindings to all interfaces we imported are available as submodules. In our case that is
bindings::random::randomto access the random number interface. bindings::Guestis atraitthat we have to implement in order to be compliant with the interface we promised above.bindings::exportis a macro that lets us declare a Rust type implementingGuestas the implementation for this interface. You call it like sobindings::export!(<YourTypeName> with_types_in bindings)(substitute<YourTypeName>for the actually name of your type).
Exercise
The exercise for this section is quite simple again, you just need to provide an implementation for the Guest trait that uses the functions provided by wasi:random/random to add a random number to the provided one.
Components Cont'd
In the previous chapter we built a simple WebAssembly component that demonstrated basic interface definitions and WASI integration. We covered a lot of ground, but the end result wasn't terribly interesting. We'll now return to our calculator project and build something more substantial!
In this chapter we will expand our parser by adding an evaluator that evaluates the parsed mathematical expressions. The evaluator should respect standard operator precedence rules - parentheses first, then multiplication and division, followed by addition and subtraction - and return the computed result as an f64.
Project Structure
Notice how the project now consists of a main.rs file instead of a lib.rs. We're now building a CLI WebAssembly component!
By compiling to the wasm32-wasip2 target, Rust automatically implements the wasi:cli/run interface for us, declaring us compatible with the behavior for traditional CLI executables.
You can run the exercise directly using cargo run --target wasm32-wasip2.
Note: Usually you cannot simply
cargo runexecutables compiled towasm32-wasip2the same way you cannot simply run executables compiled forx86on anaarch64machine. For your convenience this workshop provides a runner built onwasmtimethat allows you to run these as if the were native executables.
TODO exercise hints
Outro
We've covered quite a lot of ground in this chapter. We have built core WebAssembly modules by compiling to wasm32-unknown-unknown and saw the challenges of implementing more complex applications using. We've built two WebAssembly Components and have seen the advantages it has over core WebAssembly.
Before moving on to HTTP and web servers in WebAssembly, let's stick with Components a tiny bit longer and talk about their one neat trick™️:
Component Composition
WebAssembly Components are designed to be composed together, combining multiple components into larger applications. Each component could be written in a different language, by a different team, or even a completely different organization and evolve independently over time; The components strongly typed interfaces will ensure that your application always remains correct.
We have seen such an example in the last section: We declared we need an, any implementation of the wasi:random/random interface regardless of how that is implemented or who implements it. It was the responsibility of the host (our test framework) to figure out how to provide an implementation of the interface.
The second example we have seen in this chapter: By defining an fn main() {} in our main.rs file and compiling to the wasm32-wasip2 target we automatically implemented the wasi:cli/run interface declaring ourselves to provide the behaviour of "a traditional CLI executable".
You can see the test verifier for this workshop depending on this exact interface here. The wasm32-wasip2 target conveniently took care of implementing this interface for us when we declared
This special flavor of loose coupling has one major advantage that e.g. microservices don't have: WebAssembly's Virtual Machine. Since WebAssembly always runs within a host environment, many responsibilities are shifted to the host runtime. WIT interfaces exported by components are transport agnostic, meaning a component you import might be statically linked into your executable, dynamically linked at runtime, or even provided over the network. All without a single change to your code.
Combined with core WebAssembly's typed, structured nature, host runtimes can dynamically optimize your application based on the "in-vivo" deployment situation.
We will see this in action in the coming chapter, so let's move on!
Server Side WebAssembly
We have seen WebAssembly as "shared libraries" and WebAssembly as standalone CLI applications. Now we turn to the primary focus of this workshop: WebAssembly in the server backend role.
WebAssembly has gained in popularity in recent years because unlike traditional container-based deployments, WebAssembly modules start in microseconds rather than seconds, making them ideal for functions that need to scale rapidly from zero. Additionally, their strong sandboxing and efficient resource utilization makes them a perfect fit for serverless and edge computing applications.
A number of big companies now provide support for Wasm, among them Cloudflare, Fastly, and Azure. Each of these hosting providers unfortunately exposes slightly different host APIs and capabilities. This ecosystem fragmentation was one of the original motivations for CloudABI, which then morphed into the WASI standards we have seen earlier!
For this workshop we will be using the spin framework built by Fermyon because it has a nice set of APIs. The things you will learn during this workshop should translate easily to other providers as well.
Spin Framework
spin is an open-source framework for building and deploying serverless WebAssembly applications.
For every incoming requests, the runtime will spin up a fresh instance of your Wasm component that is immediately terminated after processing the request. The runtime additionally exposes APIs for common tasks like making HTTP requests, routing, key-value storage, database access, and more. We will go through some these of these APIs in the following sections.
Prerequisites
To get started, install the spin CLI and the spin-test plugin for local development:
curl -fsSL https://spinframework.dev/downloads/install.sh | bash
spin plugin install -u https://github.com/spinframework/spin-test/releases/download/canary/spin-test.json
The spin-test plugin allows you to run HTTP tests against your Spin applications locally, wr will be using it to check your exercises.
Spin Configuration
Spin applications are configured through a spin.toml manifest file. This file specifies how HTTP requests are routed to WebAssembly components and how those components are built. The spin.toml file in this sections exercise looks like so:
spin_manifest_version = 2
[application]
name = "spin"
version = "1.0.0"
[[trigger.http]]
route = "/..."
component = "spin"
[component.spin]
source = "../../../target/wasm32-wasip1/release/spin.wasm"
[component.spin.build]
command = "cargo build --target wasm32-wasip1 --release"
watch = ["src/**/*.rs", "Cargo.toml"]
[component.spin.tool.spin-test]
source = "tests/target/wasm32-wasip1/release/tests.wasm"
The [application] section contains basic metadata. The [[trigger.http]] section defines HTTP routing - here /... catches all paths and forwards them to the specified component. The [component] section points to the compiled WebAssembly module and includes build instructions. The command field tells Spin how to compile your Rust code, while watch specifies which files should trigger rebuilds during development. The spin-test tool configuration enables local testing of your HTTP endpoints.
Making HTTP requests
When making outbound HTTP requests from a Spin component, you must explicitly grant this capability in the spin.toml manifest file. WebAssembly's security model requires explicit permission for components to access external resources, and Spin enforces this through capability declarations in the configuration.
Take a look at the spin.toml manifest for this exercise, you will notice it only adds one line:
spin_manifest_version = 2
[application]
name = "requests"
version = "1.0.0"
[[trigger.http]]
route = "/..."
component = "requests"
[component.requests]
source = "../../../target/wasm32-wasip1/release/requests.wasm"
+ allowed_outbound_hosts = ["http://www.randomnumberapi.com"]
[component.requests.build]
command = "cargo build --target wasm32-wasip1 --release"
watch = ["src/**/*.rs", "Cargo.toml"]
[component.requests.tool.spin-test]
source = "tests/target/wasm32-wasip1/release/tests.wasm"
allowed_outbound_hosts is spins way of declaratively request access to the specified URLs from the host.
Making Requests
Let's have a look at an example HTTP endpoint that in-turn makes an HTTP request to https://example.com:
#![allow(unused)] fn main() { use spin_sdk::http::{IntoResponse, Method, Request, Response}; use spin_sdk::http_component; #[http_component] pub async fn handler(_req: Request) -> anyhow::Result<impl IntoResponse> { let req = Request::builder() .method(Method::Get) .uri("https://example.com") .build(); // Ask the Wasm runtime to send off the request! let res: Response = spin_sdk::http::send(req).await?; // do something with the response... } }
You will notice the async keyword and use of await in the handler function; spin supports automatically yielding back control to the runtime while waiting for the network response. This quality of live feature helps your components to be more resource efficient.
Also note: making HTTP requests currently requires the use of the spin-specific HTTP module. This exemplifies the relatively early, in-progress nature of WebAssembly: In the absencse of standards different hosting providers built out custom APIs and capabilities. As you've seen earlier the WebAssembly System Interface (WASI) standards provides standardized interfaces that address this fragmentation but adoption across providers is still in progress.
HTTP Handlers
spin allows you to annotate a function with the #[http_component] attribute. This marks it as the
HTTP request entry point. This handler receives a Request object containing the HTTP request data and must return a Result with a type that implements IntoResponse (such as a Response or string). The async variant allows for non-blocking operations like making outbound HTTP requests or database calls, where the runtime can yield control while waiting for I/O operations to complete.
#![allow(unused)] fn main() { #[http_component] pub fn handler(req: Request) -> anyhow::Result<impl IntoResponse> { //... } // or as we've seen earlier: #[http_component] pub async fn handler(req: Request) -> anyhow::Result<impl IntoResponse> { //... } }
When spin receives a request matching the route pattern defined in spin.toml it will forward the request to the entry point set up above. In our case that pattern is /... which matches all paths:
[[trigger.http]]
route = "/..."
component = "handler"
Error Handling
When Rust code panics inside a WebAssembly component, it triggers a WebAssembly trap1. Unlike traditional server applications where this would crash the entire system, WebAssembly's sandbox means all we get is a rather ugly stack trace in the terminal.
Instead of relying on panics, HTTP endpoint should use "proper" error handling using Results. spin HTTP endpoints conveniently already require us to return anyhow::Result<impl IntoResponse>. This means we can propagate server-internal errors (errors that we want to show up as 5xx status code responses) simply using the ? operator.
Client errors i.e. errors we want to return in response to invalid user input still require us to build and return Responses ourselves.
- bring in the evaluator from previous chapter and hook it up to the HTTP handler
-
A trap is similar to a segfault. It cannot be caught and will cause your Wasm component to be terminated. ↩
Key Value Store
The ephemeral, serverless nature of WebAssembly HTTP components allows for excellent reasoning about a system: Each request starts with a clean slate. What do you do if you actually want to share state between requests though? You need external storage that persists beyond the component's lifecycle.
Most WebAssembly serverless runtimes offer a variety of choices. The first we will look at is the Key Value Store.
They Key Value Store maps string keys to bytes. It is similar to a Rust Hashmap or Browser LocalStorage.
Before you can access the store - similar to exercise 02/01 - we need to request access to the store from the runtime. This is done by adding the following line to our spin.toml manifest:
spin_manifest_version = 2
[application]
name = "key-value"
version = "1.0.0"
[[trigger.http]]
route = "/..."
component = "key-value"
[component.key-value]
source = "../../../target/wasm32-wasip1/debug/key_value.wasm"
+key_value_stores = ["default"]
[component.key-value.build]
command = "cargo build -p key_value --target wasm32-wasip1"
[component.key-value.tool.spin-test]
source = "../../../target/wasm32-wasip1/debug/key_value_tests.wasm"
This configuration gives the key-value component access to the "default" store (currently the only supported ID). We can then open this store in our Rust code by calling the Store::open_default() method:
#![allow(unused)] fn main() { let store = spin_sdk::key_value::Store::open_default()?; let val = self .store .get("foo") .expect("failed to load value from store") .expect("not such key in the store"); // do something with the value... }
and write a value back to the store like so:
#![allow(unused)] fn main() { let store = spin_sdk::key_value::Store::open_default()?; self .store .set("foo", "bar".as_bytes()) .expect("failed to store value in store"); }
JSON
The spin store also provides two convenience methods for storing any serde serializable data into the store as JSON: Store::get_json() and Store::set_json().
#![allow(unused)] fn main() { use serde::{Serialize, Deserialize}; #[derive(Debug, Serialize, Deserialize)] struct User { name: String, age: u8 } let store = spin_sdk::key_value::Store::open_default()?; // this presumably came from _somewhere_ let user_name = "john"; // load the user object from the store, it must be deserializable to a `User` struct let mut val = self .store .get_json::<User>(user_name) .expect("failed to load value from store") .expect("not such key in the store"); // update the age (its the users birthday!) val.age += 1; // write the user object back to the store self .store .set_json(user_name, val) .expect("failed to store value in store"); }
Databases
Key value stores are simple and easy to use, but they have two big disadvantages: no support for more complex data types and queries AND they are global. Meaning different users of your API could override each other's variables!
For "proper" persistent data storage spin also supports SQL databases like SQLite. To use SQL databases in Spin, you need to declare access in your spin.toml manifest and then use the bindings from the spin_sdk crate. In our case that is the spin_sdk::sqlite module.
Database Configuration
First, add database access to your spin.toml:
[component.databases]
source = "../../../target/wasm32-wasip1/debug/databases.wasm"
+sqlite_databases = ["default"]
Connecting to the Database
Once you've configured database access in your manifest, you can establish a connection using the Connection::open_default() method. This opens the SQLite database that Spin provides to your component:
#![allow(unused)] fn main() { use spin_sdk::sqlite::{Connection, Value}; let connection = Connection::open_default()?; // do something with the connection... }
Inserting Data
You can insert data into your database using the execute method that takes a SQL query string and an array of parameters. Parameters use ? placeholders in the query and are provided as Value enum variants to ensure type safety.
#![allow(unused)] fn main() { use spin_sdk::sqlite::{Connection, Value}; let connection = Connection::open_default()?; let execute_params = [ Value::Text("john".to_string()), Value::Integer(20) ]; // Insert a new user passing the parameters we set up let session_result = connection.execute( "INSERT INTO users (name, age) VALUES (?, ?)", &execute_params, )?; }
Retrieving Data
The execute method returns a QueryResult that you can iterate over to access query results (rows and columns). Each row provides typed access to column values through the get() method, which handles the conversion from SQLite's storage types to Rust types.
#![allow(unused)] fn main() { use spin_sdk::sqlite::{Connection, Value}; let connection = Connection::open_default()?; let rowset = connection.execute( "SELECT name, age FROM users WHERE age > ?", &[Value::Integer(18)], )?; for row in rowset.rows() { let name: String = row.get("name").unwrap(); let age: i64 = row.get("age").unwrap(); println!("User: {}, Age: {}", name, age); } }
Routing
goal: understand how in-component routing works, understand how composition works, understand how inter-component routing works
Routing Within A Component
So far we have only ever seen a single catch-all (/...) endpoint per component. But what if you want to have multiple endpoint? Spin's Router API allows you to delegate requests to different handler functions. Instead of manually parsing URLs and HTTP methods in your handler function, the router allows you to declaratively map routes to handler functions:
#![allow(unused)] fn main() { use spin_sdk::http::{IntoResponse, Params, Request, Response, Router}; use spin_sdk::http_component; #[http_component] fn handle_route(req: Request) -> Response { let mut router = Router::new(); router.get("/users/:id", get_user); router.post("/users", create_user); router.handle(req) } fn get_user(_req: Request, params: Params) -> anyhow::Result<impl IntoResponse> { let user_id = params.get("id").unwrap(); // look up the user, or something... } fn create_user(req: Request, _params: Params) -> anyhow::Result<impl IntoResponse> { // look up the user, or something... } }
The router supports path parameters (:id) that are extracted and passed to handlers via the Params argument. Each handler function receives the original request and extracted parameters, returning the same anyhow::Result<impl IntoResponse> type as the main component handler.
Routing between multiple Components
Spin applications can be composed of multiple WebAssembly components, each handling different routes. This is configured in the spin.toml manifest by defining multiple [[trigger.http]] sections, each mapping a route pattern to a specific component:
[application]
name = "multi-component-app"
version = "1.0.0"
[[trigger.http]]
route = "/api/users/..."
component = "user-service"
[[trigger.http]]
route = "/api/orders/..."
component = "order-service"
[component.user-service]
source = "target/wasm32-wasip1/release/users.wasm"
[component.order-service]
source = "target/wasm32-wasip1/release/orders.wasm"
When a request arrives, Spin's HTTP trigger examines the URL path and matches it against the configured routes in order of specificity. The request is then forwarded to the corresponding WebAssembly component.
This allows to to compose applications from modular components - recall the component composition from earlier - that can be developed, tested, and deployed independently.
- point to Rust + Go demo for inter-language composition exercise: expand expression evaluator to support assigning values to variables. Store the variables in the spin-sdk KV store.
Outro
To deploy your 04_databases example component to the cloud, first create a SQLite database using
spin cloud sqlite create command. You may choose any name for the database you like.
Next we need to create the tables we expected in the database, to do this we will manually execute the statements in the migration.sql file using the spin cloud sqlite execute command:
spin cloud sqlite execute --database <your database name> "CREATE TABLE IF NOT EXISTS sessions (session_id INTEGER PRIMARY KEY AUTOINCREMENT);"
spin cloud sqlite execute --database <your database name> "CREATE TABLE IF NOT EXISTS variables (session_id INTEGER, key TEXT NOT NULL, value REAL NOT NULL);"
Now - from the 02_http/04_databases exercise we can deploy the component using spin deploy. When prompted to hook up an existing database or create a new one select the one we just created above. spin deploy will upload to the Fermyon Cloud which requires GitHub authentication.
Once deployment completes, the CLI will provide a live URL where your WebAssembly application is running on the internet.
Advanced Topics
This chapter covers advanced techniques and production deployment-related techniques for WebAssembly applications.
There will be no more exercises for this chapter. You're encouraged to read through this chapter and try experiment with the techniques described. If you have time to spare, feel free to extend you calculator application: Test different storage backends or other spin host APIs (they have LLM capabilities too if thats interesting to you). Check out this chapters Outro for inspiration.
Observability
In WebAssembly serverless applications, more responsibility is shifted to the host runtime. This makes host-level observability even more essential for understanding application performance and debugging issues. After all, the runtime might be the bottleneck!
spin provides built-in support for OpenTelemetry (OTEL), an industry-standard observability framework that collects metrics, traces, and logs from distributed systems. This integration requires Docker to run the OTEL collector and visualization tools locally.
To enable observability, first install the OTEL plugin which provides integration with OpenTelemetry tooling:
spin plugins update
spin plugins install otel
Then set up the OTEL stack. This downloads the necessary Docker containers, including collectors, storage backends, and visualization tools like Jaeger for distributed tracing and Grafana for metrics dashboards:
spin otel setup
To start your application with observability enabled, run:
spin otel up
This command launches both your Spin application and automatically captures telemetry data from the Spin runtime.
Once your instrumented application is running, you can access the web interfaces for data visualization and analysis. Jaeger provides distributed tracing views to track request flows across components, Grafana offers customizable dashboards for metrics visualization, and Prometheus serves as the metrics storage and query interface:
spin otel open {jaeger,grafana,prometheus}
Optimizing
Optimizing for Size
Smaller modules load faster, improving cold start performance and in turn reducing the latency between request arrival and function execution - a critical factor for user-facing applications where every millisecond counts.
Competition! At the end of this workshop we will compare our final optimized database examples to see who got the smallest!
Let's start with our current databases example. A debug build produces a WebAssembly module of roughly 19 MB: clearly room for improvement.
We can drastically improve the size by compiling in release mode with optimizations, if we additionally add this config to our root Cargo.toml file we can usually squeeze some bytes more:
[profile.release]
codegen-units = 1 # don't parallelize compilation of crates, this can make optimizations more effective
lto = true # use "fat" link time optimization that operates on the entire dependency graph, again potentially making optimizations more effective
opt-level = "z" # instruct the compiler to aggressively optimize for small code size, sometimes "s" can be a better option here
strip = true # strip debug symbols from the release binary
We can instruct spin to build our component in release mode by specifying the --release flag and pointing it to use the release build like so:
spin_manifest_version = 2
[application]
name = "databases"
version = "1.0.0"
[[trigger.http]]
route = "/..."
component = "databases"
[component.databases]
-source = "../../../target/wasm32-wasip1/debug/databases.wasm"
+source = "../../../target/wasm32-wasip1/release/databases.wasm"
sqlite_databases = ["default"]
[component.databases.build]
-command = "cargo build -p databases --target wasm32-wasip1"
+command = "cargo build -p databases --target wasm32-wasip1 --release"
watch = ["src/**/*.rs", "Cargo.toml"]
[component.databases.tool.spin-test]
source = "../../../target/wasm32-wasip1/debug/databases_tests.wasm"
We get a release binary that comes in at 443 KB, but we can go further. Binaryen a WebAssembly optimizer and compiler toolchain provides a widely used tool called wasm-opt that can preprocess Wasm modules and components.
wasm-opt is a bit annoying to install, but you can download it from their GitHub releases here. Alternatively it appears to be in some package repositories either under the binaryen or wasm-opt name. Again if you use nix the flake in this repo already provides wasm-opt.
By running wasm-opt on our binary like so wasm-opt target/wasm32-wasip1/release/databases.wasm -O3 -o optimized.wasm (-O3 standards for all optimizations at their highest aggressiveness) we get the binary size down to 335 KB.
Optimizing for Performance
Size optimization is particularly important for serverless deployments, but nothing beats optimizing for speed. Unfortunately, there are not many WebAssembly specific solutions out there. Lack of profiling tooling has been identified as a major blocker; that unfortunately isn't resolved yet.
In spite of this, some tooling can be adapted to work with Wasm, for example criterion the Rust benchmarking framework already works inside WebAssembly today. Wasmtime supports profiling the entire runtime (including your WebAssembly code but also the runtimes code).
One interesting WebAssembly specific performance optimization tool exist that shouldn't go unmentioned: wizer is a so-called "preinitializer" for WebAssembly modules. It takes your module or component, runs its initialization function, and then snapshots the bytes of the initialized state into a new Wasm module. Depending on your exact application this weird sounding trick can actually meaningfully improve the startup.
Outro
In this workshop we built a calculator API that parses and evaluates arithmetic expressions and persists session state across requests using SQLite. In the process we have seen how WebAssembly can be used to built server side applications today, seen the benefits and experienced the rough edges.
If you want to continue playing with our "calculator-as-a-service" here are a few ideas:
- Add a queryable history feature that stores each calculation with timestamps, allowing users to retrieve their calculation history.
- Extend this history feature to allow users to share expressions.
- Add a simple frontend using HTML, JS, and CSS.
- Implement that frontend using a Rust+WebAssembly frontend framework!
Further Reading
JCOfor running components on the web: https://github.com/bytecodealliance/jco- Component Model Specification
- WebAssembly Blast Zones