Rust製の負荷テストフレームワークGoose入門
はじめに
Rustを使っているとすべてをRustで書きたい欲に駆られることがあります。
たとえば負荷試験ツールもRustで書きたい、みたいなことがあったりします。
ありがたいことにRustではGooseという負荷テストフレームワークがあり、これを使えば負荷テストをRustで実装できます。
ちなみに、Goose
はRust Foundationのメンバーであるtag1が開発しているので安心感があります。[1]
本記事はGoose
について基本的・応用的な使い方などについて紹介していきます。
Gooseとは
Goose
はPython製の負荷テストツールであるLocustにインスパイアされたRust製の負荷テストフレームワークです。
Locust
と比べて、約11倍ほどのトラフィックを生成でき、CPUコアを可能な限り使用してくれます。[2]
またLocust
と違い、フレームワークなのでビルドしたバイナリさえあればどこでも実行が可能なため、可搬性があります。
プロジェクトの構成
本記事ではこちらのリポジトリをもとに説明していきます。
client
はGoose
を使って負荷をかけるクライアントのクレート、server
は負荷の対象サーバーのクレートになります。
$ tree -L 2
.
├── Cargo.lock
├── Cargo.toml
├── client
│ ├── Cargo.toml
│ └── src
├── server
│ ├── Cargo.toml
│ └── src
└── target
├── CACHEDIR.TAG
└── debug
使用する依存クレートは次のとおりです。
[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"
基本的な使い方
まず最初に負荷をかけるサーバーを用意します。
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
リクエストを送るだけにします。
実装の詳細は後ほど説明します。
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:3000
でuser.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!(...)
マクロを使って初期化します。
引数はシナリオ名になります。
次にScenario
のregister_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
にクレートを追加します。
クレート追加
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"
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 }
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の実装
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();
}
最後にシナリオの実装をします。
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(..)
に渡すことでレスポンスをチェックできます。
他にもヘッダーやレスポンスボディもチェックできます。
詳細はドキュメントを参照してみてください。
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(...)
を使う必要があります。
#[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
を用意してテストの条件を宣言的に書けるようにしました。
# 各シナリオで使用するパラメータ
scenarios:
# シナリオ名
- name: "create todo"
params:
# 作成するTodoの数
count: 5
さいごに
Rustで負荷テストツールを実装できるのは個人的によい体験だったので、今後も負荷テストを実施するときはGoose
を使っていこうと思っています。
気になる方はぜひ触ってみてください。
Discussion