🦍

Rust製の負荷テストフレームワークGoose入門

はじめに

Rustを使っているとすべてをRustで書きたい欲に駆られることがあります。
たとえば負荷試験ツールもRustで書きたい、みたいなことがあったりします。

ありがたいことにRustではGooseという負荷テストフレームワークがあり、これを使えば負荷テストをRustで実装できます。
ちなみに、GooseはRust Foundationのメンバーであるtag1が開発しているので安心感があります。[1]

本記事はGooseについて基本的・応用的な使い方などについて紹介していきます。

Gooseとは

GooseはPython製の負荷テストツールであるLocustにインスパイアされたRust製の負荷テストフレームワークです。
Locustと比べて、約11倍ほどのトラフィックを生成でき、CPUコアを可能な限り使用してくれます。[2]

またLocustと違い、フレームワークなのでビルドしたバイナリさえあればどこでも実行が可能なため、可搬性があります。

プロジェクトの構成

本記事ではこちらのリポジトリをもとに説明していきます。
clientGooseを使って負荷をかけるクライアントのクレート、serverは負荷の対象サーバーのクレートになります。

$ tree -L 2
.
├── Cargo.lock
├── Cargo.toml
├── client
│   ├── Cargo.toml
│   └── src
├── server
│   ├── Cargo.toml
│   └── src
└── target
    ├── CACHEDIR.TAG
    └── debug

使用する依存クレートは次のとおりです。

Cargo.toml
[workspace]
resolver = "2"
members = ["client", "server"]

[workspace.dependencies]
goose = "0.17.2"
tokio = { version = "1.36.0", features = ["macros", "rt-multi-thread"]}
axum = "0.7.5"

基本的な使い方

まず最初に負荷をかけるサーバーを用意します。

server/src/main.rs
use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(|| async { "Hello, World!" }));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

次にGooseを使って負荷をかけるシナリオを実装します。
今回は単純にGETリクエストを送るだけにします。
実装の詳細は後ほど説明します。

client/src/main.rs
use goose::prelude::*;

async fn loadtest_index(user: &mut GooseUser) -> TransactionResult {
    let _goose_metrics = user.get("").await?;

    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), GooseError> {
    GooseAttack::initialize()?
        .register_scenario(
            scenario!("LoadtestTransactions")
                .register_transaction(transaction!(loadtest_index).set_name("get index")),
        )
        .execute()
        .await?;

    Ok(())
}

これで準備はできたので、サーバーを起動して実際に負荷をかけると、次のメトリクス結果が出ます。

実行結果
$ cargo run --release -p client -- --host http://localhost:3000 --users 1 --iterations 5 
    Finished release [optimized] target(s) in 0.15s
     Running `target/release/client --host 'http://localhost:3000' --users 1 --iterations 5`
04:47:15 [INFO] Output verbosity level: INFO
04:47:15 [INFO] Logfile verbosity level: WARN
04:47:15 [INFO] users = 1
04:47:15 [INFO] iterations = 5
04:47:15 [INFO] global host configured: http://localhost:3000
04:47:15 [INFO] allocating transactions and scenarios with RoundRobin scheduler
04:47:15 [INFO] initializing 1 user states...
04:47:15 [INFO] WebSocket controller listening on: 0.0.0.0:5117
04:47:15 [INFO] Telnet controller listening on: 0.0.0.0:5116
04:47:15 [INFO] entering GooseAttack phase: Increase
04:47:15 [INFO] launching user 1 from LoadtestTransactions...
04:47:15 [INFO] user 1 completed 5 iterations of LoadtestTransactions...
04:47:15 [INFO] exiting user 1 from LoadtestTransactions...
04:47:15 [INFO] entering GooseAttack phase: Decrease
04:47:15 [INFO] failed to tell user 0 to exit: sending on a closed channel
04:47:15 [INFO] entering GooseAttack phase: Shutdown
04:47:15 [INFO] printing final metrics after 1 seconds...

 === PER SCENARIO METRICS ===
 ------------------------------------------------------------------------------
 Name                     |  # users |  # times run | scenarios/s | iterations
 ------------------------------------------------------------------------------
 1: LoadtestTransactions  |        1 |            5 |        5.00 |       5.00
 ------------------------------------------------------------------------------
 Name                     |    Avg (ms) |        Min |         Max |     Median
 ------------------------------------------------------------------------------
   1: LoadtestTransacti.. |        0.40 |          0 |           2 |          0

 === PER TRANSACTION METRICS ===
 ------------------------------------------------------------------------------
 Name                     |   # times run |        # fails |  trans/s |  fail/s
 ------------------------------------------------------------------------------
 1: LoadtestTransactions 
   1: get index           |             5 |         0 (0%) |     5.00 |    0.00
 ------------------------------------------------------------------------------
 Name                     |    Avg (ms) |        Min |         Max |     Median
 ------------------------------------------------------------------------------
 1: LoadtestTransactions 
   1: get index           |        0.40 |          0 |           2 |          0

 === PER REQUEST METRICS ===
 ------------------------------------------------------------------------------
 Name                     |        # reqs |        # fails |    req/s |  fail/s
 ------------------------------------------------------------------------------
 GET get index            |             5 |         0 (0%) |     5.00 |    0.00
 ------------------------------------------------------------------------------
 Name                     |    Avg (ms) |        Min |         Max |     Median
 ------------------------------------------------------------------------------
 GET get index            |        0.40 |          2 |           2 |          2
 ------------------------------------------------------------------------------
 Slowest page load within specified percentile of requests (in ms):
 ------------------------------------------------------------------------------
 Name                     |    50% |    75% |    98% |    99% |  99.9% | 99.99%
 ------------------------------------------------------------------------------
 GET get index            |      2 |      2 |      2 |      2 |      2 |      2
 ------------------------------------------------------------------------------
 Name                     |                                        Status codes 
 ------------------------------------------------------------------------------
 GET get index            |                                             5 [200]
 -------------------------+----------------------------------------------------
 Aggregated               |                                             5 [200] 

 === OVERVIEW ===
 ------------------------------------------------------------------------------
 Action       Started               Stopped             Elapsed    Users
 ------------------------------------------------------------------------------
 Increasing:  2024-02-27 13:47:15 - 2024-02-27 13:47:15 (00:00:00, 0 -> 1)
 Canceling:   2024-02-27 13:47:15 - 2024-02-27 13:47:15 (00:00:00, 0 <- 1)

 Target host: http://localhost:3000/
 goose v0.17.2
 ------------------------------------------------------------------------------

コマンドライン引数について

先ほどの例で指定したコマンドは次のような挙動になります。

  • --host
    • APIのURL
    • user.get("").await?はURLに続くパスを指定する
    • たとえば--host http://localhost:3000user.get("/login").awaitの場合、http://localhost:3000/loginになる
  • --users
    • tokio-workerに当たる、接続ユーザ数と考えてよい
    • 指定した値の分だけシナリオが実行されるので、シナリオ数より少ない場合は一部のシナリオは実行されない
  • --iterations
    • シナリオを繰り返す回数(ユーザ数 x 繰り返す回数
      • 指定しない場合はCtrl-c(SIGINT)を押すまで繰返し実行される
  • --scenarios
    • 特定のシナリオだけ実行する
    • シナリオ名は--scenarios-listで確認できる
    • --scenarios-list scenario1,scenario2という感じで,を使って複数のシナリオを指定できる

基本的にこれらの引数があれば負荷テストを実行できますが、他にデバッグログを出すこともできたりします。
詳細はThe Goose Bookを参照してください。

メトリクスの読み方

さきほどの例での出力を見て、なんとなく各種項目の意味は分かるかと思いますが、初見ではわからないところもあるかと思います。
The Goose Bookにはあんまりメトリクスについて解説が載っていないので、この節で少し丁寧に説明します。

ちなみにGooseはメトリクスのHTML出力もサポートしています。
詳細はこちらを参照して下さい。

PER SCENARIO METRICS

  • シナリオ単位のメトリクス
  • 上段は回数、下段は実行時間の集計結果
項目 説明
name シナリオ名
user シナリオを実行したユーザ数
times run シナリオを実行した総回数(user x iterations)
scenarios/s 全体シナリオ数に対する割合
iterations 1ユーザが実行したシナリオ数
Avg(ms) シナリオの平均実行時間(ms)
Min シナリオ実行時間の最小値
Max シナリオ実行時間の最大値
Median シナリオ実行時間の中央値
 === PER SCENARIO METRICS ===
 ------------------------------------------------------------------------------
 Name                     |  # users |  # times run | scenarios/s | iterations
 ------------------------------------------------------------------------------
 1: LoadtestTransactions  |        1 |            5 |        5.00 |       5.00
 ------------------------------------------------------------------------------
 Name                     |    Avg (ms) |        Min |         Max |     Median
 ------------------------------------------------------------------------------
   1: LoadtestTransacti.. |        0.40 |          0 |           2 |          0

PER TRANSACTION METRICS

  • トランザクションは複数のリクエストと検証処理をもつ単位
  • シナリオを実現するため、複数のトランザクションを実装することもある
    • たとえばログインして画像をアップロードするといったシナリオを実装する場合は、ログインと画像アップロードはそれぞれトランザクションを分けるといったイメージ
    • しかし、ここは特にGoose固有のルールはないので、各自よしなに分けられる
項目 説明
Name トランザクション名
times run トランザクションを実行した総回数(user x iterations)
fails 失敗したトランザクションの回数
trans/s 1秒間に実行されたリクエスト数
fails/s 1秒間に失敗したリクエスト数
Avg(ms) トランザクションの平均実行時間(ms)
Min トランザクション実行時間の最小値
Max トランザクション実行時間の最大値
Median トランザクション実行時間の中央値
 === PER TRANSACTION METRICS ===
 ------------------------------------------------------------------------------
 Name                     |   # times run |        # fails |  trans/s |  fail/s
 ------------------------------------------------------------------------------
 1: LoadtestTransactions 
   1: get index           |             5 |         0 (0%) |     5.00 |    0.00
 ------------------------------------------------------------------------------
 Name                     |    Avg (ms) |        Min |         Max |     Median
 ------------------------------------------------------------------------------
 1: LoadtestTransactions 
   1: get index           |        0.40 |          0 |           2 |          0

PER REQUEST METRICS

  • リクエストは通常のHTTPリクエスト
  • 1トランザクションに複数のリクエストを実装できる
  • パーセンタイルについて
    • データの分布を示した値
    • 値の小さい順から並べたとき、先頭からの位置がパーセンタイル
    • たとえば100リクエストの内、50%が10ms、75%が22msの場合は、50個リクエストが10ms以内で75個のリクエストが22ms以内に終わったことを示す
    • パーセンタイルに関する解説はこちらを参照
項目 説明
Name シナリオ名
reqs シナリオで成功したリクエストの総数
fails シナリオで失敗したリクエストの総数
req/s リクエスト総数の対する成功した割合
fails/s リクエスト総数の対する失敗した割合
Avg(ms) リクエストの平均実行時間(ms)
Min リクエスト実行時間の最小値
Max リクエスト実行時間の最大値
Median リクエスト実行時間の中央値
Status codes リクエストのレスポンスステータスコード([]の左にあるのは回数)
 === PER REQUEST METRICS ===
 ------------------------------------------------------------------------------
 Name                     |        # reqs |        # fails |    req/s |  fail/s
 ------------------------------------------------------------------------------
 GET get index            |             5 |         0 (0%) |     5.00 |    0.00
 ------------------------------------------------------------------------------
 Name                     |    Avg (ms) |        Min |         Max |     Median
 ------------------------------------------------------------------------------
 GET get index            |        0.40 |          2 |           2 |          2
 ------------------------------------------------------------------------------
 Slowest page load within specified percentile of requests (in ms):
 ------------------------------------------------------------------------------
 Name                     |    50% |    75% |    98% |    99% |  99.9% | 99.99%
 ------------------------------------------------------------------------------
 GET get index            |      2 |      2 |      2 |      2 |      2 |      2
 ------------------------------------------------------------------------------
 Name                     |                                        Status codes 
 ------------------------------------------------------------------------------
 GET get index            |                                             5 [200]
 -------------------------+----------------------------------------------------
 Aggregated               |                                             5 [200] 

シナリオの実装について

冒頭に使用した実装をもとに、シナリオの実装について説明します。

#[tokio::main]
async fn main() -> Result<(), GooseError> {
    GooseAttack::initialize()?
        .register_scenario(
            scenario!("LoadtestTransactions")
                .register_transaction(transaction!(loadtest_index).set_name("get index")),
        )
        .execute()
        .await?;

    Ok(())
}

まず、GooseAttack::initialize()GooseAttackインスタンスを初期化したあとに、register_scenario()メソッドを使ってシナリオを登録していきます。
シナリオはScenario構造体で表現されていて、Scenario::new(...)またはscenario!(...)マクロを使って初期化します。
引数はシナリオ名になります。

次にScenarioregister_transaction(...)を使ってトランザクションを登録していきます。
register_transaction(...)Transaction型を引数にとり、これはトランザクションを表現した構造体となっていて、
Transaction::new(...)またはtransaction!(...)で初期化します。

引数はトランザクションの処理関数で、シグネチャは次のとおりです。

pub type TransactionFunction = Arc<
    dyn for<'r> Fn(
            &'r mut GooseUser,
        ) -> Pin<Box<dyn Future<Output = TransactionResult> + Send + 'r>>
        + Send
        + Sync,
>;

定義だけ見るとちょっとよくわからないかもですが、冒頭の例では次の関数が上記のシグネチャを満たしています。
シンプルにFn(&mut GooseUser) -> TransactionResultと捉えれば良いかなと思います。

async fn loadtest_index(user: &mut GooseUser) -> TransactionResult {
    let _goose_metrics = user.get("").await?;

    Ok(())
}

また、基本的にトランザクションを登録するときはtransaction!()マクロを使うのがよいかと思います。
Transaction::new(...)を使うと場合はtransaction!()マクロと同等の処理を書く必要があって、少し面倒です。

macro_rules! transaction {
    ($transaction_func:ident) => {
        Transaction::new(std::sync::Arc::new(move |s| {
            std::boxed::Box::pin($transaction_func(s))
        }))
    };
}

トランザクションを登録したら、最後にGooseAttack::execute()で負荷テストを開始できます。

シナリオを実装してみる

実装の概要について理解したところで、次のAPIを実装して、負荷テストのシナリオを追加してみます。

API エンドポイント
Todo作成 POST /todos
Todo一覧 GET /todos

まず、Cargo.tomlにクレートを追加します。

クレート追加
Cargo.toml
diff --git a/Cargo.toml b/Cargo.toml
index 25ef06c..a6b9d20 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,4 +5,6 @@ members = ["client", "server"]
 [workspace.dependencies]
 goose = "0.17.2"
 tokio = { version = "1.36.0", features = ["macros", "rt-multi-thread"]}
-axum = "0.7.4"
+axum = "0.7.5"
+serde = { version = "1.0.197", features = ["derive"] }
+serde_json = "1.0.115"
client/Cargo.toml
diff --git a/client/Cargo.toml b/client/Cargo.toml
index 6104750..b89eb7d 100644
--- a/client/Cargo.toml
+++ b/client/Cargo.toml
@@ -8,3 +8,4 @@ edition = "2021"
 [dependencies]
 goose = { workspace = true }
 tokio = { workspace = true }
+serde_json = { workspace = true }
server/Cargo.toml
diff --git a/server/Cargo.toml b/server/Cargo.toml
index d2b91ac..376f3aa 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -7,4 +7,5 @@ edition = "2021"
 
 [dependencies]
 axum = { workspace = true }
+serde = { workspace = true }
 tokio = { workspace = true }

続けてAPIを実装します。

APIの実装
server/src/main.rs
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::{Deserialize, Serialize};
use std::{
    collections::HashMap,
    sync::{Arc, Mutex},
};

#[derive(Clone, Serialize)]
struct Todo {
    id: u32,
    title: String,
    completed: bool,
}

#[derive(Deserialize)]
struct TodoCreateInput {
    title: String,
    completed: bool,
}

async fn todos_create(
    State(state): State<Arc<Mutex<AppState>>>,
    Json(input): Json<TodoCreateInput>,
) -> impl IntoResponse {
    let mut state = state.lock().unwrap();
    let id = state.id + 1;
    state.id = id;

    let todo = Todo {
        id: state.id,
        title: input.title,
        completed: input.completed,
    };

    state.todos.insert(id, todo.clone());
    (StatusCode::CREATED, Json(todo))
}

async fn todos(State(state): State<Arc<Mutex<AppState>>>) -> impl IntoResponse {
    Json(state.lock().unwrap().todos.clone())
}

struct AppState {
    todos: HashMap<u32, Todo>,
    id: u32,
}

#[tokio::main]
async fn main() {
    let state = Arc::new(Mutex::new(AppState {
        todos: HashMap::new(),
        id: 0,
    }));

    let app = Router::new()
        .route("/", get(|| async { "Hello, World!" }))
        .route("/todos", get(todos).post(todos_create))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

最後にシナリオの実装をします。

client/src/main.rs
use goose::prelude::*;

async fn todo_create(user: &mut GooseUser) -> TransactionResult {
    let todo = serde_json::json!({
        "title": "Learn Rust",
        "completed": false,
    });

    let _ = user.post_json("/todos", &todo).await?;

    Ok(())
}

async fn todos(user: &mut GooseUser) -> TransactionResult {
    let _ = user.get("/todos").await?;

    Ok(())
}

#[tokio::main]
async fn main() -> Result<(), GooseError> {
    GooseAttack::initialize()?
        .register_scenario(
            scenario!("create and get todos")
                .register_transaction(transaction!(todo_create).set_name("create todo"))
                .register_transaction(transaction!(todos).set_name("get todos")),
        )
        .execute()
        .await?;

    Ok(())
}

シナリオを実装できたので、実際に実行してみます。

$ cargo run --release -p client -- --host http://localhost:3000 --users 1 --iterations 5
    Finished release [optimized] target(s) in 0.06s
     Running `target/release/client --host 'http://localhost:3000' --users 1 --iterations 5`
(略)
 ------------------------------------------------------------------------------
 Name                     |                                        Status codes 
 ------------------------------------------------------------------------------
 GET get todos            |                                             5 [200]
 POST create todo         |                                             5 [201]
 -------------------------+----------------------------------------------------
 Aggregated               |                                    5 [200], 5 [201] 

 === OVERVIEW ===
 ------------------------------------------------------------------------------
 Action       Started               Stopped             Elapsed    Users
 ------------------------------------------------------------------------------
 Increasing:  2024-04-07 22:43:31 - 2024-04-07 22:43:32 (00:00:01, 0 -> 1)
 Canceling:   2024-04-07 22:43:32 - 2024-04-07 22:43:32 (00:00:00, 0 <- 1)

 Target host: http://localhost:3000/
 goose v0.17.2
 ------------------------------------------------------------------------------

応用編

レスポンスのチェック

シナリオでレスポンスのチェックをしたい場合はgoose-eggsを使います。

次の例ではステータスコードが201であることを確認しています。
Validateで確認する項目を設定していて、validate_page(..)に渡すことでレスポンスをチェックできます。

他にもヘッダーやレスポンスボディもチェックできます。
詳細はドキュメントを参照してみてください。

client/src/main.rs
diff --git a/client/src/main.rs b/client/src/main.rs
index cf0c0b9..4af175f 100644
--- a/client/src/main.rs
+++ b/client/src/main.rs
@@ -1,4 +1,5 @@
 use goose::prelude::*;
+use goose_eggs::{validate_page, Validate};
 
 async fn todo_create(user: &mut GooseUser) -> TransactionResult {
     let todo = serde_json::json!({
@@ -6,7 +7,10 @@ async fn todo_create(user: &mut GooseUser) -> TransactionResult {
         "completed": false,
     });
 
-    let _ = user.post_json("/todos", &todo).await?;
+    let goose_resp = user.post_json("/todos", &todo).await?;
+    let validate = &Validate::builder().status(201).build();
+
+    validate_page(user, goose_resp, validate).await?;
 
     Ok(())
 }

シナリオ間のデータ共有

Gooseにはセッションという、ユーザ単位でシナリオを実行している間にデータを保持できる機能があります。
たとえば認証トークンをセッションに保存して、後続するトランザクションでセッションからトークンを取り出しAPI呼び出す、といったことができます。

次はTodo作成したあとidをセッションに保存して、todoを削除する際にid使う例です。

セッションにデータを保存する際はset_session_data(...)、データを取得するときはget_session_data_unchecked::<T>()を使います。
また、何かしら処理で失敗した場合はset_failure(...)を使う必要があります。

client/src/main.rs
#[derive(Deserialize)]
struct Todo {
    id: u32,
}

struct Session {
    id: u32,
}

async fn todo_create(user: &mut GooseUser) -> TransactionResult {
    let todo = serde_json::json!({
        "title": "Learn Rust",
        "completed": false,
    });

    let mut goose_resp = user.post_json("/todos", &todo).await?;

    match goose_resp.response {
        Ok(resp) => match resp.json::<Todo>().await {
            Ok(todo) => {
                user.set_session_data(Session { id: todo.id });
                Ok(())
            }
            Err(err) => {
                return user.set_failure(
                    "create todo",
                    &mut goose_resp.request,
                    None,
                    Some(err.to_string().as_str()),
                );
            }
        },
        Err(err) => {
            return user.set_failure(
                "create todo",
                &mut goose_resp.request,
                None,
                Some(err.to_string().as_str()),
            );
        }
    }
}

async fn todo_delete(user: &mut GooseUser) -> TransactionResult {
    let session = user.get_session_data_unchecked::<Session>();
    let _ = user.delete(&format!("/todos/{}", session.id)).await?;

    Ok(())
}

シナリオのパラメータを動的に指定する

負荷テストを実施する際に、登録するデータ量を変えたりといった、条件を変更しつつ性能の差分を確認したいことが多いかと思います。
Goose自体にはそれを簡単に実装できる機能は提供されていないので、自分でいい感じに実装する必要があります。

いろいろなやり方があると思いますが、yamlを使った実装例を用意したので、こちらのコミットを参照してください。

やっていることはシンプルで、次のyamlを用意してテストの条件を宣言的に書けるようにしました。

client/params.yaml
# 各シナリオで使用するパラメータ
scenarios:
  # シナリオ名
  - name: "create todo"
    params:
      # 作成するTodoの数
      count: 5

さいごに

Rustで負荷テストツールを実装できるのは個人的によい体験だったので、今後も負荷テストを実施するときはGooseを使っていこうと思っています。
気になる方はぜひ触ってみてください。

脚注
  1. https://book.goose.rs/title-page.html#brought-to-you-by ↩︎

  2. https://book.goose.rs/title-page.html#advantages ↩︎

FRAIMテックブログ

Discussion