Watching a recent talk by Lin Clark and Till Schneidereit about WebAssembly (Wasm) inspired me to start experimenting with using WebAssembly modules from serverless functions.
This blog post demonstrates how to invoke functions written in C from Node.js serverless functions. Source code in C is compiled to Wasm modules and bundled in the deployment package. Node.js code implements the serverless platform handler and calls native functions upon invocations.
The examples should work (with some modifications) on any serverless platform that supports deploying Node.js functions from a zip file. I'll be using IBM Cloud Functions (Apache OpenWhisk).
WebAssembly
WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable target for compilation of high-level languages like C/C++/Rust.
Wasm started as a project to run low-level languages in the browser. This was envisioned as a way to execute computationally intensive tasks in the client, e.g. image manipulation, machine learning, graphics engines. This would improve performance for those tasks compared to using JavaScript.
WebAssembly compiles languages like C, C++ and Rust to a portable instruction format, rather than platform-specific machine code. Compiled Wasm files are interpreted by a Wasm VM in the browser or other runtimes. APIs have been defined to support importing and executing Wasm modules from JavaScript runtimes. These APIs have been implemented in multiple browsers and recent Node.js versions (v8.0.0+).
This means Node.js serverless functions, using a runtime version above 8.0.0, can use WebAssembly!
Wasm Modules + Serverless
"Why would we want to use WebAssembly Modules from Node.js Serverless Functions?" 🤔
Performance
Time is literally money with serverless platforms. The faster the code executes, the less it will cost. Using C, C++ or Rust code, compiled to Wasm modules, for computationally intensive tasks can be much faster than the same algorithms implemented in JavaScript.
Easier use of native libraries
Node.js already has a way to use native libraries (in C or C++) from the runtime. This works by compiling the native code during the NPM installation process. Libraries bundled in deployment packages need to be compiled for the serverless platform runtime, not the development environment.
Developers often resort to using specialised containers or VMs, that try to match the runtime environments, for library compilation. This process is error-prone, difficult to debug and a source of problems for developers new to serverless.
Wasm is deliberately platform independent. This means Wasm code compiled locally will work on any Wasm runtime. No more worrying about platform architectures and complex toolchains for native libraries!
Additional runtime support
Dozens of languages now support compiling to WebAssembly.
Want to write serverless functions in Rust, C, or Lua? No problem! By wrapping Wasm modules with a small Node.js handler function, developers can write their serverless applications in any language with "compile to Wasm" support.
Developers don't have to be restricted to the runtimes provided by the platform.
JS APIs in Node.js
Here is the code needed to load a Wasm module from Node.js. Wasm modules are distributed in .wasm
files. Loaded modules are instantiated into instances, by providing a configurable runtime environment. Functions exported from Wasm modules can then be invoked on these instances from Node.js.
const wasm_module = 'library.wasm'
const bytes = fs.readFileSync(wasm_module)
const wasmModule = new WebAssembly.Module(bytes);
const wasmMemory = new WebAssembly.Memory({initial: 512});
const wasmInstance = new WebAssembly.Instance(wasmModule, { env: { memory: wasmMemory } }})
Calling Functions
Exported Wasm functions are available on the exports
property of the wasmInstance
. These properties can be invoked as normal functions.
const result = wasmInstance.exports.add(2, 2)
Passing & Returning Values
Exported Wasm functions can only receive and return native Wasm types. This (currently) means only integers.
Values that can be represented as a series of numbers, e.g. strings or arrays, can be written directly to the Wasm instance memory heap from Node.js. Heap memory references can be passed as the function parameter values, allowing the Wasm code to read these values. More complex types (e.g. JS objects) are not supported.
This process can also be used in reverse, with Wasm functions returning heap references to pass back strings or arrays with the function result.
For more details on how memory works in Web Assembly, please see this page.
Examples
Having covered the basics, let's look at some examples...
I'll start with calling a simple C function from a Node.js serverless function. This will demonstrate the complete steps needed to compile and use a small C program as a Wasm module. Then I'll look at a more real-world use-case, dynamic image resizing. This will use a C library compiled to Wasm to improve performance.
Examples will be deployed to IBM Cloud Functions (Apache OpenWhisk). They should work on other serverless platforms (supporting the Node.js runtime) with small modifications to the handler function's interface.
Simple Function Calls
Create Source Files
- Create a file
add.c
with the following contents:
int add(int a, int b) {
return a + b;
}
- Create a file (
index.js
) with the following contents:
'use strict';
const fs = require('fs');
const util = require('util')
const WASM_MODULE = 'add.wasm'
let wasm_instance
async function load_wasm(wasm_module) {
if (!wasm_instance) {
const bytes = fs.readFileSync(wasm_module);
const memory = new WebAssembly.Memory({initial: 1});
const env = {
__memory_base: 0, memory
}
const { instance, module } = await WebAssembly.instantiate(bytes, { env });
wasm_instance = instance
}
return wasm_instance.exports._add
}
exports.main = async function ({ a = 1, b = 1 }) {
const add = await load_wasm(WASM_MODULE)
const sum = add(a, b)
return { sum }
}
- Create a file (
package.json
) with the following contents:
{
"name": "wasm",
"version": "1.0.0",
"main": "index.js"
}
Compile Wasm Module
This C source file needs compiling to a WebAssembly module. There are different projects to handle this. I will be using Emscripten, which uses LLVM to compile C and C++ to WebAssembly.
Install the Emscripten toolchain.
Run the following command to generate the Wasm module.
emcc -s WASM=1 -s SIDE_MODULE=1 -s EXPORTED_FUNCTIONS="['_add']" -O1 add.c -o add.wasm
The SIDE_MODULE
option tells the compiler the Wasm module will be loaded manually using the JS APIs. This stops Emscripten generating a corresponding JS file to do this automatically. Functions exposed on the Wasm module are controlled by the EXPORTED_FUNCTIONS
configuration parameter.
Deploy Serverless Function
- Create deployment package with source files.
zip action.zip index.js add.wasm package.json
- Create serverless function from deployment package.
ibmcloud wsk action create wasm action.zip --kind nodejs:10
- Invoke serverless function to test Wasm module.
$ ibmcloud wsk action invoke wasm -r -p a 2 -p b 2
{
"sum": 4
}
It works! 🎉🎉🎉
Whilst this is a trivial example, it demonstrates the workflow needed to compile C source files to Wasm modules and invoke exported functions from Node.js serverless functions. Let's move onto a more realistic example...
Dynamic Image Resizing
This repository contains a serverless function to resize images using a C library called via WebAssembly. It is a fork of the original code created by Cloudflare for their Workers platform. See the original repository for details on what the repository contains and how the files work.
Checkout Repository
- Retrieve the source files by checking out this repository.
git clone https://github.com/jthomas/openwhisk-image-resize-wasm
This repository contains the pre-compiled Wasm module (resize.wasm
) needed to resize images using the stb library. The module exposes two functions: init
and resize
.
The init
function returns a heap reference to write the image bytes for processing into. The resize
function is called with two values, the image byte array length and new width value. It uses these values to read the image bytes from the heap and calls the library functions to resize the image to the desired width. Resized image bytes are written back to the heap and the new byte array length is returned.
Deploy Serverless Function
- Create deployment package from source files.
zip action.zip resizer.wasm package.json worker.js
- Create serverless function from deployment package.
ibmcloud wsk action update resizer action.zip --kind nodejs:10 --web true
- Retrieve HTTP URL for Web Action.
ibmcloud wsk action get resizer --url
This should return a URL like: https://<region>.cloud.ibm.com/api/v1/web/<ns>/default/resizer
- Open the Web Action URL with the
.http
extension.
https://<region>.cloud.ibm.com/api/v1/web/<ns>/default/resizer.http
This should return the following image resized to 250 pixels (from 900 pixels).
URL query parameters (url
and width
) can be used to modify the image source or output width for the next image, e.g.
https://<region>.cloud.ibm.com/api/v1/web/<ns>/default/resizer.http?url=<IMG_URL>&width=500
Conclusion
WebAssembly may have started as a way to run native code in the browser, but soon expanded to server-side runtime environments like Node.js. WebAssembly modules are supported on any serverless platform with a Node.js v8.0.0+ runtime.
Wasm provides a fast, safe and secure way to ship portable modules from compiled languages. Developers don't have to worry about whether the module is compiled for the correct platform architecture or linked against unavailable dynamic libraries. This is especially useful for serverless functions in Node.js, where compiling native libraries for production runtimes can be challenging.
Wasm modules can be used to improve performance for computationally intensive calculations, which lowers invocation times and, therefore, costs less. It also provides an easy way to utilise additional runtimes on serverless platforms without any changes by the platform provider.