Building  your first FAAS with Wasm

Building your first FAAS with Wasm

Welcome back to Wasm ecosystem ! Today we're going to build a FAAS (Function as a service) using our reverse proxy Otoroshi and the capabilities of WebAssembly.

Function as a service (FaaS) is a cloud-computing service that allows customers to run code in response to events, without managing the complex infrastructure typically associated with building and launching microservices applications.

Many FAAS platforms were already in existence, and their numbers have only increased, particularly with the rise of WebAssembly (Wasm) within cloud service providers.

Over the past few years and with the advent of WebAssembly, FAAS providers leveraged Wasm to allow users to run any type of code. This shift isn't solely due to the flexibility Wasm offers but also because of its robust security features. By operating within a sandboxed environment, Wasm fundamentally transforms the landscape for providers. They can execute user code with enhanced security measures, ensuring airtight protection against vulnerabilities, while also finely controlling user capabilities.

So for this article and our FAAS, let's create a Geolocation service with a straightforward objective: fetching addresses from the French Government Geolocation service using WebAssembly (Wasm) and storing data in Otoroshi for caching.

We will proceed by :

  • Creating an Otoroshi Route with /search designated as the frontend URL

  • Incorporating a Wasm backend plugin designed specifically for execution solely upon /search requests

  • Developing our Wasm binary via Wasmo, incorporating a call to the French Government Geolocation service for address retrieval

  • Employing Host Functions facilitated by Otoroshi to cache information at runtime within the Wasm binaries

The outcome will appear as depicted in the following schema.

Our Geolocation service using Go

Let's start by writing our module using Go

go mod init faas

Creating package and importing all needed packages

package main 

import (
    "io"
    "log"
    "net/http"
)

We can now create the global addresses cache

var addressesCache = make(map[string]string)

And start writing the function to fetch and retrieve information from the geolocation service.

func GetAddress(search string) (string, error) {
    log.Println("call geolocation service")

    resp, err := http.Get("https://api-adresse.data.gouv.fr/search/?q=" + search)
    if err != nil {
        return "", err
    }

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", err
    }

    return string(body), nil
}

The implementation is quite simple: we only need to invoke the https://api-adresse.data.gouv.fr/search/?q= endpoint with the address we intend to search for.

Now that the function for accessing this endpoint is ready, our next step involves incorporating a cache lookup mechanism. Initially, we'll check the cache for the requested address data. Should the data not be present in the cache, we'll proceed to invoke the GetAddress function to retrieve it, subsequently storing the obtained result in our cache.

func GetCompleteAddress(search string) (string, error) {
    if cachedAddress, found := addressesCache[search]; found {
        log.Println("using cache")
        return cachedAddress, nil
    } else {
        address, err := GetAddress(search)

        if err != nil {
            return "", err
        }

        addressesCache[search] = address

        return address, nil
    }
}

Let's add the main function that call our GetCompleteAddress and run it by executing go run main.go

func main() {
    log.Println(GetCompleteAddress("200+avenue+Salvador+Allende+79000"))
}
// go run main.go
{
    "label": "200 Avenue Salvador Allende 79000 Niort",
    "score": 0.9678663636363636,
    "housenumber": "200",
    "postcode": "79000",
    "x": 431952.32,
    "y": 6587277.49,
    "city": "Niort"
}

Compile our Go code to Wasm Binary

In previous articles, we've discussed Wasm and Otoroshi. A convenient solution is to utilize the comprehensive tool Wasmo which integrates all necessary processes for building Wasm that's compatible with Otoroshi.

Let's update our code to fit the structure of a Wasm plugin in Go.

The structure utilized isn't the sole method for generating a Wasm from Go code.But in our case, it will be used to fit the Extism and Otoroshi requirements.

Let's start by importing two packages : the first one to include Extism functions, required to execute Wasm binary in Otoroshi, and the second to manipulate JSON objects named jsonparser.

import (
    "github.com/extism/go-pdk"
    "github.com/buger/jsonparser"
)

Then we can remove all lines of our main function. We will directly call the GetCompleteAddress.

func main() {}

Talking about GetCompleteAddress, let's update its signature to none parameter and an int32 as return type. It is a requirement of Extism for each declared function. It's fine, but now we have to find a way to read the search input. This parameter will come from Otoroshi when the Wasm plugin will be execute. It should correspond to the following diagram.

Wasm Hosts and Guests can only communicate using linear memories. So let's retrieve our search input from the memory using pdk.Input() Extism helper.

func GetCompleteAddress() (string, error) {
    input := pdk.Input()

    var value, dataType, offset, err = jsonparser.Get([]byte(input), "request", "query", "q")

    _ = dataType
    _ = offset

    if err != nil {
        mem := pdk.AllocateString(`{ 
          status: 400, 
          error: {\"error\": \"missing query param q\"}
        }`)
        pdk.OutputMemory(mem)
        return 0
    }
// ...

The incoming input, conveyed as a JSON string dispatched by Otoroshi, encompasses the request information alongside our q query parameter. This JSON string encapsulates the necessary data for processing the address search query.

We need of two more changes before building our WebAssembly binary.

Similarly, as we read the input, we need to write our result into the linear memory to enable Otoroshi to read it.

So let's edit the end of our function by calling pdk.AllocateString and pdk.OutputMemory helpers.

func GetCompleteAddress() (string, error) {
//   ...

    output = `{ 
      status: 200, 
      body_json: ` + output_address + `
    }`

    mem := pdk.AllocateString(output)
    pdk.OutputMemory(mem)
}

We wrapped the result into a JSON object as Otoroshi expected it.

Our GetCompleteAddress function is ready. The last thing is to edit the GetAddress function to use the supported Extism http client.

func GetAddress(search string) (string, error) {
    req := pdk.NewHTTPRequest(pdk.MethodGet, "https://api-adresse.data.gouv.fr/search/?q="+search)
    req.SetHeader("q", search)
    res := req.Send()

    if res.Status() != 200 {
        return "", errors.New("Failed fetching address with given input")
    }

    body := res.Body()

    return string(body), nil
}

This initial version of our plugin is indeed functional. However, it relies on the assumption that Wasm machines in Otoroshi are not erased after each execution. Consequently, the memory of the plugin can only be safely used for a short duration of time.

To address this issue, let's implement a solution that leverages the cache of Otoroshi.

Leveraging Otoroshi Storage for Wasm

Let's talk about the requirements for storing cached data within Otoroshi.

In the Otoroshi Wasm integration, several aspects warrant discussion between the Wasm binary (referred to as Guest) and Otoroshi (commonly termed Host). Within the WebAssembly ecosystem, this interaction between Host and Guest occurs by furnishing a predefined list of Host functions at the inception of the Wasm machine. Subsequently, once the machine is initiated, these Host functions become accessible within the Wasm environment.

The available Otoroshi Host Functions that you can use are concern

  • access wasi and http resources

  • access otoroshi internal state, configuration or static configuration

  • access plugin scoped in-memory key/value storage

  • access global in-memory key/value storage

  • access plugin scoped persistent key/value storage

  • access global persistent key/value storage

For our use case, we only need to access plugin scoped persistent storage. This key/value storage will replace our addressesCache map.

Let's define the two imports in our module

//export proxy_plugin_map_set
func _ProxyPluginMapSet(context uint64, contextSize uint64) uint64
//export proxy_plugin_map_get
func _ProxyPluginMapGet(context uint64, contextSize uint64) uint64

These signatures of functions enable us to read and write the map of key/value pairs. Defining the function at the top of our plugin is required by the WebAssembly. The runtime will utilize it to verify if, at the start, the imports are provided by Otoroshi.

Let's encapsulate these two functions with two additional functions to simplify the calls to these functions. Indeed, our primary goal is to consistently access values by their corresponding keys and modify the map by providing key/value pairs. We aim to avoid directly manipulating pointers and bytes each time, seeking a more straightforward approach.

func AddNewValueToCache(key string, value string) {
    context := []byte(`{
    "key": "` + key + `",
    "value": ` + value + `
  }`)

    _ProxyPluginMapSet(ByteArrPtr(context), uint64(len(context)))
}

func GetValueFromCache(key string) (string, bool) {
    value := pointerToBytes(
        _ProxyPluginMapGet(StringBytePtr(key), uint64(len(key))))

    if len(value) > 0 {
        return string(value), true
    } else {
        return "", false
    }
}

Let's add few helpers methods to convert bytes array to string and to load, from the memory, values using pointers. These helpers are automatically present in plugin created from Wasmo.

func StringBytePtr(msg string) uint64 {
    mem := pdk.AllocateString(msg)
    return mem.Offset()
}

func ByteArrPtr(arr []byte) uint64 {
    mem := pdk.AllocateBytes(arr)
    return mem.Offset()
}

func pointerToBytes(p uint64) []byte {
    responseMemory := pdk.FindMemory(p)

    buf := make([]byte, int(responseMemory.Length()))
    responseMemory.Load(buf)

    return buf
}

Let's apply our last update on our GetCompletAddress function to use the cache.

func GetCompleteAddress() int32 {
    // ...
    var output_address = string("")
    if cachedAddress, found := GetValueFromCache(search); found {
        output_address = cachedAddress
    } else {
        address, err := GetAddress(search)

        if err != nil {
            mem := pdk.AllocateString(`{ 
              status: 400, 
              error: {\"body_str\": \"Failed fetching address with given input\"}
            }`)
            pdk.OutputMemory(mem)
            return 0
        }

        out, err := json.Marshal(address)

        if err != nil {
            mem := pdk.AllocateString(`{ 
                status: 400, 
                error: {\"body_str\": \"Failed to marshal response\"}
            }`)

            pdk.OutputMemory(mem)
            return 0
        }

        AddNewValueToCache(search, string(out))

        output_address = string(out)
    }
// ...
}

We've incorporated our GetValueFromCache and AddNewValueToCache functions to read from and write to the cache, respectively. Additionally, we've adjusted our error-handling approach to accommodate the necessity of writing the result into the linear memory.

So, the final code should be something

Let's outline the variances between the original implementation in Go and the subsequent one aimed at Wasm

FeaturesGo scriptWasm Go Plugin
Entry pointmainGetCompleteAddress function declared and exported to be accessible from Otoroshi
HTTP clienthttp.Getpdk.NewHTTPRequest
CacheGlobal map declaredHost functions coming from Otoroshi : proxy_plugin_map_set/get
Helper functions declared to manage pointer usage between guest and host
Inputs/OutputsRetrieve input from command line argumentsRetrieve input as a byte array from linear memory and utilize Extism helpers to write bytes into memory.

Deploy and Test Our Solution

The solution can be easily tested by deploying an Otoroshi instance connected to a Wasmo instance.

Start a new Otoroshi instance

 curl -L -o otoroshi.jar \
'https://github.com/MAIF/otoroshi/releases/download/v16.15.1/otoroshi.jar'
java -Dotoroshi.adminPassword=password -jar otoroshi.jar

Start a new Wasmo instance

docker network create wasmo-network
docker run -d --name s3Server \
    -p 8000:8000 \
    -e SCALITY_ACCESS_KEY_ID=access_key \
    -e SCALITY_SECRET_ACCESS_KEY=secret \
    --net wasmo-network scality/s3server
docker run -d --net wasmo-network \
    --name wasmo \
    -p 5001:5001 \
    -e "AUTH_MODE=NO_AUTH" \
    -e "AWS_ACCESS_KEY_ID=access_key" \
    -e "AWS_SECRET_ACCESS_KEY=secret" \
    -e "S3_FORCE_PATH_STYLE=true" \
    -e "S3_ENDPOINT=http://localhost:8000" \
    -e "S3_BUCKET=wasmo" \
    -e "STORAGE=DOCKER_S3" \
    maif/wasmo

Connect Wasmo and Otoroshi

Log in to the Otoroshi UI here and update the global configuration from the danger zone (accessible from the button at the top right). Scroll down to the Wasmo section and change information based on your port, ip and other credentials. You can find more information on How to configure Wasmo here.

Create the Go plugin using Wasmo UI

Navigate to your Wasmo UI, create a new plugin using Go and the Empty template.

Paste the final code of the previous section and build it using the Hammer button, at the top right.

Final step: Create the route in Otoroshi

Return to the Otoroshi UI, where you'll initiate a new route by assigning it a name. Next, navigate to the Designer tab and follow these steps:

  1. Modify the frontend by accessing geolocation.oto.tools:8080.

  2. Incorporate a new Wasm Backend plugin using the plugin menu located on the left side of the interface.

  3. Choose "Wasmo" as the type of plugin.

  4. Opt for your plugin in the subsequent selector.

  5. Specify "GetCompleteAddress" as the Name of the exported function for invocation.

  6. Activate Wasi support.

  7. Enable the appropriate Host functions in the subsequent section.

  8. Under Advanced settings, include *.data.gouv.fr as Allowed hosts.

Repeatedly save and request it. You'll notice a slight variation, indicating that our cache system is operational.

curl http://geolocation.oto.tools:8080?q=avenue+allende+79000+niort

You can also utilize the Tester Tab in the Otoroshi UI to make direct service calls. This feature provides convenient tools for debugging and identifying which plugins and route steps are consuming the most request time.

If we attempt two identical requests consecutively, you'll notice that the cache is indeed functioning properly here: the response time decreases from 85ms to approximately 10ms.

Congratulations ✨, we got our first part of the FAAS using Go, Wasm, Wasmo and Otoroshi.

If you followed this article to the end, add 👏 .

You can join discussions about Wasm, Wasmo or Otoroshi by clicking here.

About the projects used in this article :

Wasmo:maif.github.io/wasmo/builder/getting-started

Otoroshi : otoroshi.io

Extism : extism.org

Did you find this article valuable?

Support Zwiterrion by becoming a sponsor. Any amount is appreciated!