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:
Now for a computationally intense task:
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
- Set-up AWS account and generate a CLI secret key (keep note of your key)
- AWS CLI
- AWS Config (set-up your CLI access key and choose a default region)
- AWS CDK
- Install Rust (prefferably using rustup so you get all the bells and whistles)
- Install cargo lambda Resources on how are in the description AWS SDK For Rust
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
- Extract the query parameters
- Create a message to send as a response
- Build a response
- 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)
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",
});
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,
});
- Add the function URL to our Lambda function
- 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, selecty
.
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";
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,
});
}
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],
}),
);
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: ["*"],
}),
);
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" ] }
-
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};
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);
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)
}
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?;
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",
}
});
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!
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)
}
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);
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);
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:
- Start with the raw DynamoDB item (hash maps of
{ String: AttributeValue }
) - Check if each item is subscribed and remove any that are not subbed
- Grab the email addresses of the subscribed items
- 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 (fromAttributValues
toStrings
).
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
};
Then create an ses_client
:
let ses_client = SesClient::new(&config);
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)
}
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();
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?;
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)
}
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
Thanks for your eyes and attention,
Aidan
Top comments (0)