Rust in der Infrastruktur: Warum Tools in Rust umgeschrieben werden
1. Überblick
In den letzten Jahren wurden viele Infrastruktur-Tools in Rust neu geschrieben: Werkzeuge zur Dateisuche, Linter, Formatter, JavaScript-Bundler, Python-Package-Manager, Proxys und Log-Collection-Agenten. Dieser Artikel erklärt, warum Rust sich für die Infrastruktur-Ebene eignet. Danach schreiben Sie selbst eine kleine CLI in Rust, verpacken sie in einen Container und rufen sie von einer Java-Anwendung auf. Am Ende erfahren Sie, in welchen Fällen Sie Rust nicht wählen sollten.
2. Warum die Infrastruktur eine Sprache wie Rust braucht
Infrastruktur-Tools haben meist einige gemeinsame Merkmale. Sie laufen sehr häufig aus – zum Beispiel jedes Mal, wenn eine Datei gespeichert wird, oder bei jedem CI-Build. Sie verarbeiten große Datenmengen und müssen über viele Tage hinweg stabil laufen. Sie müssen auch leicht auf viele verschiedene Maschinen verteilt werden können.
Bisher lag die Wahl oft zwischen zwei Gruppen:
- C/C++: schnell und ohne schwere Runtime-Last. Dafür sind Speicherfehler wie Use-After-Free, Buffer Overflow oder Data Races sehr leicht zu verursachen und schwer zu finden.
- Sprachen mit GC oder Interpreter (Java, Python, Node.js): sicher und produktiv. Dafür sind Startzeit länger und Speicherverbrauch höher. Bei der Verteilung müssen Sie auch die Runtime mitliefern.
Rust zielt auf die Lücke zwischen diesen beiden Gruppen: Leistung wie C, aber Speichersicherheit, die vom Compiler überprüft wird.
2.1. Speichersicherheit ohne GC
Rust verwaltet Speicher durch Ownership. Jeder Wert hat genau einen Owner. Wenn der Owner den Scope verlässt, wird der Speicher freigegeben. Der Compiler überprüft diese Regeln zur Compile-Zeit:
fn main() {
let order_id = String::from("ORD-001");
let moved = order_id; // Ownership geht auf `moved` über
println!("{moved}");
println!("{order_id}"); // Benutzung einer verschobenen Variablen
}
Zeile 3 überträgt das Ownership des Strings auf moved. Danach ist order_id nicht mehr gültig. Zeile 5 versucht, es erneut zu nutzen, deshalb wird das Programm nicht kompiliert:
error[E0382]: borrow of moved value: `order_id`
In C kann ein ähnlicher Fehler normalerweise kompiliert werden und dann beim Ausführen zu einem Crash oder schlimmer zu einer Sicherheitslücke führen. Bei Infrastruktur-Tools, die mit hohen Privilegien laufen oder mit Daten von außen arbeiten – wie Proxys und Parser – ist dieser Unterschied sehr wichtig.
2.2. Sichere Concurrency
Infrastruktur-Tools müssen oft mehrere Kerne nutzen. Rust verwendet zwei Traits Send und Sync, um dem Compiler zu ermöglichen, Programme mit Data Races abzulehnen:
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());
}
Bedeutung der einzelnen Teile:
Arcist ein Reference-Count-Pointer, der zwischen Threads sicher ist.Mutexstellt sicher, dass nur ein Thread die Zählvariable gleichzeitig ändern kann.moveüberträgt einen Klon vonArcin den Closure jedes Threads.
Das Ergebnis ist immer:
processed = 4000
Wenn Sie Arc durch Rc ersetzen, einen Typ, der zwischen Threads nicht sicher ist, wird der Compiler sagen, dass `Rc<Mutex<u64>>` cannot be sent between threads safely. Sie müssen nicht bis zur Produktion warten, um eine Race Condition zu finden.
2.3. Eine Binary-Datei, sofortige Start
cargo build --release erstellt eine native ausführbare Datei. Diese Datei benötigt keine JVM, keinen Interpreter, auch keine node_modules. Das bringt mehrere Vorteile mit sich:
- Einfache Installation: nur eine Datei herunterladen und ausführen.
- Schneller Start: ideal für Linter oder Formatter, die hunderte Male pro Minute im Editor oder in Pre-Commit-Hooks aufgerufen werden.
- Kleine Container-Images: passend für Sidecars und Agenten, die auf jedem Knoten laufen.
3. Welche Arten von Tools werden gerade umgeschrieben
Ohne konkrete Zahlen können Sie den Trend bereits durch häufige Tool-Kategorien sehen:
- Kommandozeilen-CLIs: Ersatz für
grep,find,cat,ls. Bekannte Beispiele sind ripgrep und fd. - Tooling für andere Sprachen: Linter und Formatter für JavaScript/TypeScript, Python. Beispiele: Ruff, Biome, SWC. Kürzlich auch Python-Package-Manager wie uv.
- Proxy, Load Balancer, Service Mesh Data Plane: Orte, wo sowohl Leistung als auch Speichersicherheit wichtig sind.
- Observability-Agenten: Log- und Metrik-Sammlung, läuft auf jeder Maschine, daher muss es leicht sein.
- Betriebssystem- und Runtime-Komponenten: Treiber, Container Runtime, WebAssembly Runtime.
Ein bemerkenswerter gemeinsamer Punkt: Die meisten dieser Tools erfordern nicht vom Benutzer, Rust zu kennen. Rust ist ein Implementierungsdetail. Der Benutzer sieht nur ein schnelles und leicht zu installierendes Tool.
4. Praxis: CLI logstat in Rust schreiben
Angenommen, Sie müssen die Anzahl der Vorkommen jedes HTTP-Status-Codes in einem Access-Log des OrderService zählen. Jede Log-Zeile hat die Form METHOD PATH STATUS DURATION_MS.
4.1. Projekt-Setup
Erstellen Sie ein Projekt mit cargo new logstat und fügen Sie die clap-Bibliothek zum Parsen von Kommandozeilen-Argumenten hinzu:
[package]
name = "logstat"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
4.2. Quellcode
use clap::Parser;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
#[derive(Parser)]
#[command(name = "logstat", about = "HTTP-Status-Codes im Access-Log zählen")]
struct Args {
/// Pfad zur Log-Datei
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\nMüll\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);
}
}
Wichtige Punkte:
#[derive(Parser)]generiert Code zum Parsen von Argumenten und zum Generieren von--help-Inhalten aus derArgs-Struktur.parse_statusgibtOption<u16>zurück. Der?-Operator aufOptionhilft, Zeilen mit fehlenden Spalten zu überspringen..parse().ok()wandelt einen Parse-Fehler inNoneum, sodass ungültige Zeilen das Programm nicht zum Absturz bringen.count_statusesakzeptiert jedenBufRead. Der Code liest zeilenweise, muss also nicht die gesamte Datei in den Speicher laden, auch wenn das Log viele GB wiegt.BTreeMapsortiert Schlüssel aufsteigend, daher ist die Ausgabe stabil.- Die Test-Funktion übergibt einen String als
&[u8], die auchBufReadimplementiert. So können Sie die Logik testen, ohne echte Dateien zu benötigen.
Test und Build ausführen:
cargo test
cargo build --release
./target/release/logstat access.log
Mit einer Beispiel-Log-Datei sieht die Ausgabe ungefähr so aus:
200 2
201 1
500 1
4.3. In einen Container verpacken
Die Rust-Binary eignet sich hervorragend für 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"]
Die erste Phase enthält den gesamten Toolchain zum Kompilieren. Die letzte Phase enthält nur die Binary und einige grundlegende C-Bibliotheken. Das resultierende Image ist daher viel kleiner als eines mit JDK oder Node.js.
Anmerkung: Wenn Sie ein
scratch-Image verwenden möchten, müssen Sie statisch mit dem Targetx86_64-unknown-linux-muslkompilieren. Überprüfen Sie dann sorgfältig, ob Crates Abhängigkeiten von C-Bibliotheken wie OpenSSL haben.
5. Integration eines Rust-Tools in ein Java-System
Sie müssen nicht die gesamte Spring-Boot-Anwendung neu schreiben. Die realistischste Anwendung ist normalerweise, Rust für ein spezialisiertes Tool zu verwenden, während Java es wie einen externen Prozess aufruft:
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 beendet mit Code " + exitCode);
}
return result;
}
}
Erklärung:
ProcessBuilderstartet die Binary mit dem Pfad der Log-Datei als Argument.redirectError(INHERIT)schickt stderr des Tools an das Protokoll der Java-Anwendung. So werden Fehlermeldungen nicht in stdout vermischt, wo Java das Ergebnis parst.process.inputReader()liest stdout zeilenweise. Jede Zeile wird in Status und Anzahl aufgeteilt.- Ein Exit-Code ungleich 0 wird als Fehler betrachtet. Wenn zum Beispiel die Datei nicht existiert, gibt
mainErrzurück, und das Rust-Programm beendet sich mit Code 1.
Test mit JUnit 5 und 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), "logstat vorher bauen");
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 erlaubt es, Tests auf Maschinen ohne Binary zu überspringen, anstatt den Build fehlschlagen zu lassen. Auf CI sollten Sie cargo build --release vor dem mvn test-Schritt ausführen.
Neben dem Aufruf eines Prozesses haben Sie zwei andere Optionen:
- Direkter Aufruf über FFI: Nutzen Sie die Foreign Function & Memory API von Java 21, um Rust-Funktionen aufzurufen, die mit ABI C exportiert werden (
extern "C"). Das spart die Kosten für das Erstellen eines Prozesses. Dafür ist die Speicher- und Typ-Verwaltung an der Grenze zwischen den beiden Sprachen komplizierter. - Als separaten Service auslagern: Das Rust-Tool läuft eigenständig und kommuniziert über HTTP/gRPC. Das eignet sich, wenn das Tool kontinuierlich laufen muss, wie ein Proxy oder Agent.
Für die meisten Fälle ist der Prozess-Aufruf einfach genug und am einfachsten zu debuggen.
6. Wann Sie Rust nicht verwenden sollten
Rust ist nicht die Standardwahl für alles. Bedenken Sie sorgfältig in den folgenden Fällen:
- Typische CRUD-Geschäftsanwendungen: Ein Service, der
OrderundCustomermit Spring Boot verwaltet, wird normalerweise von Datenbank und Netzwerk begrenzt, nicht von der CPU. Rust-Umschreiben verbessert die Benutzerfahrung selten, verlangsamt aber sicher die Entwicklung. - Team ohne Erfahrung: Der Borrow Checker hat eine steile Lernkurve. Am Anfang werden Sie viel Zeit damit verbringen, mit dem Compiler zu “kämpfen”. Ein internes Tool, das nur von einer Person bekannt ist, ist ein echtes Betriebsrisiko.
- Ökosystem unvollständig: Java hat sehr reife Bibliotheken für Unternehmensanwendungen wie ORM, Message-Broker-Integration und Sicherheitsstandards. In einigen Bereichen hat Rust immer noch weniger Auswahl.
- Prototyp mit ständigen Änderungen: Wenn Anforderungen noch unklar sind, wird ein Python- oder Java-Skript Sie schneller experimentieren lassen.
- Einziger Grund ist “Rust ist schnell”: Bitte messen Sie erst. Oft reicht Profiling und Algorithmus-Optimierung in der aktuellen Sprache aus.
Einige häufige Anfängerfehler:
Anmerkung: Das Messen der Leistung mit der Debug-Version (
cargo buildohne--release) zeigt viel langsamere Ergebnisse als in der Realität. Benchmark immer mit der Release-Version.
Anmerkung: Missbrauch von
.unwrap()lässt das Tool bei schlechten Eingaben panicken. Für Infrastruktur-Tools sollten SieResultzurückgeben und klare Fehlermeldungen auf stderr ausgeben.
Anmerkung:
unsafedeaktiviert einige Compiler-Checks. Halten Sie es in kleinen Modulen und überprüfen Sie sorgfältig. Verwenden Sieunsafenicht nur, um den Borrow Checker zu “umgehen”.
Darüber hinaus ist die Kompilierzeit von Rust bei großen Projekten ziemlich lang. Aktivieren Sie Cache für das target-Verzeichnis und die Cargo-Registry auf CI, sonst wird die Pipeline deutlich langsamer.
7. Fazit
Rust wurde für die Infrastruktur gewählt, weil es drei Dinge vereint, die früher schwer gleichzeitig zu haben waren: native Leistung, durch den Compiler garantierte Speichersicherheit und die Möglichkeit, als eine einzige Binary-Datei zu verteilen. Das ist der Grund, warum viele CLIs, Linter, Bundler, Proxys und Agenten in Rust umgeschrieben werden.
In diesem Artikel haben Sie die CLI logstat mit Tests geschrieben, sie mit Multi-Stage-Docker-Build verpackt und von Java aus mit ProcessBuilder aufgerufen. Der sinnvollste Ansatz ist, Rust für spezialisierte Komponenten zu verwenden, die oft laufen oder ressourcenempfindlich sind. Geschäftslogik sollte in der Sprache bleiben, die Ihr Team beherrscht. Bevor Sie etwas umschreiben, messen Sie und fragen Sie sich: Ist der Gewinn groß genug, um die Kosten für Lernen und Wartung auszugleichen?