Rust in infrastructure: why many tools are rewritten in Rust
1. Overview
In recent years, many infrastructure tools have been rewritten in Rust: file search utilities, linters, formatters, JavaScript bundlers, Python package managers, proxies, and log collection agents. This article explains why Rust is well-suited for infrastructure. Then you’ll write a small CLI in Rust yourself, package it in a container, and call it from a Java application. At the end are cases where you should not choose Rust.
2. Why infrastructure needs a language like Rust
Infrastructure tools typically share some common characteristics. They run very frequently—for example, every time you save a file or every time CI builds. They process large amounts of data and must remain stable over many days. They also need to be easy to distribute to many different machines.
In the past, the choice usually came down to two groups:
- C/C++: fast and no heavy runtime. In exchange, memory errors like use-after-free, buffer overflow, or data race are very easy to introduce and hard to find.
- Languages with GC or interpreters (Java, Python, Node.js): safe and productive. In exchange, startup time is longer and memory usage is higher. When distributing, you also need to bundle the runtime.
Rust aims for the gap between these two groups: performance close to C, but memory safety guaranteed by the compiler.
2.1. Memory safety without garbage collection
Rust manages memory through ownership. Each value has exactly one owner. When the owner goes out of scope, memory is freed. The compiler checks these rules at compile time:
fn main() {
let order_id = String::from("ORD-001");
let moved = order_id; // ownership moves to `moved`
println!("{moved}");
println!("{order_id}"); // reuse a variable that has been moved
}
Line 3 transfers ownership of the string to moved. From that point on, order_id is no longer valid. Line 5 tries to use it again, so the program won’t compile:
error[E0382]: borrow of moved value: `order_id`
In C, a similar error might compile fine and then crash at runtime, or worse, become a security vulnerability. For infrastructure tools running with high privileges or processing external data, like proxies and parsers, this difference is critical.
2.2. Safe concurrency
Infrastructure tools often need to take advantage of multiple cores. Rust uses two traits, Send and Sync, to make the compiler reject programs with data races:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let processed = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();
for _ in 0..4 {
let processed = Arc::clone(&processed);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*processed.lock().unwrap() += 1;
}
}));
}
for h in handles {
h.join().unwrap();
}
println!("processed = {}", *processed.lock().unwrap());
}
What each part does:
Arcis a thread-safe reference-counted pointer.Mutexensures only one thread can modify the counter at a time.movetransfers a clone ofArcinto each thread’s closure.
The result is always:
processed = 4000
If you replace Arc with Rc, which is not thread-safe, the compiler will complain that `Rc<Mutex<u64>>` cannot be sent between threads safely. You don’t need to wait until production to discover race conditions.
2.3. A single binary that starts instantly
cargo build --release produces a native executable file. This file doesn’t need a JVM, doesn’t need an interpreter, and doesn’t need node_modules. This brings several benefits:
- Simple installation: just download one file and run it.
- Instant startup: perfect for linters or formatters called hundreds of times per minute in an editor or pre-commit hook.
- Tiny container images: ideal for sidecars and agents running on every node.
3. Types of tools being rewritten
You don’t need exact numbers to see the trend across popular tool categories:
- Command-line utilities: replacements for
grep,find,cat,ls. Common examples include ripgrep and fd. - Tooling for other languages: linters and formatters for JavaScript/TypeScript and Python. Examples: Ruff, Biome, SWC. Recently, Python package managers like uv.
- Proxies, load balancers, service mesh data planes: where both performance and memory safety matter.
- Observability agents: collecting logs and metrics, running on every machine so they need to be lightweight.
- Operating system and runtime components: drivers, container runtimes, WebAssembly runtimes.
There’s one remarkable commonality. Most of these tools don’t require users to know Rust. Rust is an implementation detail. Users just see a tool that runs fast and is easy to install.
4. Hands-on: writing a logstat CLI in Rust
Suppose you need to count occurrences of each HTTP status code in the access log of OrderService. Each log line has the format METHOD PATH STATUS DURATION_MS.
4.1. Setting up the project
Create a project with cargo new logstat, then add the clap library for command-line argument parsing:
[package]
name = "logstat"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
4.2. Source code
use clap::Parser;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
#[derive(Parser)]
#[command(name = "logstat", about = "Count HTTP status codes in access log")]
struct Args {
/// Path to the log file
path: String,
}
fn parse_status(line: &str) -> Option<u16> {
line.split_whitespace().nth(2)?.parse().ok()
}
fn count_statuses<R: BufRead>(reader: R) -> io::Result<BTreeMap<u16, u64>> {
let mut counts = BTreeMap::new();
for line in reader.lines() {
let line = line?;
if let Some(status) = parse_status(&line) {
*counts.entry(status).or_insert(0) += 1;
}
}
Ok(counts)
}
fn main() -> io::Result<()> {
let args = Args::parse();
let file = File::open(&args.path)?;
let counts = count_statuses(BufReader::new(file))?;
for (status, n) in counts {
println!("{status} {n}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_statuses_and_skips_bad_lines() {
let log = "GET /orders 200 12\nPOST /orders 201 30\nGET /orders 200 9\njunk\n";
let counts = count_statuses(log.as_bytes()).unwrap();
assert_eq!(counts.get(&200), Some(&2));
assert_eq!(counts.get(&201), Some(&1));
assert_eq!(counts.len(), 2);
}
}
Key points:
#[derive(Parser)]generates argument parsing code and--helpcontent from theArgsstruct.parse_statusreturnsOption<u16>. The?operator onOptionskips lines missing the status column..parse().ok()converts parse errors toNone, so malformed lines don’t crash the program.count_statusesaccepts anyBufRead. The code reads line by line so it doesn’t load the entire file into memory, even if the log is many GB.BTreeMapkeeps keys in ascending order, so output is deterministic.- The test function passes a string as
&[u8], which also implementsBufRead. This lets you test the logic without needing a real file.
Run the tests and build:
cargo test
cargo build --release
./target/release/logstat access.log
With a sample log file, the output will look like:
200 2
201 1
500 1
4.3. Packaging into a container
Rust binaries work well with multi-stage builds:
FROM rust:slim AS build
WORKDIR /app
COPY . .
RUN cargo build --release
FROM gcr.io/distroless/cc-debian12
COPY --from=build /app/target/release/logstat /usr/local/bin/logstat
ENTRYPOINT ["logstat"]
The first stage contains the full toolchain to compile. The second stage only contains the binary and a few basic C libraries. The final image is much smaller than an image that includes a JDK or Node.js.
Note: If you want to use a
scratchimage, you need to build statically with the targetx86_64-unknown-linux-musl. Then carefully check if any crates have C library dependencies like OpenSSL.
5. Integrating Rust tools into a Java system
You don’t need to rewrite your entire Spring Boot application. The most practical approach is usually to use Rust for a specialized tool, with Java calling it as an external process:
import java.io.IOException;
import java.nio.file.Path;
import java.util.Map;
import java.util.TreeMap;
public class LogStatClient {
private final Path binary;
public LogStatClient(Path binary) {
this.binary = binary;
}
public Map<Integer, Long> count(Path logFile) throws IOException, InterruptedException {
Process process = new ProcessBuilder(binary.toString(), logFile.toString())
.redirectError(ProcessBuilder.Redirect.INHERIT)
.start();
Map<Integer, Long> result = new TreeMap<>();
try (var reader = process.inputReader()) {
reader.lines().forEach(line -> {
String[] parts = line.split(" ");
result.put(Integer.parseInt(parts[0]), Long.parseLong(parts[1]));
});
}
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new IllegalStateException("logstat exited with code " + exitCode);
}
return result;
}
}
Explanation:
ProcessBuilderruns the binary with the log file path as an argument.redirectError(INHERIT)sends the tool’s stderr to the Java application’s logs. This way, error messages don’t mix with stdout, where Java is parsing results.process.inputReader()reads stdout line by line. Each line is split into status code and count.- Non-zero exit code is treated as an error. For example, if the file doesn’t exist,
mainreturnsErr, and the Rust program exits with code 1.
Test with JUnit 5 and AssertJ:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
class LogStatClientTest {
@TempDir
Path tempDir;
@Test
void countsStatusCodesUsingRustBinary() throws Exception {
Path binary = Path.of("tools/logstat/target/release/logstat");
assumeTrue(Files.isExecutable(binary), "Need to build logstat first");
Path log = tempDir.resolve("access.log");
Files.writeString(log, """
GET /orders 200 12
GET /orders 500 40
GET /customers 200 8
""");
var client = new LogStatClient(binary);
assertThat(client.count(log))
.containsExactly(entry(200, 2L), entry(500, 1L));
}
}
assumeTrue allows skipping the test on machines without a built binary, instead of failing the build. On CI, you should run cargo build --release before the mvn test step.
Besides calling a subprocess, you have two other options:
- Call directly via FFI: use Java 21’s Foreign Function & Memory API to invoke Rust functions exported with C ABI (
extern "C"). This avoids the overhead of spawning a process. However, managing memory and types at the boundary between the two languages is much more complex. - Separate as its own service: the Rust tool runs independently and communicates over HTTP/gRPC. This is useful when the tool needs to run continuously, like a proxy or agent.
For most cases, calling via subprocess is simple enough and easiest to debug.
6. When not to use Rust
Rust is not the default choice for everything. Think carefully in these cases.
- Typical business CRUD applications: a service managing
OrderandCustomerentities with Spring Boot is usually limited by the database and network, not the CPU. Rewriting in Rust rarely improves user experience, but will definitely slow down development speed. - Team without experience: the borrow checker has a steep learning curve. Early on, you’ll spend a lot of time “arguing” with the compiler. An internal tool maintained by only one Rust expert is a real operational risk.
- Ecosystem not mature enough: Java has very mature enterprise libraries—ORMs, message broker integrations, security standards. In some areas, Rust has fewer options.
- Prototypes that change frequently: when requirements aren’t stable, Python or Java scripts let you experiment faster.
- The only reason is “Rust is fast”: measure first. Often profiling and algorithm optimization in your current language is enough.
Some common mistakes when starting:
Note: Benchmarking with debug builds (
cargo buildwithout--release) will show much slower results than reality. Always benchmark with release builds.
Note: Overusing
.unwrap()makes the tool panic on bad input. For infrastructure tools, returnResultand print clear error messages to stderr.
Note:
unsafedisables some of the compiler’s checks. Keep it confined to small modules and review it carefully. Don’t useunsafejust to bypass the borrow checker.
Also, Rust’s compile time is fairly long for large projects. Enable caching for the target directory and Cargo registry on CI, otherwise your pipeline will be noticeably slower.
7. Conclusion
Rust is chosen for infrastructure because it combines three things that were previously hard to have at the same time: native performance, memory safety guaranteed by the compiler, and the ability to distribute as a single binary. That’s why many CLIs, linters, bundlers, proxies, and agents are being rewritten in Rust.
In this article, you wrote the logstat CLI with tests, packaged it with a multi-stage Docker build, and called it from Java via ProcessBuilder. The most sensible approach is to use Rust for specialized components that run frequently or are resource-sensitive. Keep business logic in the language your team is comfortable with. Before rewriting anything, measure and ask yourself: is the benefit large enough to justify the cost of learning and maintenance?