🧪

k6とTypeScriptで始める負荷テスト

2025/01/26に公開

負荷テスト、やってます?

明らかなボトルネックが見えていて、かつ、シンプルなワークロードに落とし込めるなら、自作のシェルスクリプトやシンプルなCLIアプリ[1]で十分な場合もありますが、ログインして情報を取ってきてそれに基づいてデータを作る...みたいな、ある程度複雑なユーザーシナリオを組もうとすると、何らかツールやフレームワークが必要になります。
YAML(Taurus[2]など)やXML(Apache JMeter[3]など)でも良いのですが、より強力な表現力を得ようとすると、慣れたプログラミング言語で記述したくなります。

本投稿では、JavaScript/TypeScriptで負荷テストを記述・実行できる、Grafana k6を紹介します。

Grafana k6とは

Grafana Lab社が開発するOSSの負荷テストツールです。
https://k6.io/

インストール

実行環境ごとの方法でインストールします。
https://grafana.com/docs/k6/latest/set-up/install-k6/

バイナリも提供されています。
https://github.com/grafana/k6/releases

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]ので、必要であれば検討してみてもいいかもしれません。

参考

脚注
  1. https://github.com/tsenart/vegeta など ↩︎

  2. https://gettaurus.org/ ↩︎

  3. https://jmeter.apache.org/ ↩︎

  4. https://github.com/grafana/sobek ↩︎

  5. https://httpbin.org/ ↩︎

  6. https://grafana.com/docs/k6/latest/using-k6/javascript-typescript-compatibility-mode/ ↩︎

  7. https://grafana.com/products/cloud/k6/ ↩︎

  8. https://community.grafana.com/t/why-init-called-4-times/97808 ↩︎

  9. https://grafana.com/docs/k6/latest/extensions/ ↩︎

Discussion