Rustインフラストラクチャ:多くのツールがRustで書き直されている理由

1. 概要

ここ数年、多くのインフラストラクチャツールがRustで書き直されています:ファイル検索ツール、リンター、フォーマッター、JavaScriptバンドラー、Pythonパッケージマネージャー、プロキシ、ログ収集エージェントなど。この記事ではRustがインフラストラクチャに適している理由を説明します。その後、Rustで小さなCLIツールを自分で書いて、コンテナにパッケージ化し、Javaアプリケーションから呼び出します。記事の最後には、Rustを選ぶべきではないケースをまとめています。

2. インフラストラクチャに Rust のような言語が必要な理由

インフラストラクチャツールには、通常、共通の特性があります。ファイル保存時やCIビルド時など、非常に何度も実行されます。大量のデータを処理し、数日間安定して動作する必要があります。また、多くの異なるマシンに簡単に配布できる必要があります。

以前は、通常2つのグループの間で選択していました。

  • C/C++:高速で重いランタイムがありません。その代わり、use-after-free、バッファオーバーフロー、データレースなどのメモリエラーが非常に起こりやすく、見つけにくいです。
  • GCまたはインタープリターのある言語(Java、Python、Node.js):安全で生産性が高いです。その代わり、起動時間が長くなり、メモリをより多く消費します。配布する際には、ランタイムも一緒に提供する必要があります。

Rustはこの2つのグループの隙間を狙っています:C程度のパフォーマンスですが、メモリセーフはコンパイラーが検査します。

2.1. GC なしのメモリセーフ

Rustは**所有権(ownership)**によってメモリを管理します。各値は正確に1人のオーナーを持ちます。オーナーがスコープを抜けるとき、メモリが解放されます。コンパイラーはコンパイル時にこれらのルールをチェックします:

fn main() {
    let order_id = String::from("ORD-001");
    let moved = order_id;          // オーナーシップが `moved` に移動
    println!("{moved}");
    println!("{order_id}");        // 既に move された変数を再利用
}

3行目は文字列のオーナーシップをmovedに移動します。それ以降、order_idは有効ではなくなります。5行目でそれを再利用しようとするため、プログラムはコンパイルできません:

error[E0382]: borrow of moved value: `order_id`

Cでは、同様のエラーは通常コンパイルでき、その後実行時にクラッシュしたり、さらに悪い場合はセキュリティ脆弱性になったりする可能性があります。プロキシやパーサーのような高権限で実行されたり、外部からのデータにアクセスするインフラツールの場合、この違いは非常に重要です。

2.2. 並行処理セーフ

インフラツールは通常、複数のコアを活用する必要があります。Rustは2つのトレイトSendとSyncを使用して、コンパイラーがデータレースを持つプログラムを拒否します:

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());
}

それぞれの部分の説明:

  • Arcはスレッド間で安全な参照カウント付きポインタです。
  • Mutexは一度に1つのスレッドだけが数えるカウント変数を変更できることを保証します。
  • moveは各スレッドのクロージャにArcのクローンを移動させます。

結果は常に以下の通りです:

processed = 4000

ArcをRcに置き換えると、スレッド間で安全ではないタイプになり、コンパイラーは`Rc<Mutex<u64>>` cannot be sent between threads safelyと報告します。本番環境に到達するまで待たずにデータレースを検出できます。

2.3. 1つのバイナリファイル、即座に起動

cargo build --releaseはネイティブ実行可能ファイルを生成します。このファイルはJVM、インタープリター、またはnode_modulesを必要としません。これは、いくつかの利点をもたらします:

  • シンプルなインストール:1つのファイルをダウンロードして実行するだけです。
  • 高速起動:エディターまたはpre-commit hookで1分間に何百回も呼び出されるリンターやフォーマッターに適しています。
  • 小さいコンテナイメージ:すべてのノードで実行されるサイドカーとエージェントに適しています。

3. 現在書き直されているツールの種類

具体的な数字がなくても、一般的なツールグループを通じてこのトレンドを確認できます:

  • コマンドラインCLI:grep、find、cat、lsの代わり。一般的な例はripgrepとfdです。
  • 他の言語用のツール:JavaScript/TypeScript、Pythonのリンターとフォーマッター。例:Ruff、Biome、SWC。最近ではPythonパッケージマネージャーのuvもあります。
  • プロキシ、ロードバランサー、サービスメッシュデータプレーン:パフォーマンスとメモリセーフティの両方が重要な場所です。
  • オブザーバビリティエージェント:ログとメトリクスを収集し、すべてのマシンで実行される必要があるため、軽量である必要があります。
  • OSとランタイムコンポーネント:ドライバー、コンテナランタイム、WebAssemblyランタイム。

注目すべき共通点があります。これらのツールのほとんどはユーザーがRustを知ることを要求しません。Rustは実装の詳細です。ユーザーが見るのは、高速で簡単にインストールできるツールだけです。

4. 実践:Rustで CLI logstat を書く

OrderServiceのアクセスログで各HTTPステータスコードの出現回数をカウントする必要があると仮定します。各ログ行はMETHOD PATH STATUS DURATION_MSの形式です。

4.1. プロジェクト宣言

cargo new logstatでプロジェクトを作成し、コマンドラインパラメーターをパースするためのclapライブラリを追加します:

[package]
name = "logstat"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4", features = ["derive"] }

4.2. ソースコード

use clap::Parser;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{self, BufRead, BufReader};

#[derive(Parser)]
#[command(name = "logstat", about = "アクセスログ内のHTTPステータスコードをカウント")]
struct Args {
    /// ログファイルへのパス
    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\ninvalid\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);
    }
}

重要なポイント:

  • #[derive(Parser)]はArgs構造体からパラメーターをパースするコードと--helpコンテンツを生成します。
  • parse_statusはOption<u16>を返します。Optionの?演算子は列が不足している行をスキップするのに役立ちます。.parse().ok()はパースエラーをNoneに変え、ため、不正なデータ行はプログラムをクラッシュさせません。
  • count_statusesは任意のBufReadを受け取ります。コードは行ごとに読み込むため、ログが数GBであっても、ファイル全体をメモリに読み込む必要はありません。
  • BTreeMapはキーを昇順で保持しているため、出力は安定しています。
  • テスト関数は文字列を&[u8]として渡します。これもBufReadを実装しています。これにより、実際のファイルなしでロジックをテストできます。

テストと構築を実行します:

cargo test
cargo build --release
./target/release/logstat access.log

サンプルログファイルの場合、出力は次のようになります:

200 2
201 1
500 1

4.3. コンテナへのパッケージング

Rustバイナリはマルチステージビルドに適しています:

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"]

最初のステージには、コンパイルするための完全なツールチェーンが含まれています。最後のステージにはバイナリと基本的なCライブラリのみが含まれています。最終的なイメージは、JDKやNode.jsを含むイメージよりはるかに小さいです。

注意:scratchイメージを使用する場合は、ターゲットx86_64-unknown-linux-muslで静的ビルドする必要があります。その場合、OpenSSLなどのCライブラリに依存するクレートを注意深く確認してください。

5. Rustツールの Javaシステムへの統合

Spring Boot全体を書き直す必要はありません。最も実用的なアプローチは通常、特定のツールにRustを使用し、Javaが外部プロセスとして呼び出すことです:

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 は終了コード " + exitCode + " で終了しました");
        }
        return result;
    }
}

説明:

  • ProcessBuilderはバイナリを実行し、ログファイルのパスをパラメーターとして渡します。
  • redirectError(INHERIT)はツールのstderrをJavaアプリケーションのログに出力します。これにより、エラーメッセージはJavaが結果をパースしているstdoutに混ざりません。
  • process.inputReader()はstdoutを行ごとに読み込みます。各行はステータスと出現回数に分割されます。
  • 終了コードが0以外の場合、エラーと見なされます。たとえば、ファイルが存在しない場合、mainはErrを返し、Rustプログラムはコード1で終了します。

JUnit 5 と 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をビルドしてから実行してください");

        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を使用すると、バイナリがまだビルドされていないマシンでテストをスキップでき、ビルドが失敗するのではなく。CIでは、mvn testステップの前にcargo build --releaseを実行する必要があります。

プロセス呼び出し以外に、他の2つのオプションがあります:

  • FFIを使用した直接呼び出し:Java 21のForeign Function & Memory APIを使用して、CのABI(extern "C")に従ってエクスポートされたRust関数を呼び出します。この方法はプロセス作成のコストを回避します。その代わり、2つの言語間の境界でのメモリ管理とデータ型がはるかに複雑になります。
  • 独立したサービスへの分離:Rustツールはスタンドアロンで実行され、HTTP/gRPCを介して通信します。このアプローチは、プロキシやエージェントなど、ツールを継続的に実行する必要がある場合に適しています。

ほとんどの場合、プロセス呼び出しは十分にシンプルでデバッグしやすいです。

6. Rustを使用すべきではない場合

Rustはすべてのデフォルトの選択肢ではありません。次の場合は慎重に検討してください。

  • 典型的なCRUDビジネスアプリケーション:Spring BootでOrderとCustomerを管理するサービスは、通常、CPUではなく、データベースとネットワークによって制限されます。Rustで書き直してもユーザーエクスペリエンスが改善されることはほとんどありませんが、確実に開発速度が低下します。
  • 経験不足のチーム:借用チェッカーは急な学習曲線があります。初期段階では、コンパイラーと「議論する」のに多くの時間を費やしてしまいます。1人のRust開発者だけが保守する内部ツールは、実際の運用リスクです。
  • 不十分なエコシステム:Javaには、ORM、メッセージブローカー統合、セキュリティ標準など、非常に成熟したエンタープライズライブラリがあります。いくつかの分野では、Rustはまだ選択肢が少なくなっています。
  • プロトタイプが継続的な変更を必要とする場合:要件がまだ不安定な場合、PythonまたはJavaスクリプトを使用すると、より迅速に実験できます。
  • 唯一の理由が「Rustは高速」である場合:まず測定してください。多くの場合、現在の言語でのプロファイリングとアルゴリズムの最適化で十分です。

開始時によくあるエラー:

注意:デバッグビルド(--releaseなしのcargo build)でパフォーマンスを測定すると、実際より大幅に遅い結果が出ます。常にリリースビルドでベンチマークしてください。

注意:.unwrap()の乱用により、不正なインプットが入力されると、ツールがパニックします。インフラツールの場合は、Resultを返し、明確なエラーメッセージをstderrに出力してください。

注意:unsafeはコンパイラーの一部のチェックを無効にします。小さいモジュール内に制限し、慎重にレビューしてください。借用チェッカーを「だまかす」ためだけにunsafeを使用しないでください。

さらに、Rustのコンパイル時間は大規模プロジェクトではかなり長いです。CIのtargetディレクトリとCargoレジストリのキャッシングを有効にしてください。そうしないと、パイプラインが大幅に遅くなります。

7. まとめ

Rustがインフラに選ばれた理由は、以前は同時に実現することが難しかった3つのことを組み合わせているからです:ネイティブパフォーマンス、コンパイラーが保証するメモリセーフティ、単一のバイナリファイルとして配布できる機能です。これが、多くのCLI、リンター、バンドラー、プロキシ、エージェントがRustで書き直されている理由です。

この記事では、テスト付きのCLIlogstatを書き、マルチステージDocker buildでパッケージし、ProcessBuilderを使用してJavaから呼び出しました。最も合理的なアプローチは、Rustを特定のコンポーネント、頻繁に実行される、またはリソースに敏感なタスクに使用することです。ビジネスロジックは、チームが習熟している言語に保つべきです。何かを書き直す前に、測定して自問してください:メリットは学習と保守のコストをカバーするのに十分ですか?

rusttech-trendsinfrastructureclidevops