Interest in WebAssembly is rising, but not where you might expect it. We’ve recently seen Adobe using WebAssembly to bring Photoshop to the web, and an experimental port of LibreOffice too. However, for most web developers WebAssembly simply lacks relevance, it doesn’t solve many of their current problems. A recent study by HTTP Archive found that only 0.06% of sites use the technology. Does this mean WebAssembly is doomed? Far from it!
Whilst WebAssembly will likely be a (useful) niche player within the browser, where it is really starting to shine is as a standalone runtime environment. It’s being introduced to existing projects and products such as Kubernetes, Istio and Red Panda, oh yes … and Microsoft’s Flight Simulator! It’s also heralding a new breed of products with WebAssembly at their core, e.g. Cosmonic, Fermyon, Internet Computer and NEAR to name but a few.
I’ve written a range of WebAssembly applications using Rust, AssemblyScript and a bit of hand-coding, but haven’t worked directly with a WebAssembly runtime before. I thought it would be fun to create a simple WebAssembly-powered serverless platform, to see what it is like to create a ‘product’ based on WebAssembly.
Turns out, it is really quite easy 😀
Embedding a WebAssembly runtime
The first step is to find a suitable WebAssembly runtime. Probably the most well-known and mature are:
- WasmEdge - initially created by Second State, and now lives within the Cloud Native Computing Foundation (CNCF)
- Wasmer - a startup, whose runtime has a large number of language integrations.
- Wasmtime - originally a Mozilla project, using Cranelift - the Wasm JIT compiler used by the browser.
- Wasm3 - an interpreter, known for its speed and small footprint, making it ideal for embedded systems / IoT.
And if you don’t like any of these options, there’s at least another 20 to choose from.
I opted for Wasmtime due to its solid support for WASI (which we’ll encounter later).
Wasmtime, in common with most WebAssemby runtimes, has multiple language embeddings, meaning that you can use it to embed WebAssembly within various languages (e.g. C#, Rust, JavaScript). However, as Wasmtime itself is written in Rust, I opted to use Rust for my servless platform.
Let’s take a look at how Wastime works.
The following is a very simple WebAssembly module, with a single exported function named run
, which returns the integer 42
on execution:
(module
(func $run (result i32)
i32.const 42)
(export "run" (func $run))
)
Most people don’t hand-craft WebAssembly, instead they write applications in high-level languages like Rust, Go or C#, which are then compiled into WebAssembly. However, for simplicity, we’ll go with a very simple hand-crafted module.
The following Rust application uses Wasmtime to load the module, locate the exported run
function then executes it:
use anyhow::Result;
use wasmtime::*;
fn main() -> Result<()> {
// create a wasmtime engine
let engine = Engine::default();
// load the module
let module = Module::from_file(&engine, "hello.wat")?;
// create a store and module instance
let mut store = Store::new(&engine, ());
let instance = Instance::new(&mut store, &module, &[])?;
// locate the exported function
let exported_run = instance.get_typed_func::<(), i32, _>(&mut store, "run")?;
// call and print the result
let res = exported_run.call(&mut store, ())?;
println!("WebAssembly says - {}", res);
Ok(())
}
Building and running this app yields the expected response of 42
:
% cargo run
Compiling hello_wasm v0.1.0
Finished dev [unoptimized + debuginfo] target(s) in 1.34s
Running `target/debug/hello_wasm`
WebAssembly says 42
Let’s take a step back and consider what is happening here …
Through Wasmtime, we’ve integrated a WebAssembly runtime into our applicatiom. This runtime is fast, giving near native execution speed, secure, with the code running in a sandboxed environment, and multi-language - in this example we hand-crafted a simple module directly, however, the above few lines of code will happily run WebAssembly modules written in C#, Go, Rust and a whole host of other languages.
Creating a serverless platform
Now that we can execute a WebAssembly module within our own code, let’s take a look at how to turn this into a serverless platform. At a high-level we’re going to need a HTTP server, and a way to route HTTP requests to WebAssembly modules.
For the HTTP server, I’ve opted for the popular actix-web project. Let’s look at how to set that up …
#[actix_web::main]
async fn main() -> io::Result<()> {
HttpServer::new(|| { App::new().service(handler) })
.bind("127.0.0.1:8080")?
.run()
.await
}
#[get("/{module_name}")]
async fn handler(module_name: Path<String>) -> impl Responder {
let wasm_module = format!("{}{}", module_name, ".wasm");
let val = invoke_wasm_module(wasm_module).expect("");
HttpResponse::Ok().body(val)
}
fn invoke_wasm_module(module_name: String)
-> result::Result<String, wasmtime_wasi::Error> {
let engine = Engine::default();
let module = Module::from_file(&engine, module_name)?;
let mut store = Store::new(&engine, ());
let instance = Instance::new(&mut store, &module, &[])?;
let exported_run = instance.get_typed_func::<(), i32, _>(&mut store, "run")?;
let res = exported_run.call(&mut store, ())?;
Ok(res.to_string())
}
In the above code, the main
function creates an Actix Web HTTP, listening to 127.0.0.1:8080
, with requests routed to the route
function. This function uses the path to determine the name of the WebAssembly module to load, then the invoke_wasm_module
function is the code we saw earlier for loading and executing modules.
Super simple, we can now create different modules that return different numbers, executing them remotely via HTTP requests. We have a very basic serverless platform. However, it is currently a little limited, there’s not that much you can do with a serverless platform that only allows you to return integers!
Returning strings
It would be more useful if our serverless functions (implemented as WebAssembly modules) could return strings rather than single integers. But here is where we encounter a bit of a challenge.
The I/O capabilities of WebAssembly are quite limited, you can export and import function (from the host environment), but the only types you can pass are integers. You can also share memory between the host and the WebAssembly runtime, and using this approach it is possible to (indirectly) return strings. A function can write a string to linear memory, then return an integer value pointing to the string’s location. The host can them read the string from this memory location.
This sounds simple, but it does add to the overall complexity. Additional ‘glue’ logic is required both within the WebAssembly module to encode the string at a suitable memory location, further glue logic is required on the host-side to decode. There are language-specific solutions to this problem, for example wasm-bindgen handles the creation of this ‘glue’ code (for a wide range of types) for Rust applications compiled to WebAssembly. However, for this serverless platform, we want a language-agnostic approach, allowing serverless functions implemented in a wide range of languages.
For this we need standards! Fortunately, these exist.
WebAssembly System Interface (WASI) is a standard system interface for WebAssembly, defining a common set of APIs which are implemented by WebAssembly runtimes.
Let’s take a look at how this works in practice.
One of the interfaces defined by WASI is standard input (stdin) / output (stdout), we’ll use stdout as a convenient way for our serverless functions to return strings.
To demonstrate this we need WebAssembly module that makes use of WASI. Here is a simple application, written in AssemblyScript that uses the WASI APIs:
import "wasi";
import { Console } from "as-wasi";
Console.write("Hello World!");
The above makes use of the as-wasi
module, which is the AssemblyScript implementation of WASI - it includes all the ‘glue’ code required to implement these APIs.
Our serverless platform also needs updating to make use of WASI on the ‘host’ side.
fn invoke_wasm_module(module_name: String)
-> result::Result<String, wasmtime_wasi::Error> {
let engine = Engine::default();
let mut linker = Linker::new(&engine);
wasmtime_wasi::add_to_linker(&mut linker, |s| s)?;
// create a buffer to store the response
let stdout_buf: Vec<u8> = vec![];
let stdout_mutex = Arc::new(RwLock::new(stdout_buf));
let stdout = WritePipe::from_shared(stdout_mutex.clone());
let wasi = WasiCtxBuilder::new()
.stdout(Box::new(stdout))
.build();
let mut store = Store::new(&engine, wasi);
let module = Module::from_file(&engine, &module_name)?;
linker.module(&mut store, &module_name, &module)?;
// create a module instance via the linker
let instance = linker.instantiate(&mut store, &module)?;
let instance_main = instance.get_typed_func::<(), (), _>(&mut store, "_start")?;
instance_main.call(&mut store, ())?;
// read the response into a string
let mut buffer: Vec<u8> = Vec::new();
stdout_mutex.read().unwrap().iter().for_each(|i| {
buffer.push(*i)
});
let s = String::from_utf8(buffer)?;
Ok(s)
}
This is a little more complicated than before - but only marginally.
The first difference is that we’re making use of the Wasmtime Linker
, as the name suggests, this handles linking the function calls between multiple WebAssembly modules. Its role will become apparent shortly.
There is some additional code to create a buffer for capturing the response, and later converting it into a string (there’s probably a simpler way to achieve this, but my Rust skills are really quite modest).
The most interesting part of the code is the WasiCtxBuilder
, which is used to create a WASI ‘context’. The WASI interface is modular, in this instance we’re only interested in the stdout functionality, and here we use the builder to create a context with just that. This is an important security feature, we could add a simple permissioning system whereby serverless functions declare their required capabilities, and at runtime, ensure only those are provided via this context.
Finally, the module instance is created via the linker, and the _start
function invoked.
To understand what is going on here, it helps to take a close look at the WebAssembly module the above AssemblyScript application creates. If you look at the compiled module in its text format (using wasm2wat
from the wasm binary toolkit), you will find that it imports a few functions:
(import "wasi_snapshot_preview1" "fd_write" (func (;0;) (type 8)))
(import "wasi_snapshot_preview1" "proc_exit" (func (;1;) (type 3)))
(import "wasi_snapshot_preview1" "environ_sizes_get" (func (;2;) (type 2)))
(import "wasi_snapshot_preview1" "environ_get" (func (;3;) (type 2)))
These are part of the WASI specification. The linker is responsible for ‘linking’ these to the implementations provided by the WASI context.
Notably, this is how the Wasmtime runtime works. Others my differ, there is no requirement to have a linker, as long as those imports are provided by the host, the runtime is considered compliant. You could event create the implementations yourself.
With the platform code updated, and the WASI compliant module deployed, we can returns strings 🎉
Passing parameters
The final piece of the puzzle is to find a way to pass data to our serverless functions. Once again, WASI provides some convenient APIs we can use.
The WASI specification provides APIs for supplying environment variables. In order to demonstrate, let’s create a more interesting serverless function, this time in Rust (demonstrating that the platform is indeed multi-language):
use sudoku::Sudoku;
fn main() {
let puzzle_line = std::env::var("puzzle").unwrap();
let sudoku = Sudoku::from_str_line(&puzzle_line).unwrap();
if let Some(solution) = sudoku.solve_unique() {
let str = solution.to_str_line().to_string();
for i in 0..9 {
println!("{}", str[(i*9)..(i*9+9)].to_string());
}
} else {
println!("failed to solve");
}
}
The above Rust application uses the Sudoku crate to solve a sudoku grid, with the unsolved grid provided via the puzzle
environment variable.
With Rust, we have to specify the target in order to create a WebAssembly module rather than a native binary. The above code is built as follows:
% cargo build --target wasm32-wasi
The above serverless function, which takes a Sudoku puzzle via an environment variable and returns the result via stdout, has now been compiled to a WASI-compliant WebAssembly module, ready for deployment to our serverless platform.
The final step is for the platform itself to provide the required environment variables. We’ll use query string parameters as a simple mechanism for supplying these.
Actix web has the concept of Extractors, here we update the handler to extract the query string into a HashMap, sending this to our function that invokes the requested wasm module.
#[get("/{module_name}")]
async fn handler(module_name: Path<String>, query: Query<HashMap<String, String>>)
-> impl Responder {
let wasm_module = format!("{}{}", module_name, ".wasm");
let val = invoke_wasm_module(wasm_module, query.into_inner())
.expect("invocation error");
HttpResponse::Ok().body(val)
}
The code that instantiates and invokes the module via Wasmtime only needs a few small additions to the part that builds the WASI context, to provide the HashMap from above as the environment variables:
// convert params hashmap to an array
let envs: Vec<(String,String)> = params.iter().map(|(key, value)| {
(key.clone(), value.clone())
}).collect();
let wasi = WasiCtxBuilder::new()
.stdout(Box::new(stdout))
.envs(&envs)?
.build();
And that’s it, we now have a serverless platform that can invoke functions (compiled to WebAssembly modules), providing parameters via the querystring, with string-based responses:
Wrap-up
So there we have it, a fully functional bare-bones serverless platform, that supports multiple-languages, in just 70 lines of Rust. It’s also lightweight (the platform binary is 12MBytes) secure, and fast. Just how fast?
On my machine, the Hello World function written in AssemblyScript takes just 19ms to execute:
% curl -o /dev/null -s -w %{time_total}\\n http://localhost:8080/hello-world
0.019504
Although, 13ms of that time is spent within Actix Web itself, Wasmtime accounts for just 6ms.
Hopefully this blog post might help you understand why WebAssembly is creating a lot of buzz and excitement at the moment. It really is a fantastic runtime environment, the multi-language support and especially the security model make it an ideal technique for allowing un-trusted code into your environment.
If you want to play with the code I shared in this blog, you can find it on GitHub. You can also discuss this post on the Reddit thread.
Finally, if this overall approach has piqued your interest, I’d very much recommend taking a look at WAGI. It uses the same techniques (enviromnent variables and stdout), but is a robust and production-ready application.