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!
- Live coding a AWS Lambda in Rust with TDD
- Lambda-Perf tool where Rust is killing the cold start game
By the way, we’re almost at 256 ⭐️ which is 🤯 for a side project! I’m sure we can do it!
- Rust Linz Meetup where I gave a talk about Serverless + Rust
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:
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!