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
protobuf
but alsojson
annotations
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.Unmarshal
to go from bytes toComplexResult
and thenprotojson.Marshal
to 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
.gcloudignore
and ensurego.mod
(andgo.sum
) are excluded (so that they won’t be used) - run
go mod vendor
to create a./vendor
directory 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
./protos
directory. Unfortunately, this doesn’t work with vendoring because e.g.github.com/[[YOUR-PROJECT]]/protos
is 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))
}