k6とTypeScriptで始める負荷テスト
負荷テスト、やってます?
明らかなボトルネックが見えていて、かつ、シンプルなワークロードに落とし込めるなら、自作のシェルスクリプトやシンプルなCLIアプリ[1]で十分な場合もありますが、ログインして情報を取ってきてそれに基づいてデータを作る...みたいな、ある程度複雑なユーザーシナリオを組もうとすると、何らかツールやフレームワークが必要になります。
YAML(Taurus[2]など)やXML(Apache JMeter[3]など)でも良いのですが、より強力な表現力を得ようとすると、慣れたプログラミング言語で記述したくなります。
本投稿では、JavaScript/TypeScriptで負荷テストを記述・実行できる、Grafana k6を紹介します。
Grafana k6とは
Grafana Lab社が開発するOSSの負荷テストツールです。
インストール
実行環境ごとの方法でインストールします。
バイナリも提供されています。
MacOSであればbrew installできます。
$ brew install k6
$ k6 version
k6 v0.55.0 (go1.23.3, darwin/arm64)
特徴
- JavaScriptでテストコードを実装できる
- 実験的ですがTypeScriptにも対応しています(後述)
- k6そのものはGoで実装されていて、JavaScriptのテストコードはsobek[4](Gojaのフォーク)にて実行される
- なのでブラウザやNode.jsのAPIは使えない
- 仮想ユーザー(VU)ごとに並列実行される
- VUごとにゴルーチンが作られ、それぞれがJavaScriptテストコードを実行する
テストコードの実装
テストコードの書き方を見ていきます。
事前に作業用ディレクトリを切って、@types/k6
が型情報なのでインストールしておきます。あわせて、テスト対象となるAPIサーバーとして、httpbin[5]をローカルに立てておきます。
# 作業用ディレクトリの作成
$ mkdir k6-sandbox
$ cd k6-sandbox
# 型情報のインストール
$ npm install --save-dev @types/k6
# テスト対象のAPIサーバーの起動
$ docker run -p 8000:80 kennethreitz/httpbin
スクリプトの作成(k6 new)
k6 new
とすると雛形となるテストコードがJavaScriptで生成されますので、拡張子を.js
=>.ts
で変更しておきます。
-
default function()
がテストロジックの実装で、VUとして並列実行される- (以降、VU関数と呼びます)
- テストの設定は
options
で与える(設定可能なパラメータ)- vusは10なので、最大10並列でdefault function()が実行される
- durationは30sなので、テストは30秒間行われる
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
vus: 10,
duration: '30s',
};
export default function() {
http.get('http://localhost:8000/get'); // (ローカルに立てたhttpbinサーバーに書き換え)
sleep(1); // 1秒VUを停止する
}
このテストコードをk6コマンドで実行します。
TypeScriptコードの場合、--compatibility-mode=experimental_enhanced
を指定します。これによってesbuildでトランスパイルされた後に実行されるようになります。[6]
$ k6 run --compatibility-mode=experimental_enhanced script.ts
実行結果
/\ Grafana /‾‾/
/\ / \ |\ __ / /
/ \/ \ | |/ / / ‾‾\
/ \ | ( | (‾) |
/ __________ \ |_|\_\ \_____/
execution: local
script: script.ts
output: -
scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration (incl. graceful stop):
* default: 10 looping VUs for 30s (gracefulStop: 30s)
data_received..................: 124 kB 4.1 kB/s
data_sent......................: 25 kB 821 B/s
http_req_blocked...............: avg=75.25µs min=1µs med=5µs max=2.14ms p(90)=13µs p(95)=26µs
http_req_connecting............: avg=22.13µs min=0s med=0s max=723µs p(90)=0s p(95)=0s
http_req_duration..............: avg=9.24ms min=953µs med=8.19ms max=31.47ms p(90)=17.93ms p(95)=20.66ms
{ expected_response:true }...: avg=9.24ms min=953µs med=8.19ms max=31.47ms p(90)=17.93ms p(95)=20.66ms
http_req_failed................: 0.00% 0 out of 300
http_req_receiving.............: avg=144.25µs min=10µs med=61µs max=2.7ms p(90)=334.2µs p(95)=440.19µs
http_req_sending...............: avg=51.81µs min=2µs med=12µs max=1.36ms p(90)=39.1µs p(95)=112.04µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=9.04ms min=897µs med=8.03ms max=31.43ms p(90)=17.72ms p(95)=20.56ms
http_reqs......................: 300 9.895958/s
iteration_duration.............: avg=1.01s min=1s med=1s max=1.03s p(90)=1.01s p(95)=1.02s
iterations.....................: 300 9.895958/s
vus............................: 10 min=10 max=10
vus_max........................: 10 min=10 max=10
running (0m30.3s), 00/10 VUs, 300 complete and 0 interrupted iterations
default ✓ [======================================] 10 VUs 30s
k6/http
k6でのリクエストはk6/httpモジュールで行います。
HTTPリクエストメソッドに対応するメソッド(get()、post()など)が定義されており、第1引数にurl、第2引数以降に各種パラメータを指定します。
パラメータには、ヘッダーやクッキー、タグ(後述)などを指定できます。
https://grafana.com/docs/k6/latest/javascript-api/k6-http/params/
import http from "k6/http";
export default function () {
const res = http.get("http://localhost:8000/get", {
headers: { "X-Hoge": "fuga" },
cookies: { bar: "buz" },
});
console.log(res.body);
// {
// "args": {},
// "headers": {
// "Cookie": "bar=buz",
// "Host": "localhost:8000",
// "User-Agent": "k6/0.55.0 (https://k6.io/)",
// "X-Hoge": "fuga"
// },
// "origin": "192.168.65.1",
// "url": "http://localhost:8000/get"
// }
}
インスタンス、シナリオ、VU
VU関数はVU(=ゴルーチン)で実行されますが、そのVUはインスタンス(=プロセス)で実行されます。k6はk6 cloud[7]などの分散環境に対応しており、その場合はスクリプトが別のプロセスで実行されることになります。
またVUは、ワークロードの負荷を柔軟に再現するために、シナリオという単位でスケジュールされます(シナリオについては後述)。
まとめると、VUはシナリオに、シナリオはインスタンスにそれぞれ属するという関係になっています。
実行時の情報(exec)
実行時の情報は、exec(k6/execution)で取得できます。
https://grafana.com/docs/k6/latest/using-k6/execution-context-variables/
VUの連番IDであるvu.idInTestを使ってVUごとにユーザーIDを被らないように割り当てる、といったユースケースが実現できます。
import exec from "k6/execution";
export default function () {
// テスト全体の設定
console.log("Test options:", exec.test.options);
// インスタンス内での完了イテレーション数
console.log("Instance iterationsCompleted:", exec.instance.iterationsCompleted);
// シナリオ内でのイテレーション数
console.log("Scenario iterationInTest:", exec.scenario.iterationInTest);
// VUのID
console.log("VU idInTest:", exec.vu.idInTest);
// VUのインスタンス内でのイテレーション数
console.log("VU iterationInInstance:", exec.vu.iterationInInstance);
// VUのシナリオ内でのイテレーション数
console.log("VU iterationInScenario:", exec.vu.iterationInScenario);
}
初期化処理と終了処理
テストの最初と最後で実行する処理を記述することもできます。
https://grafana.com/docs/k6/latest/using-k6/test-lifecycle/
- setupが1回 => VU関数がVU数*イテレーション数 => teardownが1回 の順で実行される
- シナリオに応じてVU数は変動する点に注意
- setupの返り値はdefault function()とteardown()の引数(実装例では
data
引数)として渡される- setupでログインを行って認証情報を返すことで、VU関数ではログイン後の状態からアクセスする、といったユースケースが実現できます
- VU関数でこの引数を更新することもでき、更新した値を次のイテレーションで参照することもできますが、VUを跨いだ共有ができない点には注意です
- 関数外のコード(init)はsetup、VU、teardownのそれぞれで実行される
- (実際にはoptionsをパースする際にも実行されるので、合計すると3+VU数)[8]
export function setup() {
const resp = http.get("http://localhost:8000/uuid"); // (擬似的な認証処理)
const token = JSON.parse(resp.body!.toString()).uuid;
const data = { token, vuid: null };
console.log("setup:", data);
return data;
}
export default function (data: { token: string; vuid: number }) {
console.log("VU:", data);
data.vuid = exec.vu.idInTest;
http.get("http://localhost:8000/get", {
headers: {
Authorization: `Bearer ${data.token}`,
},
});
sleep(1);
}
export function teardown(data: { token: string, vuid: number }) {
console.log("teardown:", data);
}
実行結果
$ k6 run --compatibility-mode=experimental_enhanced --vus=2 --duration=1s script.ts
INFO[0000] setup: {"token":"4e2050bc-6bd5-4a81-b253-f5eddbd0b43e","vuid":null} source=console
INFO[0000] VU: {"token":"4e2050bc-6bd5-4a81-b253-f5eddbd0b43e","vuid":null} source=console
INFO[0000] VU: {"token":"4e2050bc-6bd5-4a81-b253-f5eddbd0b43e","vuid":null} source=console
INFO[0001] VU: {"vuid":1,"token":"4e2050bc-6bd5-4a81-b253-f5eddbd0b43e"} source=console
INFO[0001] VU: {"token":"4e2050bc-6bd5-4a81-b253-f5eddbd0b43e","vuid":2} source=console
INFO[0002] teardown: {"token":"4e2050bc-6bd5-4a81-b253-f5eddbd0b43e","vuid":null} source=console
シナリオ
より複雑なワークロードを表現したいケースもあります。だんだんリクエストレートを上げていったり、あるいは、データの作成と取得を同時に行ったり...などです。
k6ではこういった負荷プロファイルを、シナリオ(Scenarios)で実装します。
https://grafana.com/docs/k6/latest/using-k6/scenarios/
シナリオはoptionsのscenariosで設定します。
export const options = {
scenarios: {
シナリオ名: {
exec: "実行する関数",
executor: "executorの指定",
// executorごとの設定...
},
VUをスケジュールするexecutorというものを、シナリオごとに選択します。2025/1時点では7種類のexecutorが提供されており、それぞれ負荷のかけ方が異なります。
https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/
-
shared-iterations
... 指定された合計イテレーション数に達するまでVUを繰り返し実行し続ける -
constant-vus
... 指定された時間が経過するまで一定数のVUを繰り返し実行し続ける- リクエストする間隔を調整するためには、VUコード内でsleepを挟む必要がある
-
constant-arrival-rate
... リクエストレートが一定になるように、実行するVU数を調節し続ける- リクエストレートの制御はexecutorで行われるため、sleepは不要
-
ramping-arrival-rate
... 時間推移する負荷を再現するexecutorで、リクエストレートとそのリクエストレートを開始する時間をセットで複数指定する(例を後述)
また、execでシナリオごとにVUが実行する関数を指定できます。指定された関数はexportされていなければならない点に注意です。
以下の実装例では、2RPS一定でGETリクエストするgetArticlesと、RPSが0=>1=>2=>1=>0と移り変わるpostArticleの2つのシナリオが同時実行されます。
export const options = {
scenarios: {
getArticles: {
exec: "getArticles",
executor: "constant-arrival-rate",
duration: "15s",
rate: 2,
preAllocatedVUs: 1,
},
postArticle: {
exec: "postArticle",
executor: "ramping-arrival-rate",
preAllocatedVUs: 1,
timeUnit: "1s",
stages: [
{ duration: "3s", target: 1 }, // 3秒かけて0=>1VU
{ duration: "5s", target: 2 }, // 5秒かけて1=>2VU
{ duration: "3s", target: 1 }, // 3秒かけて2=>1VU
{ duration: "2s", target: 0 }, // 2秒かけて1=>0VU
],
},
},
};
export function getArticles() {
console.log(`[${exec.vu.idInInstance}] getArticles`);
http.get("http://localhost:8000/get");
}
export function postArticle() {
console.log(`[${exec.vu.idInInstance}] postArticle`);
http.post("http://localhost:8000/post");
}
コンソール出力を見ると、getArticlesが常に呼び出されている中、開始3秒から12秒までpostArticlesが同時に実行されていることがわかります。
実行結果
* getArticles: 2.00 iterations/s for 15s (maxVUs: 1, exec: getArticles, gracefulStop: 30s)
* postArticle: Up to 2.00 iterations/s for 13s over 4 stages (maxVUs: 1, exec: postArticle, gracefulStop: 30s)
INFO[0000] [2] getArticles source=console
INFO[0000] [2] getArticles source=console
INFO[0001] [2] getArticles source=console
INFO[0001] [2] getArticles source=console
INFO[0002] [2] getArticles source=console
INFO[0002] [1] postArticle source=console
INFO[0002] [2] getArticles source=console
INFO[0003] [2] getArticles source=console
INFO[0003] [1] postArticle source=console
INFO[0003] [2] getArticles source=console
INFO[0004] [2] getArticles source=console
INFO[0004] [1] postArticle source=console
INFO[0004] [2] getArticles source=console
INFO[0005] [2] getArticles source=console
INFO[0005] [1] postArticle source=console
INFO[0005] [2] getArticles source=console
INFO[0005] [1] postArticle source=console
INFO[0006] [2] getArticles source=console
INFO[0006] [1] postArticle source=console
INFO[0006] [2] getArticles source=console
INFO[0007] [1] postArticle source=console
INFO[0007] [2] getArticles source=console
INFO[0007] [1] postArticle source=console
INFO[0007] [2] getArticles source=console
INFO[0008] [1] postArticle source=console
INFO[0008] [2] getArticles source=console
INFO[0008] [2] getArticles source=console
INFO[0008] [1] postArticle source=console
INFO[0009] [2] getArticles source=console
INFO[0009] [1] postArticle source=console
INFO[0009] [2] getArticles source=console
INFO[0009] [1] postArticle source=console
INFO[0010] [2] getArticles source=console
INFO[0010] [2] getArticles source=console
INFO[0010] [1] postArticle source=console
INFO[0011] [2] getArticles source=console
INFO[0011] [2] getArticles source=console
INFO[0011] [1] postArticle source=console
INFO[0012] [2] getArticles source=console
INFO[0012] [2] getArticles source=console
INFO[0013] [2] getArticles source=console
INFO[0013] [2] getArticles source=console
INFO[0014] [2] getArticles source=console
INFO[0014] [2] getArticles source=console
INFO[0015] [2] getArticles source=console
...
running (15.0s), 0/2 VUs, 44 complete and 0 interrupted iterations
getArticles ✓ [======================================] 0/1 VUs 15s 2.00 iters/s
postArticle ✗ [================================>-----] 0/1 VUs 11.6s/13s 0.95 iters/s
レポート
結果は以下のように出力されます。HTTPリクエストのデータサイズや細かいフェーズまで見えるので、ぱっと見でも傾向は見えます。
execution: local
script: script.js
output: -
scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration (incl. graceful stop):
* default: 10 looping VUs for 30s (gracefulStop: 30s)
data_received..................: 2.9 MB 97 kB/s
data_sent......................: 24 kB 792 B/s
http_req_blocked...............: avg=28.87µs min=1µs med=3.5µs max=979µs p(90)=11.1µs p(95)=27µs
http_req_connecting............: avg=16.95µs min=0s med=0s max=728µs p(90)=0s p(95)=0s
http_req_duration..............: avg=9.73ms min=1.17ms med=8.16ms max=37.3ms p(90)=19.81ms p(95)=23.24ms
{ expected_response:true }...: avg=9.73ms min=1.17ms med=8.16ms max=37.3ms p(90)=19.81ms p(95)=23.24ms
http_req_failed................: 0.00% 0 out of 300
http_req_receiving.............: avg=124.98µs min=13µs med=50µs max=2.25ms p(90)=297.09µs p(95)=426.09µs
http_req_sending...............: avg=20.12µs min=3µs med=10µs max=860µs p(90)=30µs p(95)=40.19µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=9.58ms min=1.14ms med=7.99ms max=37.27ms p(90)=19.74ms p(95)=23.2ms
http_reqs......................: 300 9.89464/s
iteration_duration.............: avg=1.01s min=1s med=1s max=1.03s p(90)=1.02s p(95)=1.02s
iterations.....................: 300 9.89464/s
vus............................: 10 min=10 max=10
vus_max........................: 10 min=10 max=10
JSON, CSVなどの形式で出力
--out
オプションで詳細な結果をファイルに出力できます。
$ k6 run --out csv=results.csv --compatibility-mode=experimental_enhanced script.ts
CSVの結果
$ head results.csv
metric_name,timestamp,metric_value,check,error,error_code,expected_response,group,method,name,proto,scenario,service,status,subproto,tls_version,url,extra_tags,metadata
http_reqs,1737776710,1.000000,,,,true,,GET,http://localhost:8000/get,HTTP/1.1,default,,200,,,http://localhost:8000/get,,
http_req_duration,1737776710,161.734000,,,,true,,GET,http://localhost:8000/get,HTTP/1.1,default,,200,,,http://localhost:8000/get,,
http_req_blocked,1737776710,6.835000,,,,true,,GET,http://localhost:8000/get,HTTP/1.1,default,,200,,,http://localhost:8000/get,,
http_req_connecting,1737776710,0.488000,,,,true,,GET,http://localhost:8000/get,HTTP/1.1,default,,200,,,http://localhost:8000/get,,
http_req_tls_handshaking,1737776710,0.000000,,,,true,,GET,http://localhost:8000/get,HTTP/1.1,default,,200,,,http://localhost:8000/get,,
http_req_sending,1737776710,1.072000,,,,true,,GET,http://localhost:8000/get,HTTP/1.1,default,,200,,,http://localhost:8000/get,,
http_req_waiting,1737776710,160.555000,,,,true,,GET,http://localhost:8000/get,HTTP/1.1,default,,200,,,http://localhost:8000/get,,
http_req_receiving,1737776710,0.107000,,,,true,,GET,http://localhost:8000/get,HTTP/1.1,default,,200,,,http://localhost:8000/get,,
http_req_failed,1737776710,0.000000,,,,true,,GET,http://localhost:8000/get,HTTP/1.1,default,,200,,,http://localhost:8000/get,,
タグ
結果の可視化や集計などのために、任意のデータを残すことができます。
https://grafana.com/docs/k6/latest/using-k6/tags-and-groups/
以下の例では1回目のGETリクエストではindex=get-1
、2回目のGETリクエストではindex=get-2
が記録されます。
export default function () {
http.get("http://localhost:8000/get", {
tags: {
index: "get-1",
},
});
http.get("http://localhost:8000/get", {
tags: {
index: "get-2",
},
});
sleep(1);
}
CSV出力のextra_tags
を見ると、セットしたタグが出力されていることを確認できます。
metric_name,timestamp,metric_value,check,error,error_code,expected_response,group,method,name,proto,scenario,service,status,subproto,tls_version,url,extra_tags,metadata
http_reqs,1737777130,1.000000,,,,true,,GET,http://localhost:8000/get,HTTP/1.1,default,,200,,,http://localhost:8000/get,index=get-1,
...
http_reqs,1737777130,1.000000,,,,true,,GET,http://localhost:8000/get,HTTP/1.1,default,,200,,,http://localhost:8000/get,index=get-2,
まとめ
k6を使った負荷テストの実装について紹介しました。
JavaScript/TypeScriptで記述でき、Web界隈の開発者には受け入れられやすいかなと思います。
シナリオ単位で負荷のかけ方を細かく指定することで、より実際のユースケースに即したワークロードを再現できる点もとてもGoodです。
k6のランタイムの実装はGoで、VUはプロセスやスレッドより軽量なゴルーチンで実行されますので、あえて分散環境で実行させずとも、大きな負荷をかけられることも予想できます。
反面、ブラウザやNode.jsのAPIを使うことはできません。xk6でビルドすることで、k6へ拡張機能を実装・追加することもできます[9]ので、必要であれば検討してみてもいいかもしれません。
参考
- https://zenn.dev/pharmax/articles/98ed49994cdaf2
- https://dev.classmethod.jp/articles/lets-try-k6/
- https://zenn.dev/moko_poi/articles/72996341dc1665
Discussion