Rust trong hạ tầng: vì sao nhiều tool viết lại bằng Rust
1. Tổng quan
Vài năm gần đây, nhiều tool hạ tầng được viết lại bằng Rust: công cụ tìm kiếm file, linter, formatter, bundler JavaScript, trình quản lý package Python, proxy và agent thu thập log. Bài này giải thích vì sao Rust hợp với mảng hạ tầng. Sau đó bạn sẽ tự viết một CLI nhỏ bằng Rust, đóng gói nó vào container và gọi nó từ một ứng dụng Java. Cuối bài là những trường hợp bạn không nên chọn Rust.
2. Vì sao hạ tầng cần một ngôn ngữ như Rust
Tool hạ tầng thường có chung vài đặc điểm. Chúng chạy rất nhiều lần, ví dụ mỗi lần lưu file hay mỗi lần build CI. Chúng xử lý lượng dữ liệu lớn và phải ổn định trong nhiều ngày. Chúng cũng cần dễ phân phối tới nhiều máy khác nhau.
Trước đây lựa chọn thường nằm giữa hai nhóm:
- C/C++: nhanh và không có runtime nặng. Đổi lại, lỗi bộ nhớ như use-after-free, buffer overflow hay data race rất dễ xảy ra và khó tìm.
- Ngôn ngữ có GC hoặc interpreter (Java, Python, Node.js): an toàn và năng suất cao. Đổi lại, thời gian khởi động dài hơn và tốn bộ nhớ hơn. Khi phân phối, bạn còn phải kèm runtime.
Rust nhắm vào khoảng trống giữa hai nhóm này: hiệu năng gần C, nhưng an toàn bộ nhớ được compiler kiểm tra.
2.1. An toàn bộ nhớ mà không cần GC
Rust quản lý bộ nhớ bằng ownership. Mỗi giá trị có đúng một owner. Khi owner ra khỏi scope, bộ nhớ được giải phóng. Compiler kiểm tra các quy tắc này ở thời điểm biên dịch:
fn main() {
let order_id = String::from("ORD-001");
let moved = order_id; // ownership chuyển sang `moved`
println!("{moved}");
println!("{order_id}"); // dùng lại biến đã bị move
}
Dòng 3 chuyển ownership của chuỗi sang moved. Từ đó order_id không còn hợp lệ. Dòng 5 cố dùng lại nó, nên chương trình không biên dịch được:
error[E0382]: borrow of moved value: `order_id`
Trong C, lỗi tương tự có thể biên dịch bình thường rồi gây crash lúc chạy, hoặc tệ hơn là thành lỗ hổng bảo mật. Với tool hạ tầng chạy ở quyền cao hoặc tiếp xúc dữ liệu từ bên ngoài như proxy và parser, khác biệt này rất quan trọng.
2.2. Concurrency an toàn
Tool hạ tầng thường cần tận dụng nhiều core. Rust dùng hai trait Send và Sync để compiler từ chối các chương trình có data race:
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());
}
Ý nghĩa từng phần:
Arclà con trỏ đếm tham chiếu an toàn giữa các thread.Mutexđảm bảo mỗi lần chỉ một thread được sửa biến đếm.movechuyển bản saoArcvào closure của từng thread.
Kết quả luôn là:
processed = 4000
Nếu bạn thay Arc bằng Rc, loại không an toàn giữa các thread, compiler sẽ báo `Rc<Mutex<u64>>` cannot be sent between threads safely. Bạn không cần chờ tới production mới phát hiện race condition.
2.3. Một file binary, khởi động tức thì
cargo build --release tạo ra một file thực thi native. File này không cần JVM, không cần interpreter, cũng không cần node_modules. Điều đó mang lại vài lợi ích:
- Cài đặt đơn giản: chỉ cần tải một file về và chạy.
- Khởi động nhanh: hợp với linter hay formatter được gọi hàng trăm lần mỗi phút trong editor hoặc pre-commit hook.
- Image container nhỏ: hợp với sidecar và agent chạy trên mọi node.
3. Những loại tool đang được viết lại
Không cần số liệu cụ thể, bạn vẫn thấy được xu hướng qua các nhóm tool phổ biến:
- CLI dòng lệnh: thay thế cho
grep,find,cat,ls. Ví dụ quen thuộc là ripgrep và fd. - Tooling cho ngôn ngữ khác: linter và formatter cho JavaScript/TypeScript, Python. Ví dụ: Ruff, Biome, SWC. Gần đây có cả trình quản lý package Python như uv.
- Proxy, load balancer, service mesh data plane: nơi cả hiệu năng lẫn an toàn bộ nhớ đều quan trọng.
- Agent observability: thu thập log và metric, chạy trên mọi máy nên cần nhẹ.
- Thành phần hệ điều hành và runtime: driver, container runtime, WebAssembly runtime.
Có một điểm chung đáng chú ý. Phần lớn các tool này không yêu cầu người dùng biết Rust. Rust là chi tiết cài đặt. Người dùng chỉ thấy một tool chạy nhanh và dễ cài.
4. Thực hành: viết CLI logstat bằng Rust
Giả sử bạn cần đếm số lần xuất hiện của từng HTTP status code trong access log của OrderService. Mỗi dòng log có dạng METHOD PATH STATUS DURATION_MS.
4.1. Khai báo project
Tạo project bằng cargo new logstat, rồi thêm thư viện clap để parse tham số dòng lệnh:
[package]
name = "logstat"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
4.2. Mã nguồn
use clap::Parser;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
#[derive(Parser)]
#[command(name = "logstat", about = "Đếm HTTP status code trong access log")]
struct Args {
/// Đường dẫn tới file 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\nrác\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);
}
}
Các điểm chính:
#[derive(Parser)]sinh code parse tham số và cả nội dung--helptừ structArgs.parse_statustrả vềOption<u16>. Toán tử?trênOptiongiúp bỏ qua dòng thiếu cột..parse().ok()biến lỗi parse thànhNone, nên dòng rác không làm chương trình crash.count_statusesnhận bất kỳBufReadnào. Code đọc theo dòng nên không phải nạp cả file vào bộ nhớ, kể cả khi log nặng nhiều GB.BTreeMapgiữ key theo thứ tự tăng dần, nên output ổn định.- Hàm test truyền chuỗi dưới dạng
&[u8], vốn cũng implementBufRead. Nhờ vậy bạn test được logic mà không cần file thật.
Chạy test và build:
cargo test
cargo build --release
./target/release/logstat access.log
Với file log mẫu, output sẽ có dạng:
200 2
201 1
500 1
4.3. Đóng gói vào container
Binary Rust hợp với multi-stage build:
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"]
Stage đầu chứa toàn bộ toolchain để biên dịch. Stage cuối chỉ chứa binary và vài thư viện C cơ bản. Image cuối vì vậy nhỏ hơn nhiều so với image kèm JDK hay Node.js.
Lưu ý: Nếu muốn dùng image
scratch, bạn cần build static với targetx86_64-unknown-linux-musl. Khi đó hãy kiểm tra kỹ các crate có phụ thuộc thư viện C như OpenSSL.
5. Tích hợp tool Rust vào hệ thống Java
Bạn không cần viết lại cả ứng dụng Spring Boot. Cách áp dụng thực tế nhất thường là dùng Rust cho một tool chuyên biệt, còn Java gọi nó như một tiến trình bên ngoài:
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 thoát với mã " + exitCode);
}
return result;
}
}
Giải thích:
ProcessBuilderchạy binary với đường dẫn file log làm tham số.redirectError(INHERIT)đẩy stderr của tool ra log của ứng dụng Java. Nhờ vậy thông báo lỗi không bị trộn vào stdout, nơi Java đang parse kết quả.process.inputReader()đọc stdout theo dòng. Mỗi dòng được tách thành status và số lần xuất hiện.- Exit code khác 0 được coi là lỗi. Ví dụ, file không tồn tại thì
maintrả vềErr, và chương trình Rust thoát với mã 1.
Test với JUnit 5 và 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), "Cần build logstat trước");
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 cho phép bỏ qua test trên máy chưa build binary, thay vì làm build thất bại. Trên CI, bạn nên chạy cargo build --release trước bước mvn test.
Ngoài cách gọi tiến trình, bạn còn hai lựa chọn khác:
- Gọi trực tiếp qua FFI: dùng Foreign Function & Memory API của Java 21 để gọi hàm Rust export theo ABI của C (
extern "C"). Cách này tránh được chi phí tạo tiến trình. Đổi lại, quản lý bộ nhớ và kiểu dữ liệu ở ranh giới hai ngôn ngữ phức tạp hơn nhiều. - Tách thành service riêng: tool Rust chạy độc lập và giao tiếp qua HTTP/gRPC. Cách này hợp khi tool cần chạy liên tục, chẳng hạn proxy hoặc agent.
Với đa số trường hợp, gọi qua tiến trình là đủ đơn giản và dễ debug nhất.
6. Khi nào không nên dùng Rust
Rust không phải lựa chọn mặc định cho mọi thứ. Hãy cân nhắc kỹ trong các trường hợp sau.
- Ứng dụng nghiệp vụ CRUD thông thường: một service quản lý
OrdervàCustomervới Spring Boot thường bị giới hạn bởi database và network, không phải CPU. Viết lại bằng Rust hiếm khi cải thiện trải nghiệm người dùng, nhưng chắc chắn làm chậm tốc độ phát triển. - Team chưa có kinh nghiệm: borrow checker có đường cong học tập dốc. Giai đoạn đầu, bạn sẽ mất nhiều thời gian “cãi nhau” với compiler. Một tool nội bộ do một người duy nhất biết Rust duy trì là rủi ro vận hành thật sự.
- Hệ sinh thái chưa đủ: Java có những thư viện doanh nghiệp rất trưởng thành, như ORM, tích hợp message broker hay chuẩn bảo mật. Ở một số mảng, Rust vẫn ít lựa chọn hơn.
- Prototype cần thay đổi liên tục: khi yêu cầu chưa ổn định, script Python hoặc Java sẽ giúp bạn thử nghiệm nhanh hơn.
- Lý do duy nhất là “Rust nhanh”: hãy đo trước. Nhiều khi profiling và tối ưu thuật toán trong ngôn ngữ hiện tại đã đủ.
Một số lỗi hay gặp khi bắt đầu:
Lưu ý: Đo hiệu năng bằng bản debug (
cargo buildkhông có--release) sẽ cho kết quả chậm hơn nhiều so với thực tế. Luôn benchmark bằng bản release.
Lưu ý: Lạm dụng
.unwrap()khiến tool panic khi gặp input xấu. Với tool hạ tầng, hãy trả vềResultvà in thông báo lỗi rõ ràng ra stderr.
Lưu ý:
unsafetắt một phần kiểm tra của compiler. Hãy giới hạn nó trong module nhỏ và review kỹ. Đừng dùngunsafechỉ để “qua mặt” borrow checker.
Ngoài ra, thời gian biên dịch của Rust khá dài với project lớn. Hãy bật cache cho thư mục target và registry của Cargo trên CI, nếu không pipeline sẽ chậm đáng kể.
7. Kết luận
Rust được chọn cho hạ tầng vì nó gộp được ba thứ trước đây khó có cùng lúc: hiệu năng native, an toàn bộ nhớ do compiler đảm bảo, và khả năng phân phối dưới dạng một file binary duy nhất. Đó là lý do nhiều CLI, linter, bundler, proxy và agent được viết lại bằng Rust.
Trong bài, bạn đã viết CLI logstat có test, đóng gói nó bằng multi-stage Docker build và gọi nó từ Java qua ProcessBuilder. Cách tiếp cận hợp lý nhất là dùng Rust cho những thành phần chuyên biệt, chạy nhiều lần hoặc nhạy cảm về tài nguyên. Phần nghiệp vụ nên giữ ở ngôn ngữ mà team đang làm chủ. Trước khi viết lại bất cứ thứ gì, hãy đo đạc và tự hỏi: lợi ích có đủ lớn để bù cho chi phí học và duy trì không?