Building a compact microservice with Rust

Posted 2023-04-13 11:00:00 by sanyi ‐ 6 min read

Short introduction to building a really compact microservice with Rust and Axum, using statically linked musl-libc

The purpose of this tutorial is to build a compact hello world microservice in Rust using the Axum framework and compile it statically against musl-libc. The resulting binary can be run in a docker container without any dependencies so we can create a simple from scratch docker image for it.

I tested this tutorial on Ubuntu Linux, so if you use another platform the required steps may be slightly different.

Required tools:

Create a new rust project:


$ cargo init rust-microservice
     Created binary (application) package

Test that it compiles successfully:


$ cd rust-microservice
$ cargo build
   Compiling rust-microservice v0.1.0 (...)
    Finished dev [unoptimized + debuginfo] target(s) in 0.21s

Now add our dependencies to Cargo.toml:

[package]
name = "rust-microservice"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
axum = "0.6.15"
serde = { version = "1.0.160", features = ["derive"] }
serde_json = "1.0.96"
tokio = { version = "1.27.0", features = ["rt", "rt-multi-thread", "macros"] }
  • axum is the web application framework
  • serde provides data serialization - deserialization services
  • serde_json adds JSON support to serde
  • tokio is an async runtime for rust

Now run cargo build again to fetch the dependencies:


$ cargo build

    Updating crates.io index
   Compiling proc-macro2 v1.0.56
   ...
   Compiling rust-microservice v0.1.0 (/home/sapati/Desktop/Code/r-s-t/rust-microservice)
    Finished dev [unoptimized + debuginfo] target(s) in 12.51s

To create our microservice, edit src/main.rs:


use axum::{http::StatusCode, routing::get, Json, Router};
use serde::Serialize;
use std::net::SocketAddr;
use tokio;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(root))
        .route("/hello", get(hello));

    let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

// basic handler that responds with a static string
async fn root() -> &'static str {
    "Hello, World!"
}

async fn hello() -> (StatusCode, Json<Hello>) {
    let hello = Hello {
        message: "Hello World!".to_string(),
    };

    (StatusCode::OK, Json(hello))
}

#[derive(Serialize)]
struct Hello {
    message: String,
}

The main method simply binds an http service to port 3000 and assigns two endpoints: / and /hello:


#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(root))
        .route("/hello", get(hello));

    let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
    axum::Server::bind(&addr)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

The #[tokio::main] macro initializes an async runtime for us.

The root handler method assigned to the / endpoint simply returns the string Hello, World!:


async fn root() -> &'static str {
    "Hello, World!"
}

The hello handler method assigned to the /hello endpoint returns an HTTP 200 status code and a JSON response encapsulating a Hello object:


async fn hello() -> (StatusCode, Json<Hello>) {
    let hello = Hello {
        message: "Hello World!".to_string(),
    };

    (StatusCode::OK, Json(hello))
}

The Hello object only contains a message:


#[derive(Serialize)]
struct Hello {
    message: String,
}

The #[derive(Serialize)] macro adds the serialization capabilities to the Hello struct.

Let's compile our code again:


$ cargo build
   Compiling rust-microservice v0.1.0 (...)
    Finished dev [unoptimized + debuginfo] target(s) in 1.27s

The resulting binary can be found in target/debug/rust-microservice. If you start it, it will listen on port 3000 for requests:


$ ./target/debug/rust-microservice

You can test it with cURL:


$ curl -v http://127.0.0.1:3000/hello

*   Trying 127.0.0.1:3000...
* Connected to 127.0.0.1 (127.0.0.1) port 3000 (#0)
> GET /hello HTTP/1.1
> Host: 127.0.0.1:3000
> User-Agent: curl/7.81.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: application/json
< content-length: 26
< date: Thu, 13 Apr 2023 06:13:00 GMT
< 
* Connection #0 to host 127.0.0.1 left intact

{"message":"Hello World!"}

This binary is dynamically linked as you can verify with ldd:


$ ldd ./target/debug/rust-microservice
	linux-vdso.so.1 (0x00007ffe09314000)
	libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f9915477000)
	libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f9915390000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9914c00000)
	/lib64/ld-linux-x86-64.so.2 (0x00007f99154cd000)

To create a statically linked version we will need the cross utility:


$ cargo install cross

Cross uses docker for the compilation so you will need docker too. The default cargo target is x86_64-unknown-linux-gnu but for the static compilation we will need x86_64-unknown-linux-musl:


$ rustup toolchain install --force-non-host stable-x86_64-unknown-linux-musl

Now we can compile our project for the musl toolchain:


$ cross build --target x86_64-unknown-linux-musl --release

The resulting binary will be in ./target/x86_64-unknown-linux-musl/release/rust-microservice

You can run it and test again with cURL.

If you check the size of the binary it will be around 5-6 MB:


$ du -sk ./target/x86_64-unknown-linux-musl/release/rust-microservice 
5932	./target/x86_64-unknown-linux-musl/release/rust-microservice

This binary still contains a lot of symbols, we can remove them with the strip command:


$ strip ./target/x86_64-unknown-linux-musl/release/rust-microservice 
$ du -sk ./target/x86_64-unknown-linux-musl/release/rust-microservice 
1472	./target/x86_64-unknown-linux-musl/release/rust-microservice

The resulting binary will be only around 1.5 MB

Now add the Dockerfile:

FROM scratch

COPY ./target/x86_64-unknown-linux-musl/release/rust-microservice  /rust-microservice

EXPOSE 3000

ENTRYPOINT [ "/rust-microservice" ]

This will be a really small docker image containing only a single file, our rust based binary.

Build the docker image:


$ docker build -t micro .
$ docker images
REPOSITORY                                      TAG       IMAGE ID       CREATED         SIZE
micro                                           latest    ba2473a8d6f7   3 seconds ago   1.5MB

As you can see, the whole docker image is only 1.5MB.

We can run this docker image with the following command:


$ docker run -p 3000:3000 micro:latest

To load test the service I used the wrk utility:


$ wrk -t 8 -c 100 http://127.0.0.1:3000/hello

Top showed the following results during the test:

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND                                    
3521678 root      20   0   37172   1148    960 S 779,8   0,0   2:59.69 rust-microservi                            
3521636 root      20   0 2480652   5072   2572 S 436,8   0,0   1:40.13 docker-proxy                               
3521777 sapati    20   0  694368   4704   3724 S 181,1   0,0   0:05.47 wrk      

The RES column shows the resident set size of the application (actual memory usage), 1148 kB for the rust-microservice.

Tags:
rust axum musl-libc cross docker