SecondState | AWS Lambda the Rust and WebAssembly Serverless function
Author Robby Qiu, Second State development and WasmEdge contributor
The Serverless function saves developers a lot of the hassle of managing their back-end infrastructure. Serverless also simplifies the development process because developers only have to focus on the logic of the business itself. This article is a step-by-step guide on how to write and deploy WebAssembly Serverless functions on Amazon’s Serverless computing platform, AWS Lambda. In our demo, the WebAssembly function is executed using the WasmEdge Runtime. The following diagram shows the overall architecture of our solution.
In the first part of this article, we’ll explain why WebAssembly is an excellent Runtime for Serverless functions. We compare WebAssembly bytecodes (typically compiled from Rust, C++), high-level programming languages (such as Python and JavaScript), and machine native executables (native client or NaCl). Then, in Part 2, we’ll demonstrate two examples of Serverless functions, both written in Rust and compiled for deployment into WebAssembly. The first example demonstrates WasmEdge’s ability to quickly process images, while the second example runs AI reasoning powered by WasmEdge’s TensorFlow extension.
Why WebAssembly?
The short answer is that WebAssembly is fast, secure, and portable. So why exactly? Here are the detailed answers.
WebAssembly vs. Python and JavaScript
A recent DataDog survey found that most AWS Lambda Serverless functions are written in JavaScript and Python. They are two of the most popular programming languages in the world, so this is not surprising.
However, high-level languages are notoriously slow. In fact, Python is up to 60,000 times slower than an equivalent program written in C or C++, according to a paper published in Science.
Thus, while JavaScript and Python are great for simple functions, they are poorly suited to computationally intensive tasks such as image, video, audio, and natural language processing, which are increasingly common in modern applications.
WebAssembly, on the other hand, performs as well as C/C++ compiled native binaries (NaCl), while still maintaining the portability, security, and manageability associated with the high-level language Runtime. WasmEdge is currently one of the fastest WebAssembly Runtime on the market.
WebAssembly vs. native client
However, when both run inside Docker containers or microVMS, what are WebAssembly’s advantages over NaCl?
Our vision for the future is WebAssembly as an alternative lightweight Runtime running in parallel with Docker and microVM in a native infrastructure. WebAssembly performs better and consumes fewer resources than containers like Docker or microVM. But for now, AWS Lambda and many other platforms only support WebAssembly running inside microVM. Still, running WebAssembly functions in microVM has many advantages over running containerized NaCl programs.
First, WebAssembly provides fine-grained Runtime isolation for individual functions. A microservice can have multiple functions and support services running in a microVM. WebAssembly can make microservices more secure and stable.
Second, WebAssembly bytecode is portable. Even within the container, NaCl still relies on the underlying CPU, operating system, and dynamic libraries installed on the OS. WebAssembly bytecode applications are cross-platform. Developers write it once and deploy it on any cloud, any container, and any hardware platform.
Third, WebAssembly applications are * easy to deploy and manage. ** They are much less platform dependent and complex than NaCl dynamic libraries and executables.
Finally, WebAssembly is multilingual. C/C++, Rust, Swift, and Kotlin programs can be easily compiled into WebAssembly. WebAssembly even supports JavaScript. The WasmEdge Tensorflow API provides the most customisable way to execute the Tensorflow model in the Rust programming language.
We can see that WebAssembly + WasmEdge is a better choice. To see this in action, let’s dive into an example and get hands-on with it!
preparation
Since our Demo WebAssembly functions are written in Rust, you need to install a Rust compiler. Make sure you add the WASM32-WASI compiler target (below) to generate WebAssembly bytecode.
$ rustup target add wasm32-wasi
Copy the code
The demo application front-end is written with next.js and deployed on AWS Lambda. We assume that you already have the basics of working with next.js and Lambda.
Case 1: Image processing
Our first demo application asked the user to upload an image, and then the user called the Serverless function to make it black and white. You can view live demos already deployed through GitHub Pages.
IO /aws-lambda-…
Fork the GitHub repo of the Demo application and start deploying your own functions. Refer to the README tutorial in Repository for details on how to deploy an application on an AWS Lambda.
Template GitHub repo: github.com/second-stat…
Create a function
The template repo is a standard Next-js application. The back-end serverless functions are in the API/Functions /image_grayscale folder. The SRC /main.rs file contains the source code for the Rust program. The Rust program reads data from STDIN and then outputs a black-and-white image to STDOUT.
use hex; use std::io::{self, Read}; use image::{ImageOutputFormat, ImageFormat}; fn main() { let mut buf = Vec::new(); io::stdin().read_to_end(&mut buf).unwrap(); let image_format_detected: ImageFormat = image::guess_format(&buf).unwrap(); let img = image::load_from_memory(&buf).unwrap(); let filtered = img.grayscale(); let mut buf = vec! []; match image_format_detected { ImageFormat::Gif => { filtered.write_to(&mut buf, ImageOutputFormat::Gif).unwrap(); }, _ => { filtered.write_to(&mut buf, ImageOutputFormat::Png).unwrap(); }}; io::stdout().write_all(&buf).unwrap(); io::stdout().flush().unwrap(); }Copy the code
Rust programs can be built as WebAssembly bytecode or native code using Rust’s Cargo tool.
$ cd api/functions/image-grayscale/
$ cargo build --release --target wasm32-wasi
Copy the code
Copy the Build artifact to the API folder.
$ cp target/wasm32-wasi/release/grayscale.wasm .. /.. /Copy the code
When we build the Docker image, API /pre.sh is executed. Pre.sh installs the WasmEdge Runtime, and then compiles each WebAssembly bytecode program into the native SO library to speed up execution.
Create the service script and load the function
The API /hello.js script loads the WasmEdge Runtime, launches the compiled WebAssembly program in WasmEdge, and passes the uploaded image data through STDIN. Note that API /hello.js runs the compiled grayscale.so file generated by API /pre.sh for better performance.
const { spawn } = require('child_process');
const path = require('path');
function _runWasm(reqBody) {
return new Promise(resolve => {
const wasmedge = spawn(path.join(__dirname, 'wasmedge'), [path.join(__dirname, 'grayscale.so')]);
let d = [];
wasmedge.stdout.on('data', (data) => {
d.push(data);
});
wasmedge.on('close', (code) => {
let buf = Buffer.concat(d);
resolve(buf);
});
wasmedge.stdin.write(reqBody);
wasmedge.stdin.end('');
});
}
Copy the code
The exports.handler section of hello.js exports an asynchronous function handler that handles a different event each time the Serverless function is called. In this case, we just call the above function to process the image and return the result, but you can define more complex event handling behavior as needed. We also need to return some Access-Control-allow headers to avoid cross-domain Origin Resource Sharing (CORS) errors when calling Servereless from the browser. If you encounter a CORS error while copying our example, you can view more information about CORS errors here.
exports.handler = async function(event, context) {
var typedArray = new Uint8Array(event.body.match(/[\da-f]{2}/gi).map(function (h) {
return parseInt(h, 16);
}));
let buf = await _runWasm(typedArray);
return {
statusCode: 200,
headers: {
"Access-Control-Allow-Headers" : "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT"
},
body: buf.toString('hex')
};
}
Copy the code
Build a Docker image for Lambda deployment
We now have WebAssembly bytecode functions and scripts to load and connect to Web requests. In order to deploy them as function services on AWS Lambda, you still need to package the whole thing into a Docker image.
We won’t go into the details of how to build Docker images and deploy them on AWS Lambda, you can refer to the Deploy section of the README. However, we’ll highlight a portion of the Dockerfile to avoid some pitfalls.
FROM public.ecr.aws/lambda/nodejs:14
# Change directory to /var/task
WORKDIR /var/task
RUN yum update -y && yum install -y curl tar gzip
# Bundle and pre-compile the wasm files
COPY *.wasm ./
COPY pre.sh ./
RUN chmod +x pre.sh
RUN ./pre.sh
# Bundle the JS files
COPY *.js ./
CMD [ "hello.handler" ]
Copy the code
First, we build an image from the Node.js base image of AWS Lambda. The advantage of using the AWS Lambda Base image is that it includes the Lambda Runtime interface client (RIC), which we need when deploying the Docker image in AWS Lambda. Amazon Linux uses YUM as the package manager.
These Base images contain the Amazon Linux Base operating system, runtime for the given language, dependencies, and the Lambda Runtime interface client (RIC), which implements the Lambda Runtime API. The Lambda Runtime API client allows your Runtime to receive requests from and send them to the Lambda service.
Second, we need to place our function and all its dependencies in the /var/task directory. AWS Lambda does not execute files in other folders.
Third, we need to define the default command when we start the container. CMD [“hello.handler”] means that whenever the serverless function is called, we will call the handler function in hello.js. Recall that we passed exports.handler =… in hello.js in the previous step. Handler functions are defined and exported.
Optional: Test the Docker image locally
You can test Docker images built from the base image of AWS Lambda locally by following AWS’s instructions. Local testing requires AWS Lambda Runtime Interface Emulator (RIE), which is already installed in all AWS Lambda base images. To test your image, first start the Docker container by running the following command:
docker run -p 9000:8080 myfunction:latest
Copy the code
This command on your local machine set up a function endpoint http://localhost:9000/2015-03-31/functions/function/invocations.
Then, from a separate terminal window, run:
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}'
Copy the code
You should get the desired output in the terminal.
If you don’t want to use base images from AWS Lambda, you can also use your own base images and install RIC and/or RIE when building Docker images. Just follow the AWS instructions to create an image from the alternative base image section.
That’s it! After building the Docker image, you can unpack it to AWS Lambda by following the steps outlined by README in the REPo. Now your Serverless function is ready! Let’s look at the second, more difficult function
Case 2: AI reasoning
The second demo application lets the user upload an image and triggers a Serverless function to identify the main items on the image.
It is in the same GitHub repo as the previous example, but in the TensorFlow branch. The back-end serverless functions for image classification are located in the API/Functions /image-classification folder of the TensorFlow branch. The SRC /main.rs file contains the source code for the Rust program. The Rust program reads the image data from STDIN and then outputs the script output to STDOUT. It utilizes the WasmEdge Tensorflow API to run AI reasoning.
AI Reasoning template: github.com/second-stat…
pub fn main() { // Step 1: Load the TFLite model let model_data: &[u8] = include_bytes! (" models/mobilenet_v1_1. 0 _224 / mobilenet_v1_1. 0 _224_quant. Tflite "); let labels = include_str! (" models/mobilenet_v1_1. 0 _224 / labels_mobilenet_quant_v1_224. TXT "); // Step 2: Read image from STDIN let mut buf = Vec::new(); io::stdin().read_to_end(&mut buf).unwrap(); // Step 3: Resize the input image for the tensorflow model let flat_img = wasmedge_tensorflow_interface::load_jpg_image_to_rgb8(&buf, 224, 224); // Step 4: AI inference let mut session = wasmedge_tensorflow_interface::Session::new(&model_data, wasmedge_tensorflow_interface::ModelType::TensorFlowLite); session.add_input("input", &flat_img, &[1, 224, 224, 3]) .run(); let res_vec: Vec<u8> = session.get_output("MobilenetV1/Predictions/Reshape_1"); // Step 5: Find the food label that responds to the highest probability in res_vec // ... . let mut label_lines = labels.lines(); for _i in 0.. max_index { label_lines.next(); } // Step 6: Generate the output text let class_name = label_lines.next().unwrap().to_string(); if max_value > 50 { println! ("It {} a <a href='https://www.google.com/search?q={}'>{}</a> in the picture", confidence.to_string(), class_name, class_name); } else { println! ("It does not appears to be any food item in the picture."); }}Copy the code
You can use the Cargo tool to build Rust programs into WebAssembly bytecode or native code.
$ cd api/functions/image-classification/
$ cargo build --release --target wasm32-wasi
Copy the code
Copy build Artifacts to the API folder.
$ cp target/wasm32-wasi/release/classify.wasm .. /.. /Copy the code
Likewise, the API /pre.sh script installs the WasmEdge Runtime and its Tensorflow dependencies in this application. It also compiles the classify. Wasm bytecode program to the classify. So native shared library at deployment time.
The API /hello.js script loads the WasmEdge Runtime, starts the compiled WebAssembly program in WasmEdge, and passes the uploaded image data via STDIN. Note that API /hello.js runs the compiled classify. So file generated by API /pre.sh for better performance. The Handler function is similar to our previous example and won’t be detailed here.
const { spawn } = require('child_process');
const path = require('path');
function _runWasm(reqBody) {
return new Promise(resolve => {
const wasmedge = spawn(
path.join(__dirname, 'wasmedge-tensorflow-lite'),
[path.join(__dirname, 'classify.so')],
{env: {'LD_LIBRARY_PATH': __dirname}}
);
let d = [];
wasmedge.stdout.on('data', (data) => {
d.push(data);
});
wasmedge.on('close', (code) => {
resolve(d.join(''));
});
wasmedge.stdin.write(reqBody);
wasmedge.stdin.end('');
});
}
exports.handler = ... // _runWasm(reqBody) is called in the handler
Copy the code
You can build the Docker image and deploy the function as described in the previous example. You have now created a Web application for categorizing topics!
Looking to the future
Running WasmEdge from a Docker container deployed on an AWS Lambda is an easy way to add high-performance functions to a Web application. Looking ahead, a better approach is to use WasmEdge as the container itself. This eliminates the need for Docker and Node.js to install WasmEdge. As a result, we can run the Serverless function more efficiently. WasmEdge is already compatible with Docker tools. If you are interested in joining WasmEdge and CNCF in this exciting work, please let us know!