Date:

Share:

FAAS in Go with WASM, WASI and Rust

Related Articles

This post is best described as a tech demo; It merges together web servers, plugins, WebAssembly, Go, Rust and ABIs. Here is what it shows:

  • How to load WASM code with WASI in the Go environment and connect it to a web server.
  • How to implement web server plugins in any compileable language for WASM.
  • How to translate Go programs into WASM using WASI.
  • How to translate Rust programs to WASM that uses WASI.
  • How to write WAT (WebAssembly Text) code that uses WASI to interact with a non-JS environment.

We are going to build simple FAAS A server (Function as a Service) in Go that allows us to write modules In any language that has a WASM target. Compared to existing technologies, it is something between GCP’s Cloud functions, Cloud activation And the good old CGI.

to design

Let’s start with a high-level diagram describing how the system works:

The numbered steps in the diagram are:

  1. The FAAS server accepts HTTP GET request, with a path consisting of a module name (func in the example in the diagram) and an arbitrary query string.
  2. The FAAS server finds and loads the WASM module corresponding to the module name provided to it, and invokes it with a description of the HTTP request.
  3. The module emits output to its stdout, which is captured by the FAAS server.
  4. The FAAS server uses the module’s stdout as the content of the HTTP response to the request it received.

FAAS server

Let’s start our deep dive with the FAAS server itself (The full code is here). The HTTP handling part is simple:

func httpHandler(w http.ResponseWriter, req *http.Request) {
  parts := strings.Split(strings.Trim(req.URL.Path, "/"), "/")
  if len(parts) < 1 {
    http.Error(w, "want /{modulename} prefix", http.StatusBadRequest)
    return
  }
  mod := parts[0]
  log.Printf("module %v requested with query %v", mod, req.URL.Query())

  env := map[string]string{
    "http_path":   req.URL.Path,
    "http_method": req.Method,
    "http_host":   req.Host,
    "http_query":  req.URL.Query().Encode(),
    "remote_addr": req.RemoteAddr,
  }

  modpath := fmt.Sprintf("target/%v.wasm", mod)
  log.Printf("loading module %v", modpath)
  out, err := invokeWasmModule(mod, modpath, env)
  if err != nil {
    log.Printf("error loading module %v", modpath)
    http.Error(w, "unable to find module "+modpath, http.StatusNotFound)
    return
  }

  // The module's stdout is written into the response.
  fmt.Fprint(w, out)
}

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("/", httpHandler)
  log.Fatal(http.ListenAndServe(":8080", mux))
}

This server listens on port 8080 (feel free to change this or make it more configurable), and registers a catch-all handler for the root path. The handler parses the actual request URL to find the module name. It then stores information to pass to the module loaded in Env from here.

The loaded module is found with a file system search in target directory relative to the FAAS binary server. All of this is for demonstration purposes only and can be easily changed, of course. The therapist then reads
invokeWasmModule, which we will get to shortly. This function returns the stdout of the invoked module, which the handler prints to the HTTP response.

Running WASM code in Go

Given a WASM module, how do we invoke it programmatically in Go? There are some high quality WASM running times that work outside the browser environment, and many of them have Go bindings; for example wasmtime-go. The one I like best, however, is wazero; It’s a zero-dependency Go runtime that has no prerequisites other than running go get. Our FAAS server uses wazero Load and run WASM modules.

here invokeWasmModule:

// invokeWasmModule invokes the given WASM module (given as a file path),
// setting its env vars according to env. Returns the module's stdout.
func invokeWasmModule(modname string, wasmPath string, env map[string]string) (string, error) {
  ctx := context.Background()

  r := wazero.NewRuntime(ctx)
  defer r.Close(ctx)
  wasi_snapshot_preview1.MustInstantiate(ctx, r)

  // Instantiate the wasm runtime, setting up exported functions from the host
  // that the wasm module can use for logging purposes.
  _, err := r.NewHostModuleBuilder("env").
    NewFunctionBuilder().
    WithFunc(func(v uint32) {
      log.Printf("[%v]: %v", modname, v)
    }).
    Export("log_i32").
    NewFunctionBuilder().
    WithFunc(func(ctx context.Context, mod api.Module, ptr uint32, len uint32) {
      // Read the string from the module's exported memory.
      if bytes, ok := mod.Memory().Read(ptr, len); ok {
        log.Printf("[%v]: %v", modname, string(bytes))
      } else {
        log.Printf("[%v]: log_string: unable to read wasm memory", modname)
      }
    }).
    Export("log_string").
    Instantiate(ctx)
  if err != nil {
    return "", err
  }

  wasmObj, err := os.ReadFile(wasmPath)
  if err != nil {
    return "", err
  }

  // Set up stdout redirection and env vars for the module.
  var stdoutBuf bytes.Buffer
  config := wazero.NewModuleConfig().WithStdout(&stdoutBuf)

  for k, v := range env {
    config = config.WithEnv(k, v)
  }

  // Instantiate the module. This invokes the _start function by default.
  _, err = r.InstantiateWithConfig(ctx, wasmObj, config)
  if err != nil {
    return "", err
  }

  return stdoutBuf.String(), nil
}

Interesting things to note about this code:

  • wazero Supports WASI, which needs to be explicitly instantiated to be usable by the loaded modules.
  • Much of the code deals with exporting logging functions from the host (FAAS server Go code) to the WASM module.
  • We set the loaded module’s stdout to be directed to the repository, and set its environment variables to match Env Map moved in.

There are several ways for host code to interact with WASM modules using only the WASI API and ABI. Here, we choose to use environment variables for input and stdout for output, but there are other options (see the other resources
section at the bottom for some tips).

That’s it – the entire FAAS server, about 100 LOC of commented Go code. Now let’s move on to see how many WASM modules this thing can load and run.

Writing modules in Go

We can compile Go code to WASM that uses WASI. Here’s a basic Go program that emits a greeting and logs its environment variables to stdout:

package main

import (
  "fmt"
  "os"
)

func main() {
  fmt.Println("goenv environment:")

  for _, e := range os.Environ() {
    fmt.Println(" ", e)
  }
}

Until recently, the only way to compile Go code to WASM that worked outside the browser was using TinyGo compiler. In our FAAS project structure, the reference from the root directory is:

$ tinygo build -o target/goenv.wasm -target=wasi examples/goenv/goenv.go

Keen readers will remember that target/ The directory is exactly where the FAAS server looks *.wasm Files to load as modules. Now that we have placed a named module goenv.wasm There, we are ready to run our server with it go run in the root directory. We can issue an HTTP request to it
goenv Module in a separate terminal:

$ curl "localhost:8080/goenv?foo=bar&id=1234"
goenv environment:
  http_method=GET
  http_host=localhost:8080
  http_query=foo=bar&id=1234
  remote_addr=127.0.0.1:59268
  http_path=/goenv

And looking at the terminal where the FAAS server is running, we will see a log like:

2023/04/29 06:35:59 module goenv requested with query map[foo:[bar] id:[1234]]
2023/04/29 06:35:59 loading module target/goenv.wasm

As I mentioned before, this was the main way for Campbell to WASI Until recently. In the upcoming Go release (version 1.21), new support for the WASI target is included in the main Go toolchain (the gc compiler). It’s easy to try today either by building Go from source or using it gotip:

$ GOOS=wasip1 GOARCH=wasm gotip build -o target/goenv.wasm examples/goenv/goenv.go

(God wasip1 The target name refers to “WASI Preview 1”)

Writing modules in Rust

Rust is another language that has good support for WASM and WASI in the build system. After adding the wasm32-wasi purpose with rustingIt’s as simple as passing the destination name to it Charger:

$ cargo build --target wasm32-wasi --release

The code is simple, similar to the Go version:

use std::env;

fn main() {
    println!("rustenv environment:");

    for (key, value) in env::vars() {
        println!("  {key}: {value}");
    }
}

Writing modules in WebAssembly Text (WAT)

As we’ve seen, compiling Go and Rust code to WASM is pretty easy; Looking for a challenge, let’s write a module in WAT! As I’ve written before, I enjoy writing directly in WAT; It’s educational, and produces incredibly compact binaries.

The “educational” aspect quickly becomes apparent when you think about our mission. How exactly am I supposed to write to stdout or read environment variables using WASM? This is where WASI comes into play. WASI defines both an API and an ABI, both of which will be visible in our sample. Below are several code snippets with explanations; For the full code check out the Sample repository.

First, I want to show how to output to stdout; We start with the import of fd_write Reading the WASI system:

(import "wasi_snapshot_preview1" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))

Apparently, he has four i32 parameters and returns an i32; What this all means Unfortunately, WASI documentation can be a lot of work; The resources I have found useful are:

  1. Preview Specs 1 legacy
  2. C header descriptions of these functions

With that in hand, I was able to make a useful concoction println Equivalent in WAT that uses fd_write Under the hood:

;; println prints a string to stdout using WASI.
;; It takes the string's address and length as parameters.
(func $println (param $strptr i32) (param $len i32)
    ;; Print the string pointed to by $strptr first.
    ;;   fd=1
    ;;   data vector with the pointer and length
    (i32.store (global.get $datavec_addr) (local.get $strptr))
    (i32.store (global.get $datavec_len) (local.get $len))
    (call $fd_write
        (i32.const 1)
        (global.get $datavec_addr)
        (i32.const 1)
        (global.get $fdwrite_ret)
    )
    drop

    ;; Print out a newline.
    (i32.store (global.get $datavec_addr) (i32.const 850))
    (i32.store (global.get $datavec_len) (i32.const 1))
    (call $fd_write
        (i32.const 1)
        (global.get $datavec_addr)
        (i32.const 1)
        (global.get $fdwrite_ret)
    )
    drop
)

It uses some globals that you’ll need to search for in Full code sample
If you are interested. Here’s another helper function that prints a null-terminated string to stdout:

;; show_env emits a single env var pair to stdout. envptr points to it,
;; and it's 0-terminated.
(func $show_env (param $envptr i32)
    (local $i i32)
    (local.set $i (i32.const 0))

    ;; for i = 0; envptr[i] != 0; i++
    (loop $count_loop (block $break_count_loop
        (i32.eqz (i32.load8_u (i32.add (local.get $envptr) (local.get $i))))
        br_if $break_count_loop

        (local.set $i (i32.add (local.get $i) (i32.const 1)))
        br $count_loop
    ))

    (call $println (local.get $envptr) (local.get $i))
)

The fun part about writing assembly is that there are no simplifications. Everything is outside. You know how strings are usually represented using null termination (like in C) or a (start, len) Pair? In manual WAT code using WASI we have the pleasure of using both approaches in the same program 🙂

Finally, our main function:

(func $main (export "_start")
    (local $i i32)
    (local $num_of_envs i32)
    (local $next_env_ptr i32)

    (call $log_string (i32.const 750) (i32.const 19))

    ;; Find out the number of env vars.
    (call $environ_sizes_get (global.get $env_count) (global.get $env_len))
    drop

    ;; Get the env vars themselves into memory.
    (call $environ_get (global.get $env_ptrs) (global.get $env_buf))
    drop

    ;; Print out the preamble
    (call $println (i32.const 800) (i32.const 19))

    ;; for i = 0; i != *env_count; i++
    ;;   show env var i
    (local.set $num_of_envs (i32.load (global.get $env_count)))
    (local.set $i (i32.const 0))
    (loop $envvar_loop (block $break_envvar_loop
        (i32.eq (local.get $i) (local.get $num_of_envs))
        (br_if $break_envvar_loop)

        ;; next_env_ptr <- env_ptrs[i*4]
        (local.set
            $next_env_ptr
            (i32.load (i32.add  (global.get $env_ptrs)
                                (i32.mul (local.get $i) (i32.const 4)))))

        ;; print out this env var
        (call $show_env (local.get $next_env_ptr))

        (local.set $i (i32.add (local.get $i) (i32.const 1)))
        (br $envvar_loop)
    ))
)

We can now compile this WAT code into the FAAS module and restart the server:

$ wat2wasm examples/watenv.wat -o target/watenv.wasm
$ go run .

Let’s try this:

$ curl "localhost:8080/watenv?foo=bar&id=1234"
watenv environment:
http_host=localhost:8080
http_query=foo=bar&id=1234
remote_addr=127.0.0.1:43868
http_path=/watenv
http_method=GET

WASI: API and ABI

I mentioned you WASI API and ABI Earlier; Now is a good time to explain what that means. An API is a set of functions that programs using WASI have access to; You can think of it as a standard library of sorts. Go programmers have access to fmt the package and Println function within it. Programs targeting WASI have access to fd_write system call b
wasi_snapshow_preview1 module, and so on. The API of fd_write Also defines how this function takes parameters and what it returns. Our sample uses three WASI functions: fd_write, Environ_sizes_get and Environ_get.

ABI is a little less familiar to most programmers; It predicts the runtime between a program and its environment. The WASI ABI is currently unstable and is described here. In our program, the ABI is expressed in two ways:

  1. The main entry point we export is _start function. It is automatically read by a WASI-enabled host after configuration.
  2. Our WASM code exports its linear memory to a host with
    (Memory (Export "Memory") 1). Because the WASI APIs require passing pointers to memory, both the host and the WASM module need a shared understanding of how to access that memory.

Naturally, both the Go and Rust implementations of FAAS modules conform to the WASI API and ABI, but this is hidden by the compiler from programmers. In the Go program, for example, all we have to do is write a main function as usual and emit to stdout using Println. The Go compiler will exit correctly _start and Memory:

$ wasm-objdump -x target/goenv.wasm

... snip

Export[2]:
 - func[1028] <_rt0_wasm_wasip1> -> "_start"
 - memory[0] -> "memory"

... snip

and will properly connect things to call our code from it _startand so.

WASI and additives

The FAAS server shown in this post is definitely a development example
additives using WASM and WASI. This is a developing and exciting field of programming and great progress is being made on several fronts. Currently, WASI modules are limited to interacting with the environment through means such as environment variables and stdin/stdout; While this is fine for interacting with the outside world, for host-to-module communication it’s not amazing, in my experience. The WASM Standards Committee is therefore working on additional improvements to WASI that may include sockets and other means of transferring data between hosts and modules.

Meanwhile projects make do with what they have. For example, the
sqlc Go package Supports WASM plugins. Communication with plugins happens as follows: the host encodes a command into protobuf and outputs it to the plugin’s stdin; It then reads the plugin’s stdout for a protobuf encoded response.

Other projects take more crazy approaches; for example, The messenger proxy Supports WASM plugins by defining a custom API and ABI between the host and WASM modules. I will probably write more about this in a later post.

other resources

Here are some other resources on the same topic as this post:


Source

Popular Articles