WASM Cloud Functions
- 7 minutes read - 1299 wordsFollowing on from waPC & Protobufs and a question on Stack Overflow about Cloud Functions, I was compelled to try running WASM on Cloud Functions no JavaScript.
I wanted to reuse the WASM waPC functions that I’d written in Rust as described in the other post. Cloud Functions does not (yet!?) provide a Rust runtime and so I’m using the waPC Host for Go in this example.
It works!
PARAMS=$(printf '{"a":{"real":39,"imag":3},"b":{"real":39,"imag":3}}' | base64)
TOKEN=$(gcloud auth print-identity-token)
echo "{
\"filename\":\"complex.wasm\",
\"function\":\"c:mul\",
\"params\":\"${PARAMS}\"
}" |\
curl \
--silent \
--request POST \
--header "Content-Type: application/json" \
--header "Authorization: Bearer ${TOKEN}" \
--data @- \
https://${REGION}-${PROJECT}.cloudfunctions.net/invoker
yields (correctly):
{
"result": {
"real": 1512,
"imag": 234
}
}
Let me explain…
Protobufs & JSON
I’m using a WASM binary unchanged from the work described in the other post. I’d added another proto (complex.proto) to that project and implemented it using waPC and Rust. The result is complex.wasm. The proto is:
syntax = "proto3";
package examples;
option go_package = "github.com/[[PROJECT]]/protos";
service ComplexService {
rpc add(ComplexRequest) returns (ComplexResponse);
rpc sub(ComplexRequest) returns (ComplexResponse);
rpc mul(ComplexRequest) returns (ComplexResponse);
rpc div(ComplexRequest) returns (ComplexResponse);
}
message ComplexRequest {
Complex a = 1;
Complex b = 2;
}
message ComplexResponse {
Complex result = 1;
string error = 2;
}
message Complex {
float real = 1;
float imag = 2;
}
The implementation expects protobuf (!) requests and responses. The implementation uses the protobuf well-known type, Any, as a way to transfer bytes across the host:guest boundary.
I didn’t want to change this implementation for Cloud Functions (though if you’re only using Cloud Functions, it would be more straightforward to use JSON throughout). I recalled that protobufs supports JSON mapping and so I’m using that.
As with the waPC implementation, the WASM function parameters and result are enveloped but, for the Cloud Function, using JSON. Here’s the Golang struct:
type Message struct {
Filename string `json:"filename"`
Function string `json:"function"`
Params []byte `json:"params"`
}
Returning to the example at the top of this post, it should be clear that the request data maps to this Golang struct.
{
"filename": "complex.wasm",
"function": "c:mul",
"params": "${PARAMS}"
}
But what about ${PARAMS}? Well, that’s base64-encoded to envelope it but, the source data is:
"a": {
"real": 39,
"imag": 3
},
"b": {
"real": 39,
"imag": 3
}
}
And this corresponds to a ComplexRequest type as defined in the protobuf message. When protoc compiles this file, the Golang types it produces are:
type ComplexRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
A *Complex `protobuf:"bytes,1,opt,name=a,proto3" json:"a,omitempty"`
B *Complex `protobuf:"bytes,2,opt,name=b,proto3" json:"b,omitempty"`
}
type Complex struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Real float32 `protobuf:"fixed32,1,opt,name=real,proto3" json:"real,omitempty"`
Imag float32 `protobuf:"fixed32,2,opt,name=imag,proto3" json:"imag,omitempty"`
}
NOTE these not only include
protobufbut alsojsonannotations
Hopefully, it’s clear that ${PARAMS} represents a ComplexRequest
Then we just need to parse the JSON Message, extract a filename (representing complex.wasm) and a function (c:mul corresponds to the multiplication function) with extracted params.
Extracting the params is straightforward using Golang’s new SDK (google.golang.org/protobuf) protojson package:
rqst := &pb.ComplexRequest{}
protojson.Unmarshal(msg.Params, rqst)
params, _ := proto.Marshal(rqst)
And, similarly to convert the function’s result (bytes) through ComplexResult and into JSON to return:
resp := &pb.ComplexResponse{}
proto.Unmarshal(result, resp)
j, _ := protojson.Marshal(resp)
NOTE
proto.Unmarshalto go from bytes toComplexResultand thenprotojson.Marshalto convert it into JSON.
Handler
Cloud Functions (re)uses Golang’s net/http package’s handlers:
func Invoker(w http.ResponseWriter, r *http.Request) { ... }
We can follow Google’s Golang HTTP Functions example to build the bones of the function and extract values from the Message struct defined above.
We must then load and instantiate the WASM (waPC) function using waPC Host for Go. The code is straighforward and mirrors the example in that repo.
One optimisation may be to load and instantiate WASM binaries once per instance rather than once per invocation using global variables. Loading per invocation is slow; complex.wasm is 17MB (!?).
The source is at the end of this post.
Deployment
waPC depends on github.com/wasmerio/go-ext-wasm and this includes shared objects. We must ensure that these are present in the deployed Cloud Function. The (best) way to do this is to vendor the dependencies (and not use Go Modules).
Google describes how to do this, basically:
- create a
.gcloudignoreand ensurego.mod(andgo.sum) are excluded (so that they won’t be used) - run
go mod vendorto create a./vendordirectory containing the dependencies - move protoc-generated files into the vendor directory under the correct module path
NOTE my practice is to hold protoc-generated files in a
./protosdirectory. Unfortunately, this doesn’t work with vendoring because e.g.github.com/[[YOUR-PROJECT]]/protosis unlikely to be present in the vendor directory. The solution is to move|copy the protoc-generated files into the vendor directory under the correct path (e.g../vendor/github.com/[[YOUR-PROJECT]]/protos).
For simplicity, I copied the complex.wasm binary into the project root too. However, I noticed that, when switching between Go Modules and vendoring, the default file system path in the Cloud Function changed. When using:
- Go Modules, it’s
serverless_function_source_code - Vendoring, it’s
src/[[PACKAGE]]
Lastly, we can deploy the Function:
gcloud functions deploy invoker \
--entry-point=Invoker \
--runtime=go113 \
--trigger-http \
--project=${PROJECT}
And then you may invoke the function, either the gcloud-way:
PARAMS=$(printf '{"a":{"real":39,"imag":3},"b":{"real":39,"imag":3}}' | base64)
gcloud functions call invoker \
--project=${PROJECT} \
--data="{\"filename\":\"complex.wasm\",\"function\":\"c:mul\",\"params\":\"${PARAMS}\"}"
Or the curl-way:
PARAMS=$(printf '{"a":{"real":39,"imag":3},"b":{"real":39,"imag":3}}' | base64)
TOKEN=$(gcloud auth print-identity-token)
echo "{\"filename\":\"complex.wasm\",\"function\":\"c:mul\",\"params\":\"${PARAMS}\"}" |\
curl \
--silent \
--request POST \
--header "Content-Type: application/json" \
--header "Authorization: Bearer ${TOKEN}" \
--data @- \
https://${REGION}-${PROJECT}.cloudfunctions.net/invoker
To get:
result: '{"result":{"real":1512, "imag":234}}'
Conclusion
WASM is very interesting. Cloud Functions really should provide a WASI-compliant runtime but, until this happens, it’s straightforward to use one of the existing runtimes (e.g. Golang) and create a WASI-compliant host from it. This sample could be extended several ways to make it more general-purpose including supporting uploading WASM binaries (and persisting them to e.g. Cloud Storage) and retaining (warm) instantiations of WASM binaries to save reloading.
Source
package [[PACKAGE]]
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"path/filepath"
"github.com/wapc/wapc-go"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
pb "github.com/[[PROJECT]]/protos"
)
const (
path = "src/[[PACKAGE]]"
)
type Message struct {
Filename string `json:"filename"`
Function string `json:"function"`
Params []byte `json:"params"`
}
func consoleLog(msg string) {
log.Println(msg)
}
func callback(ctx context.Context, binding, namespace, operation string, payload []byte) ([]byte, error) {
log.Printf("[callback] binding: %s operation: %s payload: %s", binding, operation, payload)
return []byte("default"), nil
}
func Invoker(w http.ResponseWriter, r *http.Request) {
// Decode the incoming Request Body as a JSON WASM structure
// This includes Params an enveloped JSON Protobuf
msg := &Message{}
if err := json.NewDecoder(r.Body).Decode(msg); err != nil {
fmt.Fprintf(w, "%+v\n", msg)
fmt.Fprintf(w, "Error: %s", err)
return
}
if msg.Filename == "" {
fmt.Fprint(w, "Filename must be set")
return
}
// Prefix `filename` with the Cloud Functions default directory
// https://cloud.google.com/functions/docs/concepts/exec#file_system
filename := fmt.Sprintf("%s/%s", path, msg.Filename)
code, err := ioutil.ReadFile(filename)
if err != nil {
fmt.Fprintf(w, "Filename error: %s", filename)
return
}
module, err := wapc.New(consoleLog, code, callback)
if err != nil {
fmt.Fprintf(w, "Module error: %s", err)
return
}
defer module.Close()
instance, err := module.Instantiate()
if err != nil {
fmt.Fprintf(w, "Instantiate error: %s", err)
return
}
defer instance.Close()
// Convert incoming Params bytes of JSON (!) into Protobuf
rqst := &pb.ComplexRequest{}
if err := protojson.Unmarshal(msg.Params, rqst); err != nil {
fmt.Fprintf(w, "protojson unmarshal error: %s", err)
return
}
// Marshal it into bytes for waPC host:guest transition
params, err := proto.Marshal(rqst)
if err != nil {
fmt.Fprintf(w, "Unable to marshal protobuf: %+v", rqst)
return
}
// Invoke the function
ctx := context.Background()
result, err := instance.Invoke(ctx, msg.Function, params)
if err != nil {
fmt.Fprintf(w, "Invoke error: %s", err)
return
}
// Unmarshal the bytes returned into Protobuf
resp := &pb.ComplexResponse{}
proto.Unmarshal(result, resp)
// Unmarshal Protobuf into JSON
j, err := protojson.Marshal(resp)
if err != nil {
fmt.Fprintf(w, "Unable to marshal Protobuf into JSON: %s", err)
return
}
fmt.Fprintf(w, "%s", string(j))
}