AwRust (part 1)
Hello, S3 (and Hello, Observability)

I’m starting a small project called awrust: a set of lightweight, Docker-first AWS service emulators written in Rust, MIT licensed.
The goal of this first post is to establish a foundation that’s:
easy to run,
easy to debug,
and easy to evolve without regret.
So today we’ll do exactly one thing: ship a minimal server with a /health endpoint, and make sure it logs requests in a way that won’t annoy future me.
The “why”
S3 emulation sounds straightforward until you try to make it compatible with real tooling (AWS SDKs, AWS CLI), and then you realize the API surface is huge and full of edge cases. So instead of building “all of S3”, I’m building a small scoped emulator that’s great for local development and CI, and I’m writing down decisions as I go.
Project setup (workspace + server)
Right now the workspace has two crates:
awrust-s3-domain— domain logic (empty for now)awrust-s3-server— the HTTP server
At this stage, the server listens on 0.0.0.0:4566 and serves a single endpoint:
GET /health→{"status":"ok"}
The code (first vertical slice)
Here’s the entire server for this first step:
use axum::{routing::get, Json, Router};
use serde::Serialize;
use std::net::SocketAddr;
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
use tracing::{info, Level};
use tracing_subscriber::{fmt, EnvFilter};
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
}
#[tokio::main]
async fn main() {
init_tracing();
let app = Router::new()
.route("/health", get(health))
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(Level::INFO))
.on_response(DefaultOnResponse::new().level(Level::INFO)),
);
let addr: SocketAddr = "0.0.0.0:4566".parse().expect("valid listen addr");
info!(service = "s3", %addr, "awrust-s3 server starting");
let listener = tokio::net::TcpListener::bind(addr).await.expect("bind listen addr");
axum::serve(listener, app).await.expect("server error");
}
async fn health() -> Json<HealthResponse> {
Json(HealthResponse { status: "ok" })
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(Level::INFO.to_string()));
fmt()
.json()
.with_env_filter(filter)
.with_current_span(true)
.with_span_list(true)
.init();
}
Why these dependencies?
For now, I’m keeping the stack boring and well-supported:
Tokio — the async runtime (most of Rust’s server ecosystem assumes it)
Axum — minimal HTTP routing on top of Hyper/Tokio
Tracing + tracing-subscriber — structured logs and request-level context
tower-http TraceLayer — request logging (method/path/status/latency) with minimal code
I wrote short ADRs documenting these choices, because future changes are easier when “why” is recorded.
“Why wasn’t it logging requests?”
One small gotcha: TraceLayer logs under the tower_http target, and log levels/filters matter.
I set explicit INFO levels for:
span creation (
make_span_with)response logging (
on_response)
That way, hitting /health reliably prints something like:
{
"level":"INFO",
"target":"tower_http::trace::on_response",
"fields":{"message":"finished processing request","status":200,"latency":"0 ms"},
"span":{"method":"GET","uri":"/health"}
}
This is important early. Debuggability is part of the product.
Running it
From the server crate:
cargo run
Then:
curl -s localhost:4566/health; echo
To adjust log levels:
RUST_LOG=info cargo run
or to go noisier:
RUST_LOG=debug cargo run
What’s next (Part 2)
Now that the server is alive and observable, the next step is the first real “S3-shaped” feature:
a Store trait
a MemoryStore
and the first endpoints:
PUT /<bucket>PUT /<bucket>/<key>GET /<bucket>/<key>
Once those work, I’ll validate the contract using AWS CLI.



