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.