Ballpointcarrot.net

Running Rust on AWS Lambda on ARM64

878 words, a 5m read.

TL;DR: Rust on ARM64 Lambda: check out my example repo

I've been a hobbyist Rust developer for a while now, playing with random tools and mostly having false starts on projects that I'd like to build, but then the energy to do so wanes before I can complete them. Because of that, I always look for new applications for Rust to fit somewhere in my day-to-day, so that I can give myself some practice.

I also work very closely with AWS Serverless tooling in my daily work at Stedi. Recently, AWS posted an update to the Lambda service that they were providing Graviton2-based Lambda functions as part of the service, and I immediately thought to myself, "How would I do a Rust lambda that way?"

Lambda gives you the ability to create a "Custom" runtime - all you need to do is provide a executable that acts as a bootstrap for running the function (aptly named "bootstrap" in the runtime code). By building that as a rust binary with the correct architecture, we can run Rust code natively on the Lambda!

Project Setup

I started with a vanilla cargo project, and added the following into the cargo.toml:

[[bin]]
name = "bootstrap"
path = "src/main.rs"

[dependencies]
lambda_runtime = "^0.4"
tokio = "^1"
serde = { version = "^1", features = ["derive"] }
serde_json = "^1"

The [[bin]] section allows us to specify the binary filename for what's built (and saves us renaming the file after compilation). The dependencies are all references and dependencies of the AWS Rust runtime crates - that's where the code for the function we'll be using is pulled from.

we just have one file in the source directory, at src/main.rs:

use lambda_runtime::{handler_fn, Context, Error};
use serde_json::{json, Value};

#[tokio::main]
async fn main() -> Result<(), Error> {
    let funct = handler_fn(func);
    lambda_runtime::run(funct).await?;
    Ok(())
}

async fn func(event: Value, _: Context) -> Result<Value, Error> {
    let first_name = event["firstName"].as_str().unwrap_or("world");
    Ok(json!({"message": format!("Hello, {}!", first_name)}))
}

For those less savvy with Rust code, this defines the 'bootstrap' runtime under main(), and calls the async function func() to act as the lambda function body. Our function responds with a message of "Hello, World!" in a JSON response by default, or can respond with a specific greeting when passed a "firstName" JSON value as part of its input.

Now, you could build this locally, but because of the way that the lambda function is called, local runs will panic:

± cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.39s
     Running `target/debug/bootstrap`
thread 'main' panicked at 'Missing AWS_LAMBDA_RUNTIME_API env var: NotPresent', /home/ckruse/.asdf/installs/rust/stable/registry/src/github.com-1ecc6299db9ec823/lambda_runtime-0.4.1/src/lib.rs:57:58

The immediate goal here is to run it in lambda, and not locally, so we'll save testing it out on our own for a later time.

Cross-Compiling

The example that is provided in the AWS Rust runtime repository shows them using a custom linker and rustup target in order to cross-compile for x86-based lambdas. I attempted to dive into how to get a arm64-based linker, but in the process of trying to figure out how to get that linker installed locally (no convenient packages for Ubuntu, sorry), I stumbled across a project that provides cross-compilation tooling for various platforms via Docker. That made my life a lot easier - Using the rust-musl-cross Docker container, all I have to do is wrap the call to cargo build, and it builds for the proper architecture:

#!/bin/sh

build_arch=aarch64-musl

docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:$build_arch $@

Armed with that (pardon the pun), I can now call ./musl-build cargo build --release, and I get a resulting binary at target/aarch64-unknown-linux-musl/release/bootstrap.

The final step, as called out in the AWS Rust runtime docs, is to bundle that executable up in a Zip file, so that the Lambda service can construct what it needs to on its side. I've made the package.sh file in the repo to handle that and act as a basic build script (I'll probably convert this to a Makefile later when feeling energetic):

#!/bin/sh

mkdir -p dist

if [ ! -d target/aarch64-unknown-linux-musl ]; then
  ./musl-build cargo build --release
fi

# TODO: use vars for arch folder and binary name
cp target/aarch64-unknown-linux-musl/release/bootstrap dist/
cd dist && zip package.zip bootstrap && cd ..

Now, after running that command, I can grab the file at dist/package.zip and go take that to AWS Lambda to run:

With no arguments provided:

Rust Lambda without name

With a name provided:

{
  "firstName": "Christopher"
}

Rust Lambda with firstName

Conclusion

I hope you can use this to make a running start building out lambda function code on the new Graviton2-based Lambda runners. Let me know if this helped you out!