AWS Lambda with Rust and SAM
Tags: aws, rust, lambda, sam
Published: 2023-09-26 20:48:02

> 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](https://github.com/szymon-szym/rust-lambda-blog/tree/AWS-Lambda-with-Rust-and-SAM)

**Set up environment**

Installing Rust is super easy. I am using [rustup](https://www.rust-lang.org/learn/get-started) 
For IaC I use [AWS SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) 
AWS SAM uses (for Rust applications) [cargo lambda](https://www.cargo-lambda.info/guide/installation.html)

My code editor of choice is VS Code with rust-analyzer extension.

**Create project with SAM**

```sam init```

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h8ekuf123quufn31abcu.png)


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8hvqyes2rdflknbs9lag.png)


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vhupbcnrr7j1futlttuc.png)

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:

```yaml
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](https://serverlessland.com/search?search=rust)_ 

**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` 
``` rust
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.
```rust
#[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

```rust
mod models {
    // move Request and Response definitions here
}
```

Now I use rust-analyzer in VS Code to extract the module to the new file
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ennelg7ph3dygi3jkvwn.png)

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`

```rust
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. 
```rust
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. 
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n215l07i1ww0bkwi368i.png)
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
```bash
cargo add serde_json
```
[Documentation](https://serde.rs/enum-representations.html) 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:
```json
    {
    "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):
```rust
#[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. 
```Rust
    println!("{:?}", event.payload);
```
I create a sample event to test my lambda in `events/tempMsg.json`:
```json
{
    "type": "temperatureMessage",
    "sensorId": "tempSesnsor123ABC",
    "temperature": 36.6
}
```
Now I can invoke my function locally
```bash
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. 

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kjoqx8ru4bairlxtjeri.png)

Let's test also faulty input. I create `events/wrongTempMsg.json`:
```json
{
    "sensorId": "tempSesnsor123ABC",
    "temperature": 36.6
}
```
Now after invoking the function with the faulty message, I can see an expected error

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vpyg5ch3hxn9s68ll8kz.png)
Looks good. 

**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. 
```rust
    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. 
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1ggiusun5uk1evofugru.png)

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`

```rust

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](https://www.youtube.com/watch?v=N0XMjokwTIM) 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
```rust
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 
```Rust
    req.send().await?;
```
I also don't need to explicitly return from the function 
```Rust
    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. 
```Rust
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
```Rust
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`
```Rust
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. 
```bash
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. 

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hrgzyv1b0s12jkhx08eo.png)

Result

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uijf662h0yd9swpyrksj.png)

And another one to check hot lambda

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hu88y9z76ai8vm1lhctb.png)

The Dynamo table looks good

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/s50nmzl6h1hxw2y7hzss.png)

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.](https://maxday.github.io/lambda-perf/)_

**Summary**

[Source code for this post is on GitHub](https://github.com/szymon-szym/rust-lambda-blog/tree/AWS-Lambda-with-Rust-and-SAM)

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.