back

Rust on AWS App Runner - Part 1

Hey hey hey 🍕!

Over the past few months, I’ve been advocating for Rust on AWS Lambda a lot!

Here is a quick recap in case you missed it!

By the way, we’re almost at 256 ⭐️ which is 🤯 for a side project! I’m sure we can do it!

But as you may know, serverless is NOT only about AWS Lambda. If you need compute, AWS App Runner might be a great fit!

AWS App Runner

If you see Lambda as functions as a service you can see App Runner as containers as a service.

It means that you can focus on

  • building your app
  • containerized it (ie: Docker image)

and AWS will scale it for you, including to zero.

It also means that you will experience a cold start when no containers are up or when you need to handle more traffic.

Similarly to AWS Lambda, Rust would be a great candidate for AWS App Runner if we manage to build a tiny container which starts extremely fast!

Annnnnnd that’s what we are going to try to do in the series of blog post!

Ready? Let’s gooooooo! 🎉

  • Part1 - Create a simple Rust API - this blog post
  • Part2 - Containerize it! (and make it small)
  • Part3 - Deploy it to AWS App Runner

Part 1 - Create a simple Rust API

In this part, we will focus on building a simple Rust service.

Let’s bootstrap the project using cargo and run cargo new pizza-rust --bin (oh yeah we’re going to talk about 🍕)

You should now have this file structure:

file structure

Dependency

There are multiple web framework crates in Rust but one the most commonly used is actix_web so let’s use this one!

We’re also going to need serde to serialize and deserialize our pizzas.

Finally, for testing purpose, we’re going to use both actix-rt abd actix-test

main.rs

Our main function will look like this:

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(move || {
        App::new()
        .service(pizza_service)
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

Here we’ve defined a very minimal web server, listening on 8080 and using the not-yet-defined pizza_service

Test first!

Before going too far in the actual code, let’s write our unit test first. When we hit /pizza, we want to receive a JSON array of pizza.

#[cfg(test)]
mod tests {
    use crate::{pizza_service, Pizza};
    use actix_web::{App, web};

    #[actix_rt::test]
    async fn test() {

        // define a test pizza
        let pizzas = vec![
            Pizza::new("pizzaTest", vec!["topping0", "topping1"], 1234),
        ];

        // define our in-memory db
        let app_data = web::Data::new(pizzas);

        // define our test server
        let srv = actix_test::start(move || 
            App::new()
            .app_data(app_data.clone())
            .service(pizza_service)
        );

        // perform the request
        let req = srv.get("/pizza");
        let mut response = req.send().await.unwrap();
        assert!(response.status().is_success());
        let result_pizza = response.json::<Vec<Pizza>>().await.unwrap();

        // assert the result
        assert!(result_pizza[0].name == "pizzaTest");
        assert!(result_pizza[0].toppings == vec!["topping0", "topping1"]);
        assert!(result_pizza[0].price == 1234);
    }
}

Back to main.rs

Finally, let’s modify our code to satisfy our test

// let's create our pizza structure, making sure we can serialize and deserialize it!
#[derive(Serialize, Deserialize)]
struct Pizza {
    name: String,
    toppings: Vec<String>,
    price: i32,
}

// simple Pizza constructor
impl Pizza {
    fn new(name: &str, toppings: Vec<&str>, price: i32) -> Pizza {
        Pizza {
            name: name.to_string(),
            toppings: toppings.iter().map(|s| s.to_string()).collect(),
            price,
        }
    }
}

// define our GET endpoint
#[get("/pizza")]
async fn pizza_service(data: web::Data<Vec<Pizza>>) -> impl Responder {
    //just returning the JSON representation of our pizzas
    HttpResponse::Ok().json(&data)
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {

    // our fresh pizzas
    let pizzas = vec![
        Pizza::new("Margherita", vec!["tomato", "fior di latte"], 10),
        Pizza::new("Veggie", vec!["green peppers", "onion", "mushrooms"], 12),
    ];

    // in-memory db
    let app_data = web::Data::new(pizzas);

    HttpServer::new(move || {
        App::new()
        .app_data(app_data.clone())
        .service(pizza_service)
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

That’s it! Our test is now passing:

cargo test
   Compiling app-runner-rust v1.0.0 (/rust)
    Finished test [unoptimized + debuginfo] target(s) in 1.27s
     Running unittests src/main.rs (target/debug/deps/app_runner_rust-46a4a4cb852eb0c3)

running 1 test
test tests::test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Final check

Let’s run our API with cargo run and hit our endpoint curl -i http://localhost:8080/pizza

curl -i http://localhost:8080/pizza
HTTP/1.1 200 OK
content-length: 150
content-type: application/json
date: Sun, 20 Aug 2023 00:26:27 GMT

[{"name":"Margherita","toppings":["tomato","fior di latte"],"price":10},{"name":"Veggie","toppings":["green peppers","onion","mushrooms"],"price":12}]

Voila! 🍕

Our API is now ready to be containerized and that’s the topic of the next blog post in this series!

👋 Stay tunned for part 2 next week!

You can find me on LinkedIn, YouTube and Twitter!