DEV Community

Cover image for Blazingly Fast Lambda Functions With Rust
Aidan
Aidan

Posted on

Blazingly Fast Lambda Functions With Rust

Before we get into the meat of this post, I've created a video run-through of this project that you may find useful.
Also... Spoiler alert (the source code)

AWS Lambda

Lambda Runtimes
Lambda supports many languages through supported runtimes. They support some popular languages out the box like:

  • Java
  • Python
  • Ruby
  • JavaScript They also allow you to build your own, which allows you to use more performant languages like Rust and Go. ## Why Rust?
  • Type safety
  • Compiler helps you write error free code
  • Great performance Comparing Rust vs other run-times Below is how well it compares doing a simple task vs other run-times: Image description

Now for a computationally intense task:
Image description

As you can see, Rust and Go are both top dogs with Rust getting the medal for the heavy processing task

(Full article and context for where the benchmarks came from)

Pre-reqs

Project intro

  • Use cargo lambda to create a rust project that already has the basics set-up for us
  • Use AWS CDK to manage our Lambda function and create a DynamaoDB table
  • Add some emails to a DynamoDB table
  • Query the table for all emails
  • Send out a basic email using AWS SES

Into the coding

Initial Cargo Lambda function set-up

cd <your_repos_dir>
cargo lambda new rust-email-lambda
y - for trigger lambda as a HTTP function
cd rust-email-lambda

Cargo Lambda has generated a couple of crate dependencies for us in our cargo.toml, an async main.rs using the tokio crate that calls a function in http_handler.rs. http_handler.rs is where most of the magic is happening.

Here's the code cargo lambda produced for me using version 1.6.3:

pub(crate) async fn function_handler(event: Request) -> Result<Response<Body>, Error> {
    // Extract some useful information from the request
    let who = event // 1.
        .query_string_parameters_ref()
        .and_then(|params| params.first("name"))
        .unwrap_or("world");
    let message = format!("Hello {who}, this is an AWS Lambda HTTP request"); // 2.

    // Return something that implements IntoResponse.
    // It will be serialized to the right response event automatically by the runtime
    let resp = Response::builder() // 3.
        .status(200)
        .header("content-type", "text/html")
        .body(message.into())
        .map_err(Box::new)?;
    Ok(resp) // 4.
}
// test stuff
Enter fullscreen mode Exit fullscreen mode
  1. Extract the query parameters
  2. Create a message to send as a response
  3. Build a response
  4. Wrap the response in Ok()

We're going to keep things simple for our demonstration, just to test everything's working first of all, delete everything from the function_handler body until it looks like this:

   let resp = Response::builder()
        .status(200)
        .header("content-type", "text/html")
        .body(String::from("Hello From our rust lambda").into())
        .map_err(Box::new)?;
    Ok(resp)
Enter fullscreen mode Exit fullscreen mode

Run cargo lambda watch. Once that's finished building your Rust app go to localhost:9000 and you should see "Hello from our Rust Lambda."

If that worked well locally, build a production ready bundle using cargo lambda build --release. The code will be packaged up in an executable, ready to deploy to AWS Lambda in the target/lambda/<your_proj_name>/boostrap dir.

Initial Lambda CDK set-up

Within the root lambda function, create a new dir called deployment.
cd ./deployment
Then run cdk init app --language typescript. This will create and install a new CDK project using TypeScript as the language.

Open up deployment/lib/deployment-stack.ts. This is where we defined the AWS Infrastructure we want to deploy using the aws-cdk-lib package. Delete the comments of boilerplate code and write the following to define our Lambda function:

    import {
        Function,
        Code,
        Runtime
    } from "aws-cdk-lib/aws-lambda";
    import path = require("path");
    // Other imports ...

    // this is inside the class "Deployment Stack"
    const handler = new Function(this, "RustLambdaFunction", {
      code: Code.fromAsset(
        path.join(
          __dirname,
          "..",
          "..",
          "target/lambda/testing-rust-email-lambda",
        ),
      ),
      runtime: Runtime.PROVIDED_AL2023,
      handler: "whatev",
    });
Enter fullscreen mode Exit fullscreen mode

Now to add a URL so we can trigger this lambda function by visiting a specific web page we can do the following:

    // 1.
    const fnUrl = handler.addFunctionUrl({
      authType: FunctionUrlAuthType.NONE,
    });

    //2.
    new cdk.CfnOutput(this, "Lambda URL", {
      value: fnUrl.url,
    });
Enter fullscreen mode Exit fullscreen mode
  1. Add the function URL to our Lambda function
  2. Output the URL in our CDK output so we can see the generated URL when deploying our code Now lets see i f our code works on production! Run: cdk deploy from our deployment dir. It will ask if you want to deploy the changes it detects in the CDK project, select y.

Open up the URL that's outputted to the console. If all went well you should see "Hello from our Rust Lambda.". You've just successfully written a Lambda Function using Rust!

Creating a Dynamo Table to store email addresses

Lets create a DynamoDB table with the CDK, now this is something we could do in Rust using the AWS SDK however there's a lot more boiler-plate, plus using CDK to manage our infrastructure makes it easy to spin up and down our AWS resources which is nice when experimenting.

Add the following two imports to the deployment-stack.ts:

import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as iam from "aws-cdk-lib/aws-iam";
Enter fullscreen mode Exit fullscreen mode

Now within our DeploymentStack cdk.stack{}:

export class DeplyomentStack extends cdk.stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    // rest of the code we already wrote
        const emailTable = new dynamodb.Table(this, "EmailsTable", {
          tableName: "Emails",
          partitionKey: {
            name: "Id",
            type: dynamodb.AttributeType.STRING,
          },
          removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
}
Enter fullscreen mode Exit fullscreen mode

This will create a new table called "Emails" in our AWS region. We also generate a column called "Id", the partitionKey can be thought of like a primary key if you're from a more traditional SQL world. It's going to be the unique identifier for records in our table.
The removalPolicy is optional, but I've set it to destroy to ensure when I run cdk destroy, the Table is removed in the processes.

Now if we deployed these changes and tried to add items or query our DynamoDB table with our Lambda function we'd run into problems as the Lambda function lacks permissions to interact with our table. Let's change that now:

handler.addToRolePolicy(
  new iam.PolicyStatement({
    actions: ["dynamodb:Scan", "dynamodb:PutItem"],
    resources: [emailTable.tableArn],
  }),
);
Enter fullscreen mode Exit fullscreen mode

We're giving our Lambda function (remember we set it to the variable handler) permissions to "Scan" and "PutItem". In plain English that means we can grab all the items from a table and insert items into a table. We restrict those actions to just our Emails table with the resources: [emailTable.tableArn]. It's best practice in AWS to follow the "Principle of Least Privilege". Meaning give users and services ONLY the permissions they require.

While we're at it, we're going to need to give our Lambda the ability to send emails too:

    handler.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ["ses:SendEmail"],
        resources: ["*"],
      }),
    );
Enter fullscreen mode Exit fullscreen mode

Yay, let's give that another cdk deploy to make sure everything's still working as it should.

Adding emails to our table using the Dynamo SDK and Uuid crate

We need a few creates to send data to our table.
In the Cargo.toml add the following under [dpendencies]

[dependencies]
// Our other dependencies...
aws-sdk-dynamodb = "1.63.0"
aws-sdk-ses = "1.61.0"
aws-config = "1.5.15"
uuid = { version = "1.13.1", features = [ "v4" ] }
Enter fullscreen mode Exit fullscreen mode
  • aws-sdk-dynamodb & aws-sdk-ses are crates managed by the AWS team themselves, for AWS SDK docs go here
  • aws-config - resolves our region and credentials with the SDK clients.
  • uuid - un-related to AWS, just a fast way to create unique identifiers, we'll be using this to create Ids for our Emails table

In our http_handler.rs import these:

use aws_config::BehaviorVersion;
use aws_sdk_dynamodb::{types::AttributeValue, Client as DynamoClient, Error as DynamoError};
Enter fullscreen mode Exit fullscreen mode

Create our client config using aws_config and create a new DynamoDB Client passing in said config:

let config = aws_config::defaults(BehaviorVersion::latest())
    .region("eu-west-2")
    .load()
    .await;

let dynamo_client = DynamoClient::new(&config);
Enter fullscreen mode Exit fullscreen mode

Let's create a function called add_email that we can pass in an email address to add to our DynamoDB table:

// rest of our imports
use uuid::Uuid;
// rest of the function_handler code

async fn add_email(
    client: &DynamoClient,
    email: String,
) -> Result<aws_sdk_dynamodb::operation::put_item::PutItemOutput, DynamoError> {
    let id_av = AttributeValue::S(Uuid::new_v4().to_string());
    let email_av = AttributeValue::S(email);
    let subscribed_av = AttributeValue::Bool(true);

    let req = client
        .put_item()
        .table_name("Emails")
        .item("Id", id_av)
        .item("Email", email_av)
        .item("Subscribed", subscribed_av);

    println!("Executing request [{req:?}] to add an item...");

    let res = req.send().await?;

    println!("Added email!");

    Ok(res)
}
Enter fullscreen mode Exit fullscreen mode

We make the function async as the dynamo db request is asynchronous. This allows us to await the response that comes back from the send() method. We pass in two params: client, which will be the DynamoDB client we created earlier, and email which is the email address we want to add to our table.

To create the correct attribute for Dynamo we use the AttributeValue type, S for our string values id and email, Bool for our subscribed boolean. Then we create our request using the dynamo client and we want to use the put_item() method for adding items to our table, passing in our attributes as items. Finally we send off the request using req.send().await?; and we wrap the response in Ok(res) to return the output.

Let's put this in action to create an email address to make sure it's all working:

// Inside our function_handler() function
add_email(&dynamo_client, String::from("dev@aidanlowson.com")).await?;
Enter fullscreen mode Exit fullscreen mode

If you aren't already running the function locally, go ahead with cargo lambda watch and open up localhost:9000

Hmm, I ran into {"errorType":"&alloc::boxed::Box<dyn core::error::Error + core::marker::Send + core::marker::Sync>","errorMessage":"ResourceNotFoundException: Requested resource not found"}.

My aws config file was set-up with us-east-1 as my default region, not eu-west-2 where I'm looking to add emails in the Rust code. If you're like me and want to deploy/use resources in different regions to your default, open up deploy/bin.deploy.ts

const app = new cdk.App();
new DeployStack(app, "DeployStack", {
    env: {
        region: "eu-west-2",
    }
});
Enter fullscreen mode Exit fullscreen mode

By setting the region environment variable we ensure our resources will be deployed to a specific region when deploying our CDK stack.

After re-running cargo lambda watch I can see we're successfully adding data via the rust aws sdk!
Image description

Query our table to get all emails and parse the data

Imagine we have a table full of hundreds of emails subscribed to us for our great news-letter or something, in order to send an email to our amazing readers we'd first need to grab them from our table, filter the un-subscribed ones out and store the rest of the emails in a Vector.

Create a new function called get_all_items (nice and generic as we could use this to query any Dynamo table):

use std::collections::HashMap;

async fn get_all_items(
    client: &DynamoClient,
    table_name: &String
) -> Result<Vec<HashMap<String, AttributeValue>>, DynamoError>  {
    let mut items = Vec::new();
    let mut last_evaluated_key = None;

    loop {
        let resp = client
            .scan()
            .table_name(table_name)
            .set_exclusive_start_key(last_evaluated_key)
            .send()
            .await?;

        if let Some(new_items) = resp.items {
            items.extend(new_items);
        }

        last_evaluated_key = resp.last_evaluated_key;

        if last_evaluated_key.is_none() {
            break;
        }
    }

    Ok(items)
}
Enter fullscreen mode Exit fullscreen mode

We use a HashMap from the std library to store the items we get back from DynamoDB. The function takes in two params, the Dynamo client and the name of the table we want to query. We create an empty vector to store our items and last_evaluated_key is used to check if there's more items to fetch as the sdk can only bring back so many items at once. This limit can be set by us with the limit() method or it will be automatically limited to 1 MB of data. We loop until last_evaluated_key is none (using the is_none() method as Rust doesn't have a null/undefined type).

We call the client.scan() method, passing in the name of the table we want to scan and again we await this as it's asynchronous. Rust doesn't know if items have returned or not from our response, so we use an if let Some() to check first, and if there are items we call the items.extend() method to append our new_items to our items vector. We then wrap our items in an Ok(), returning either our items or a DynamoDB error.

Let's give our function a run and log out the results:

let raw_emails = get_all_items(&dynamo_client, &String::from("Emails")).await?;

println!("emails returned {:?} ", raw_emails);
Enter fullscreen mode Exit fullscreen mode

Hopefully you see a bunch of emails! Although the format is awkward, let's parse the data into a vector of email strings shall we?

let emails: Vec<String> = raw_emails
    .iter()
    .filter_map(|item| {
        item.get("Subscribed")
            .and_then(|subscribed_object| subscribed_object.as_bool().ok())
            .map(|subbed| *subbed)
            .filter(|subbed| *subbed)
            .and_then(|_| {
                item.get("Email")
                    .and_then(|email_object| match email_object.as_s() {
                        Ok(s) => Some(s.to_string()),
                        Err(_) => None,
                    })
            })
    })
.collect();

println!("Emails: {:?} ", emails);
Enter fullscreen mode Exit fullscreen mode

If you're not well versed in Rust (like me) than this is kinda confusing. And sorry for the Rust pros, because I'm certain there must be a nicer way to write this!
So let me try break it down as simply as I can. Think of what we're doing as a pipeline that does the following steps:

  1. Start with the raw DynamoDB item (hash maps of { String: AttributeValue })
  2. Check if each item is subscribed and remove any that are not subbed
  3. Grab the email addresses of the subscribed items
  4. Return the email string to be added into our vector filter_map lets us filter out items we don't want and transform the data in one go (from AttributValues to Strings).

Send an email to our emails! (finally ey?)

We've already installed the aws-sdk-ses, lets put it to use. Create a new async function async fn send_email.

First off import these treats from aws_sdk_ses

use aws_sdk_ses::{
    operation::send_email::SendEmailOutput,
    types::{Body as EmailBody, Content, Destination, Message},
    Client as SesClient, Error as SesError
};
Enter fullscreen mode Exit fullscreen mode

Then create an ses_client:

let ses_client = SesClient::new(&config);
Enter fullscreen mode Exit fullscreen mode

The function, where the magic will happen:

async fn send_email(ses_client: &SesClient, recipients: Vec<String>) -> Result<SendEmailOutput, SesError> {
    let sender = "dev@aidanlowson.com"; // Put in any email address you have configured in AWS SES
    let subject = String::from("Hello From AWS Lambda!");
    let body_text = String::from("Writen in Rust, served on AWS, pretty cool ey?");
    let body_html = String::from("<html><body><h1>Writen in Rust, served on AWS, pretty cool ey?</h1></body></html>");

    let destination = Destination::builder()
        .set_bcc_addresses(Some(recipients))
        .build();

    let send_email_builder = ses_client
        .send_email()
        .destination(destination)
        .message(
            Message::builder()
                .subject(Content::builder().data(subject).build()?)
                .body(
                    EmailBody::builder()
                        .text(Content::builder().data(body_text).build()?)
                        .html(Content::builder().data(body_html).build()?)
                        .build(),
                )
                .build(),
        )
        .source(sender);


    let response = send_email_builder.send().await?;

    Ok(response)
}
Enter fullscreen mode Exit fullscreen mode

Whey look at that! At lot is happening again. Let's break it down, we take in the ses_client we just made and our email recipients. If you haven't done so already, create an "identity", which will be an email address you can send emails from using SES. Follow the steps on the AWS Console in the SES service to do this. Create a var sender for said identity. We need to actually send some content to our recipients, specifically we need a subject and body. To spruce things up we use a body_html too, allowing us to use a <h1>! Cool! Some email clients will block html so it's important to send both.

The way the sdk works for building the email confuses me a bit, but the docs did make things more clear, if you want to fully understand this stuff I reccomend opening the docs with cargo doc --open and navigate down to aws_sdk_ses. With that being said,

I'll simply run through what's going on at a high level. Let's take the destination as an example:

let destination = Destination::builder()
    .set_bcc_addresses(Some(recipients))
    .build();
Enter fullscreen mode Exit fullscreen mode

We're using a builder pattern, a common way to create complex objects step by step in Rust. Destination::builder() creates a new "builder" object that helps construct a Destination instance. set_bcc_addresses() sets the blind carbon copy addresses for the email. the .build() step finalizes the builder and converts it into a Destnation object that we can use in our email. Thesend_email_builder works using the same pattern but just with more steps as we set the subject, body which in-itself we set both the text and html.

Once we've built our email object, in the send_email_builder var, we call the send() method to post our emails off into the ether. Let's call our function shall we?

send_email(&ses_client, emails).await?;
Enter fullscreen mode Exit fullscreen mode

Well well well, that concludes the rust code, for context here's the full http_handler.rs function:

use std::collections::HashMap;

use lambda_http::{Body, Error, Request, Response};
use aws_config::BehaviorVersion;
use aws_sdk_dynamodb::{types::AttributeValue, Client as DynamoClient, Error as DynamoError};
use aws_sdk_ses::{
    operation::send_email::SendEmailOutput,
    types::{Body as EmailBody, Content, Destination, Message},
    Client as SesClient, Error as SesError
};
use uuid::Uuid;

pub(crate) async fn function_handler(_event: Request) -> Result<Response<Body>, Error> {

    let config = aws_config::defaults(BehaviorVersion::latest())
        .region("eu-west-2")
        .load()
        .await;

    let dynamo_client = DynamoClient::new(&config);
    let ses_client = SesClient::new(&config);

    add_email(&dynamo_client, String::from("dev@aidanlowson.com")).await?;

    let raw_emails = get_all_items(&dynamo_client, &String::from("Emails")).await?;

    let emails: Vec<String> = raw_emails
        .iter()
        .filter_map(|item| {
            item.get("Subscribed")
                .and_then(|subscribed_object| subscribed_object.as_bool().ok())
                .map(|subbed| *subbed)
                .filter(|subbed| *subbed)
                .and_then(|_| {
                    item.get("Email")
                        .and_then(|email_object| match email_object.as_s() {
                            Ok(s) => Some(s.to_string()),
                            Err(_) => None,
                        })
                })
        })
    .collect();

    println!("Emails: {:?} ", emails);

    send_email(&ses_client, emails).await?;

    println!("Emails sent!");

   let resp = Response::builder()
        .status(200)
        .header("content-type", "text/html")
        .body(String::from("Well done, you sent out some emails from AWS SES using the AWS SDK, powered by Rust!").into())
        .map_err(Box::new)?;
    Ok(resp)
}

async fn add_email(
    client: &DynamoClient,
    email: String,
) -> Result<aws_sdk_dynamodb::operation::put_item::PutItemOutput, DynamoError> {
    let id_av = AttributeValue::S(Uuid::new_v4().to_string());
    let email_av = AttributeValue::S(email);
    let subscribed_av = AttributeValue::Bool(true);

    let req = client
        .put_item()
        .table_name("Emails")
        .item("Id", id_av)
        .item("Email", email_av)
        .item("Subscribed", subscribed_av);

    println!("Executing request [{req:?}] to add an item...");

    let res = req.send().await?;

    println!("Added email!");

    Ok(res)
}

async fn get_all_items(
    client: &DynamoClient,
    table_name: &String
) -> Result<Vec<HashMap<String, AttributeValue>>, DynamoError>  {
    let mut items = Vec::new();
    let mut last_evaluated_key = None;

    loop {
        let resp = client
            .scan()
            .table_name(table_name)
            .set_exclusive_start_key(last_evaluated_key)
            .send()
            .await?;

        if let Some(new_items) = resp.items {
            items.extend(new_items);
        }

        last_evaluated_key = resp.last_evaluated_key;

        if last_evaluated_key.is_none() {
            break;
        }
    }

    Ok(items)
}

async fn send_email(ses_client: &SesClient, recipients: Vec<String>) -> Result<SendEmailOutput, SesError> {
    let sender = "dev@aidanlowson.com"; // Put in any email address you have configured in AWS SES
    let subject = String::from("Hello From AWS Lambda!");
    let body_text = String::from("Writen in Rust, served on AWS, pretty cool ey?");
    let body_html = String::from("<html><body><h1>Writen in Rust, served on AWS, pretty cool ey?</h1></body></html>");

    let destination = Destination::builder()
        .set_bcc_addresses(Some(recipients))
        .build();

    let send_email_builder = ses_client
        .send_email()
        .destination(destination)
        .message(
            Message::builder()
                .subject(Content::builder().data(subject).build()?)
                .body(
                    EmailBody::builder()
                        .text(Content::builder().data(body_text).build()?)
                        .html(Content::builder().data(body_html).build()?)
                        .build(),
                )
                .build(),
        )
        .source(sender);


    let response = send_email_builder.send().await?;

    Ok(response)
}
Enter fullscreen mode Exit fullscreen mode

I'm sure there's better ways of doing this, but as a Rust novice you will have to humble me in the comment section and I'd love to refactor and improve this!

Conclusion

I just wanted to play around and see if it was possible to write Lambda functions in Rust and I was impressed with the tools out there to make this possible, Cargo Lambda, the AWS SDK for Rust and the fact Lambda supports Rust as a custom runtime.

I'm still a Rust noob so it takes ages for me to do anything without battling it's strict type safety. It'd be much much quicker to write code in a scripting language like TS or Python. However if performance and reliability are critical, which in many backed services those are both key, then Rust is a fantastic option. Go deserves get an honorable mention as another performant option when it comes to writing Lambda functions!

For the full projects source code visit this GitHub Repo

For programming related content and tutorials like this one please visit me YouTubes

Get in touch via email or LinkedIn

For all links visit me site

Thanks for your eyes and attention,

Aidan

Top comments (0)