Giving up smoking is the easiest thing in the world. I know because I've done it thousands of times
The quote from Mark Twain reminds me of my own experience with learning Rust. I've started to learn it plenty of times, and probably I'll do it again more than once.
I try to be more or less up-to-date with new trends related to the cloud in general and AWS in particular (for professional reasons and out of pure interest). Recently I've stumbled upon a few podcasts' episodes about using Rust in the cloud. I decided to give myself one more chance to explore the exciting world of this language.
It looks that in my case staying up-to-date with cloud technicalities doesn't go that well. That's why I was surprised that AWS uses Rust in its own projects and that Lambda service (Firecracker) is the most famous example. Moreover, AWS is a member of the Rust Foundation.
Okay, so how can I seamlessly start using Rust in the cloud? In this post, I share my experience of creating a simple serverless application with Rust.
Goals
- I plan to check if I can use the language without deeply understanding advanced concepts
- I would love to use tools I am familiar with to create cloud infrastructure (I mean AWS SAM)
Project
My dummy project is a simplified IoT application. Extremely simplified - it will be a lambda function that writes to DynamoDB.
Lambda receives an input message
that can be data from a temperature or from a moisture sensor. Both messages look slightly different and need to be processed differently. Each message will be stored in DynamoDB.
Source code for this post is on GitHub
Set up environment
Installing Rust is super easy. I am using rustup
For IaC I use AWS SAM
AWS SAM uses (for Rust applications) cargo lambda
My code editor of choice is VS Code with rust-analyzer extension.
Create project with SAM
sam init
Once the app is created let's open it in VS Code. If you've worked with SAM already, the general structure would be familiar. In template.yaml
there is a definition of lambda function and API gateway. I've added the DynamoDB table, and removed API part, as it won't be needed, so template looks like this:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
iot-app
Sample SAM Template for iot-app
# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
Function:
Timeout: 3
MemorySize: 128
Tracing: Active
Resources:
HelloWorldFunction:
Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
Metadata:
BuildMethod: rust-cargolambda # More info about Cargo Lambda: https://github.com/cargo-lambda/cargo-lambda
Properties:
CodeUri: ./rust_app # Points to dir of Cargo.toml
Handler: bootstrap # Do not change, as this is the default executable name produced by Cargo Lambda
Runtime: provided.al2
Architectures:
- arm64
Environment:
Variables:
TABLE_NAME: !Ref DynamoSensorsTable
Policies:
## Read more about SAM Policy templates at:
## https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-policy-templates.html
- DynamoDBWritePolicy:
TableName: !Ref DynamoSensorsTable
DynamoSensorsTable:
Type: AWS::DynamoDB::Table
Properties:
AttributeDefinitions:
- AttributeName: sensor_id
AttributeType: S
- AttributeName: timestamp
AttributeType: N
KeySchema:
- AttributeName: sensor_id
KeyType: HASH
- AttributeName: timestamp
KeyType: RANGE
BillingMode: PAY_PER_REQUEST
Outputs:
HelloWorldFunction:
Description: Hello World Lambda Function ARN
Value: !GetAtt HelloWorldFunction.Arn
HelloWorldFunctionIamRole:
Description: Implicit IAM Role created for Hello World function
Value: !GetAtt HelloWorldFunctionRole.Arn
I run sam build --beta-features
just to confirm, that project builds (cargo-lambda is an experimental feature).
All good, let's inspect Rust code
BTW - There are plenty of patterns using Rust on serverlessland, which I found very helpful - link
Function code
Even though code in the new programming language might appear a bit strange, the Rust version looks quite straightforward. The handler code is placed in rust_app/src/main.rs
async fn function_handler(event: LambdaEvent<Request>) -> Result<Response, Error> {
// Prepare the response
let resp = Response {
statusCode: 200,
body: "Hello World!".to_string(),
};
// Return `Response` (it will be serialized to JSON automatically by the runtime)
Ok(resp)
}
What is nice is that a function signature is explicit. I can tell that the function is asynchronous, it takes an event in a specific shape (Request
type) and that the returned result might have two branches - success or failure.
A handler is wrapped inside main
function which is responsible for the initial configuration.
#[tokio::main]
async fn main() -> Result<(), Error> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
// disable printing the name of the module in every log line.
.with_target(false)
// disabling time is handy because CloudWatch will add the ingestion time.
.without_time()
.init();
run(service_fn(function_handler)).await
}
In the file, there are also some types defined, but I will change them in the second.
Define types
So far SAM provided us with a really nice starting point, now it is time to begin implementing our own business logic. Let's start with types. In my project, I want to handle two types of messages with a single function and process them according to the message type.
First, let's move types' definitions to the new file. To do so, I create new module inside main.rs
file
mod models {
// move Request and Response definitions here
}
Now I use rust-analyzer in VS Code to extract the module to the new file
Nice! To be able to import those types, I make them publicly available in the scope of the module. It is OK for now. Eventually I'll hide all logic from handler function anyway.
rust_app/src/models.rs
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct Request {
}
#[derive(Serialize)]
pub struct Response {
pub statusCode: i32,
pub body: String,
}
I need the type for input message which can be one of two variants: information about temperature, or about moisture.
pub struct TemperatureMessage {
sensor_id: String,
temperature: f32
}
pub struct MoistureMessage {
sensor_id: String,
moisture: i32
}
pub enum InputMessage {
TemperatureMessage(TemperatureMessage),
MoistureMessage(MoistureMessage)
}
I use enum for InputMessage
definition. It is because I would love to use powerful pattern matching features provided by Rust for handling the input. I'll return to this in the second.
In main.rs
I switch the Request
type to InputMessage
, but now I see an error inside main
function.
Ok, the handler function needs to know how to deserialize input. It makes sense. Now it's time to decide, how we want to map our types to and from json, which is the input and output for lambda.
Serialization, Deserialization
The most interesting part is how I plan to handle enum as an input. To do so, I will add a new dependency - serde_json
In the root folder of rust_app (the one with Cargo.toml
) I run
cargo add serde_json
Documentation of serde gives pretty nice strategies for serialize/deserialize enums. I stick with internally tagged enums. E.g. if my input is TemperatureMessage, I expect it to look like this:
{
"type": "temperatureMessage",
"sensorId": "tempSesnsor123ABC",
"temperature": 36.6
}
Now my types are annotated in the following way (Debug is needed for printing the structs in the console):
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct TemperatureMessage {
pub sensor_id: String,
pub temperature: f32
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MoistureMessage {
pub sensor_id: String,
pub moisture: i32
}
#[derive(Deserialize, Debug)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum InputMessage {
TemperatureMessage(TemperatureMessage),
MoistureMessage(MoistureMessage)
}
Test serialization
It's time to test if this code even works. Luckily SAM lets test lambda locally, without deploying to AWS.
I added one line at the beginning of the handler to print the event.
println!("{:?}", event.payload);
I create a sample event to test my lambda in events/tempMsg.json
:
{
"type": "temperatureMessage",
"sensorId": "tempSesnsor123ABC",
"temperature": 36.6
}
Now I can invoke my function locally
sam build --beta-features && sam local invoke HelloWorldFunction --event events/tempMsg.json
After downloading the image defined in template.ymal
it works. Input json was properly mapped to our type.
Let's test also faulty input. I create events/wrongTempMsg.json
:
{
"sensorId": "tempSesnsor123ABC",
"temperature": 36.6
}
Now after invoking the function with the faulty message, I can see an expected error
Business logic
I want to handle temperature and moisture messages differently. Rust provides pattern matching, which is a powerful and elegant way to control the flow of the program logic.
match event.payload {
InputMessage::TemperatureMessage(tm) => println!("Temperature is {:?}", tm.temperature),
InputMessage::MoistureMessage(mm) => println!("Moisture is {}", mm.moisture),
}
Pattern matching fits very well into static type system. What is amazing is that if I remove the arm for moisture message, compiler will tell me, that I am not covering all possible cases.
I want to keep my handler as simple as possible, that's why I extract business logic to separate module, let's move it service.rs
use std::io::Error;
use crate::modules::InputMessage;
pub(crate) fn handle_message(event: InputMessage)->Result<String, Error> {
match event {
InputMessage::TemperatureMessage(tm) => println!("Temperature is {:?}", tm.temperature),
InputMessage::MoistureMessage(mm) => println!("Moisture is {}", mm.moisture),
}
Ok(String::from("ok"))
}
SDK
Now it's time to play with SDKs, which are luckily released as a developer preview
BTW there is a great talk from Zelda Hassler explaining how SDKs were built.
In my example, I want to create records in DynamoDB based on message type. Let's start by creating functions for each case.
I need two more dependencies
cargo add aws-config
cargo add aws-sdk-dynamodb
I update service.ts
use std::{ time::{SystemTime, UNIX_EPOCH}, error::Error};
use aws_sdk_dynamodb::Client;
use crate::modules::{InputMessage, TemperatureMessage, MoistureMessage};
// .....
async fn store_temp_msg(client: Client, table_name: &String, tm: TemperatureMessage) -> Result<String, Box<dyn Error>> {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
let req = client
.put_item()
.table_name(table_name)
.item("sensor_id", aws_sdk_dynamodb::types::AttributeValue::S(tm.sensor_id))
.item("message_type", aws_sdk_dynamodb::types::AttributeValue::S("TEMP_MESSAGE".to_string()))
.item("timestamp", aws_sdk_dynamodb::types::AttributeValue::N(timestamp.to_string()))
.item("temperature", aws_sdk_dynamodb::types::AttributeValue::N(tm.temperature.to_string()));
req.send().await?;
Ok("Item saved".to_string())
}
async fn store_moist_msg(client: &Client, table_name: &String, mm: MoistureMessage) -> Result<String, Box<dyn Error>> {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
let req = client
.put_item()
.table_name(table_name)
.item("sensor_id", aws_sdk_dynamodb::types::AttributeValue::S(mm.sensor_id))
.item("message_type", aws_sdk_dynamodb::types::AttributeValue::S("MOIST_MESSAGE".to_string()))
.item("timestamp", aws_sdk_dynamodb::types::AttributeValue::N(timestamp.to_string()))
.item("moisture", aws_sdk_dynamodb::types::AttributeValue::N(mm.moisture.to_string()));
req.send().await?;
Ok("Item saved".to_string())
}
Functions are quite repetitive, but at this point, I leave it this way. In real life, I would expect more specific types and probably some data massaging before putting it to Dynamo.
Code related to using Dynamo is easy to follow, especially with any previous experience with SDKs for other languages.
I don't need to deeply understand how async works for Rust to use SDK. I declare async fn
and then call await
on the results. At this level, it is pretty straightforward.
However, there is some magic related to the Result
. It is possible, because Result
can be mapped and bound (if you have any experience with functional languages you already see what I mean, but let's avoid the "m" word). The practical implications are that inside the function that returns Result
I can add a question mark at the end of the expression to "unwrap" the value
req.send().await?;
I also don't need to explicitly return from the function
Ok("Item saved".to_string())
Now, to be able to call my functions I need to pass the dynamo client there. Due to the fact, that getting the client requires some networking under the hood, I want to initialize it outside the handler.
I init it inside the main
function. To do so I changed the signature of my function_handler and used an anonymous function to "inject" the client into the handler.
async fn function_handler(client: &Client, event: LambdaEvent<InputMessage>) -> Result<Response, Box<dyn Error>> {
// Prepare the response
let response = service::handle_message(event.payload)?;
let resp = Response {
statusCode: 200,
body: "Hello World!".to_string(),
};
// Return `Response` (it will be serialized to JSON automatically by the runtime)
Ok(resp)
}
#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
// disable printing the name of the module in every log line.
.with_target(false)
// disabling time is handy because CloudWatch will add the ingestion time.
.without_time()
.init();
let region_provider = RegionProviderChain::default_provider().or_else("us-east-1");
let config = aws_config::from_env().region(region_provider).load().await;
let client = Client::new(&config);
run(service_fn(|event: LambdaEvent<InputMessage>| {
function_handler(&client, event)
})).await
}
I also changed the Error in the lambda handler, from the one specific for lambda to more generic. It is needed to be able to handle different types of Errors returned from Result
and pipe them all as one type. To be honest, I have no idea if it is not some kind of antipattern. At least the compiler is happy.
Ok, I am almost there. I update my handle_message
function
pub(crate) async fn handle_message(client: &Client, event: InputMessage, table_name: &String)->Result<String, Box<dyn Error>> {
match event {
InputMessage::TemperatureMessage(tm) => store_temp_msg(client, table_name, tm).await,
InputMessage::MoistureMessage(mm) => store_moist_msg(client, table_name, mm).await,
}
}
And, as the last step, function_handler
async fn function_handler(client: &Client, event: LambdaEvent<InputMessage>) -> Result<Response, Box<dyn Error>> {
let table_name = std::env::var("TABLE_NAME")?;
let response = service::handle_message(client, event.payload, &table_name).await?;
let resp = Response {
statusCode: 200,
body: response,
};
// Return `Response` (it will be serialized to JSON automatically by the runtime)
Ok(resp)
}
As you see, I take the Dynamo table name from the environment variable, and I will return an Error
if it is not there.
Test in the cloud
That's it. I now build and deploy the application.
sam build --beta-features
sam deploy --profile <AWS_PROFILE>
After a few minutes, I have the infrastructure live and ready to use. The simplest way is to use test tab directly in the lambda function.
Result
And another one to check hot lambda
The Dynamo table looks good
Yoohoo! It works!
In general, Rust is fast. I didn't try to apply any optimizations. With default settings, the whole execution for a cold start is around 160 ms. It is an amazing result for our use case.
I am not trying to create benchmarks in any meaning. If you need reliable benchmarks for lambdas, there is a great project to check "hello world" cold starts for different languages.
Summary
Source code for this post is on GitHub
I was able to create a lambda function in Rust and deploy it to AWS using SAM. I also utilized SDK for interaction with DynamoDB. None of those actions required a deeper knowledge of the language. A very general experience and some googling were enough in this case.
Even though the example is simplified, I had a chance to appreciate some Rust features. I liked the easiness of serialization/deserialization, explicit errors with Result
, and a powerful type system with structs and products.
The performance of rust-based lambdas is truly amazing but for me, the more important thing is the ergonomics of the language itself.
Long story short - it was fun. I definitely keep exploring Rust and playing with it in the cloud context.
Top comments (0)