DEV Community

szymon-szym
szymon-szym

Posted on

AWS Lambda with Rust and SAM

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

Image description

Image description

Image description

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

Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Now I use rust-analyzer in VS Code to extract the module to the new file
Image description

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,
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

I create a sample event to test my lambda in events/tempMsg.json:

{
    "type": "temperatureMessage",
    "sensorId": "tempSesnsor123ABC",
    "temperature": 36.6
}
Enter fullscreen mode Exit fullscreen mode

Now I can invoke my function locally

sam build --beta-features && sam local invoke HelloWorldFunction --event events/tempMsg.json
Enter fullscreen mode Exit fullscreen mode

After downloading the image defined in template.ymal it works. Input json was properly mapped to our type.

Image description

Let's test also faulty input. I create events/wrongTempMsg.json:

{
    "sensorId": "tempSesnsor123ABC",
    "temperature": 36.6
}
Enter fullscreen mode Exit fullscreen mode

Now after invoking the function with the faulty message, I can see an expected error

Image description
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.

    match event.payload {
        InputMessage::TemperatureMessage(tm) => println!("Temperature is {:?}", tm.temperature),
        InputMessage::MoistureMessage(mm) => println!("Moisture is {}", mm.moisture),
    }
Enter fullscreen mode Exit fullscreen mode

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

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"))
}

Enter fullscreen mode Exit fullscreen mode

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())

}
Enter fullscreen mode Exit fullscreen mode

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?;
Enter fullscreen mode Exit fullscreen mode

I also don't need to explicitly return from the function

    Ok("Item saved".to_string())
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
    }

}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

Result

Image description

And another one to check hot lambda

Image description

The Dynamo table looks good

Image description

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)