Rust dans l'infrastructure : pourquoi tant d'outils sont réécrits
1. Vue d’ensemble
Ces dernières années, de nombreux outils d’infrastructure ont été réécrits en Rust : moteurs de recherche de fichiers, linters, formatters, bundlers JavaScript, gestionnaires de paquets Python, proxys et agents de collecte de logs. Cet article explique pourquoi Rust est adapté à l’infrastructure. Vous allez ensuite écrire une petite CLI en Rust, l’empaqueter dans un container et l’appeler depuis une application Java. À la fin, vous découvrirez les cas où vous ne devriez pas choisir Rust.
2. Pourquoi l’infrastructure a besoin d’un langage comme Rust
Les outils d’infrastructure ont généralement des caractéristiques communes. Ils s’exécutent très souvent, par exemple à chaque sauvegarde de fichier ou à chaque build CI. Ils traitent de grandes quantités de données et doivent rester stables pendant plusieurs jours. Ils doivent aussi être faciles à distribuer sur de nombreuses machines différentes.
Historiquement, le choix se situait entre deux groupes :
- C/C++ : rapide et sans runtime lourd. En contrepartie, les erreurs mémoire comme use-after-free, buffer overflow ou data race sont très faciles à commettre et difficiles à trouver.
- Langages avec GC ou interpréteur (Java, Python, Node.js) : sûrs et productifs. En contrepartie, le temps de démarrage est plus long et la consommation mémoire plus importante. Lors de la distribution, il faut aussi fournir le runtime.
Rust vise le créneau entre ces deux groupes : performance proche du C, mais sécurité mémoire garantie par le compilateur.
2.1. Sécurité mémoire sans GC
Rust gère la mémoire par ownership. Chaque valeur a exactement un propriétaire. Lorsque le propriétaire sort de la portée, la mémoire est libérée. Le compilateur vérifie ces règles à la compilation :
fn main() {
let order_id = String::from("ORD-001");
let moved = order_id; // propriété transférée à `moved`
println!("{moved}");
println!("{order_id}"); // réutilisation de la variable après move
}
La ligne 3 transfère la propriété de la chaîne à moved. À partir de là, order_id n’est plus valide. La ligne 5 tente de la réutiliser, donc le programme ne se compile pas :
error[E0382]: borrow of moved value: `order_id`
En C, une erreur similaire pourrait se compiler sans problème, puis causer un crash à l’exécution, ou pire devenir une faille de sécurité. Avec les outils d’infrastructure s’exécutant avec des privilèges élevés ou traitant des données externes comme les proxys et parsers, cette différence est très importante.
2.2. Concurrence sûre
Les outils d’infrastructure ont souvent besoin de tirer parti de plusieurs cores. Rust utilise deux traits Send et Sync pour que le compilateur refuse les programmes avec des 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());
}
Signification de chaque partie :
Arcest un pointeur de comptage de références sûr entre threads.Mutexgarantit qu’un seul thread à la fois peut modifier le compteur.movetransfère une copie deArcdans la closure de chaque thread.
Le résultat est toujours :
processed = 4000
Si vous remplacez Arc par Rc, un type non sûr entre threads, le compilateur signalera `Rc<Mutex<u64>>` cannot be sent between threads safely. Vous n’aurez pas à attendre la production pour découvrir une condition de course.
2.3. Un seul binaire, démarrage instantané
cargo build --release crée un exécutable natif unique. Ce fichier n’a besoin ni de JVM, ni d’interpréteur, ni de node_modules. Cela apporte plusieurs avantages :
- Installation simple : il suffit de télécharger un fichier et de l’exécuter.
- Démarrage rapide : idéal pour les linters ou formatters appelés des centaines de fois par minute dans un éditeur ou un hook pre-commit.
- Image container petite : idéal pour les sidecars et agents s’exécutant sur tous les nœuds.
3. Types d’outils en cours de réécriture
Même sans chiffres spécifiques, vous pouvez observer la tendance à travers les groupes d’outils populaires :
- CLI en ligne de commande : remplaçant
grep,find,cat,ls. Des exemples bien connus sont ripgrep et fd. - Outillage pour d’autres langages : linters et formatters pour JavaScript/TypeScript, Python. Exemples : Ruff, Biome, SWC. Récemment aussi des gestionnaires de paquets Python comme uv.
- Proxys, load balancers, service mesh data planes : où la performance et la sécurité mémoire sont toutes deux cruciales.
- Agents d’observabilité : collecte de logs et de métriques, s’exécutant sur toutes les machines, donc doivent être légers.
- Composants du système d’exploitation et du runtime : drivers, container runtimes, WebAssembly runtimes.
Un point commun remarquable : la plupart de ces outils ne demandent pas à l’utilisateur de connaître Rust. Rust est un détail d’implémentation. L’utilisateur voit simplement un outil rapide et facile à installer.
4. Pratique : écrire la CLI logstat en Rust
Supposons que vous ayez besoin de compter les occurrences de chaque code de statut HTTP dans les access logs du service OrderService. Chaque ligne de log a la forme METHOD PATH STATUS DURATION_MS.
4.1. Déclaration du projet
Créez un projet avec cargo new logstat, puis ajoutez la bibliothèque clap pour parser les arguments :
[package]
name = "logstat"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
4.2. Code source
use clap::Parser;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
#[derive(Parser)]
#[command(name = "logstat", about = "Compte les codes de statut HTTP dans les access logs")]
struct Args {
/// Chemin vers le fichier log
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\npoubelle\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);
}
}
Points clés :
#[derive(Parser)]génère le code pour parser les arguments et même le contenu de--helpà partir de la structArgs.parse_statusretourneOption<u16>. L’opérateur?surOptionpermet de sauter les lignes manquant une colonne..parse().ok()convertit une erreur de parsing enNone, donc les lignes incorrectes ne plantent pas le programme.count_statusesaccepte n’importe quelBufRead. Le code lit ligne par ligne donc il ne charge pas le fichier entier en mémoire, même si le log fait plusieurs GB.BTreeMapgarde les clés triées par ordre croissant, donc la sortie est stable.- La fonction de test passe une chaîne sous la forme
&[u8], qui implémente aussiBufRead. Vous pouvez ainsi tester la logique sans fichier réel.
Exécutez les tests et la compilation :
cargo test
cargo build --release
./target/release/logstat access.log
Avec un fichier log d’exemple, la sortie ressemblerait à :
200 2
201 1
500 1
4.3. Empaquetage dans un container
Un binaire Rust est idéal pour une compilation multi-stage :
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"]
Le premier stage contient l’intégralité de la chaîne d’outils pour compiler. Le dernier stage ne contient que le binaire et quelques bibliothèques C de base. L’image finale est donc beaucoup plus petite qu’une image incluant le JDK ou Node.js.
Note : Si vous voulez utiliser l’image
scratch, vous devez compiler statiquement avec la ciblex86_64-unknown-linux-musl. Vérifiez ensuite attentivement si les crates ont des dépendances en bibliothèques C comme OpenSSL.
5. Intégration d’outils Rust dans les systèmes Java
Vous n’avez pas besoin de réécrire l’application Spring Boot entière. L’approche la plus pratique en production consiste à utiliser Rust pour un outil spécialisé, tandis que Java l’appelle comme un processus externe :
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 s'est terminé avec le code " + exitCode);
}
return result;
}
}
Explication :
ProcessBuilderexécute le binaire avec le chemin du fichier log comme paramètre.redirectError(INHERIT)redirige stderr de l’outil vers les logs de l’application Java. Ainsi les messages d’erreur ne se mélangent pas à stdout, où Java parse le résultat.process.inputReader()lit stdout ligne par ligne. Chaque ligne est séparée en code de statut et nombre d’occurrences.- Un code de sortie différent de 0 est considéré comme une erreur. Par exemple, si le fichier n’existe pas,
mainretourne uneErr, et le programme Rust se termine avec le code 1.
Test avec JUnit 5 et 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), "Il faut compiler logstat avant");
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 permet de sauter le test sur une machine où le binaire n’a pas été compilé, au lieu de faire échouer la build. Sur CI, vous devriez exécuter cargo build --release avant mvn test.
En dehors de l’appel de processus, vous avez deux autres options :
- Appel direct via FFI : utilisez l’API Foreign Function & Memory de Java 21 pour appeler des fonctions Rust exportées selon l’ABI C (
extern "C"). Cette approche évite le coût de créer un processus. En contrepartie, gérer la mémoire et les types de données à la frontière entre les deux langages est beaucoup plus complexe. - Séparation en service indépendant : l’outil Rust s’exécute de manière autonome et communique via HTTP/gRPC. Cette approche convient quand l’outil doit s’exécuter continuellement, comme un proxy ou agent.
Dans la plupart des cas, appeler via un processus est suffisamment simple et plus facile à déboguer.
6. Quand ne pas utiliser Rust
Rust n’est pas le choix par défaut pour tout. Réfléchissez-y attentivement dans les cas suivants.
- Applications CRUD métier classiques : un service gérant des
OrderetCustomeravec Spring Boot est généralement limité par la base de données et le réseau, pas le CPU. Réécrire en Rust améliore rarement l’expérience utilisateur, mais ralentit certainement la vitesse de développement. - Équipe sans expérience : le borrow checker a une courbe d’apprentissage abrupte. Au début, vous perdrez beaucoup de temps à “discuter” avec le compilateur. Un outil interne maintenu par une seule personne connaissant Rust est un vrai risque opérationnel.
- Écosystème insuffisant : Java dispose de bibliothèques entreprise très matures, comme les ORM, l’intégration de message brokers ou les normes de sécurité. Dans certains domaines, Rust a moins d’options.
- Prototype changeant fréquemment : quand les exigences ne sont pas stables, un script Python ou Java vous permettra d’expérimenter plus rapidement.
- Seule raison : “Rust est rapide” : mesurez d’abord. Souvent, le profiling et l’optimisation de l’algorithme dans le langage actuel suffisent.
Quelques erreurs courantes au démarrage :
Note : Mesurer la performance avec une build debug (
cargo buildsans--release) donnera des résultats bien plus lents que la réalité. Toujours benchmarker avec la build release.
Note : Abuser de
.unwrap()fait que l’outil panique sur une mauvaise entrée. Avec les outils d’infrastructure, retournez uneResultet affichez un message d’erreur clair sur stderr.
Note :
unsafedésactive une partie des vérifications du compilateur. Limitez-le à des modules minuscules et relisez attentivement. N’utilisez pasunsafejuste pour “tromper” le borrow checker.
De plus, le temps de compilation Rust est assez long sur les gros projets. Activez le cache du répertoire target et du registre Cargo sur CI, sinon le pipeline sera beaucoup plus lent.
7. Conclusion
Rust est choisi pour l’infrastructure parce qu’il combine trois choses difficiles à avoir ensemble auparavant : performance native, sécurité mémoire garantie par le compilateur, et capacité à distribuer sous forme d’un seul binaire unique. C’est pourquoi de nombreuses CLI, linters, bundlers, proxys et agents sont réécrits en Rust.
Dans cet article, vous avez écrit la CLI logstat avec des tests, l’avez empaqueté avec une compilation Docker multi-stage et l’avez appelée depuis Java via ProcessBuilder. L’approche la plus raisonnable consiste à utiliser Rust pour les composants spécialisés, s’exécutant souvent ou sensibles aux ressources. La logique métier devrait rester dans un langage que votre équipe maîtrise. Avant de réécrire quoi que ce soit, mesurez et posez-vous la question : le bénéfice est-il assez grand pour justifier le coût d’apprentissage et de maintenance ?