はじめに
案件で高負荷試験を行いましたが、その際にGrafanaが提供している k6 を使用しました。その際に
- 効率のいいシナリオの作成の仕方
- シナリオが増えてきたときの管理方法
- 環境ごとの切り替え方法
など、運用保守することにあたってどういう風にするのがいいのか考えたので今回の記事では実際に運用してみて、行ったことをまとめます。
対象読者
- k6で性能試験を行いたい人
- できるだけ脳死でシナリオを作成したい人
- 複数のシナリオを運用していきたい人
- JavaScriptで負荷試験を書きたい人
k6とは?
Grafana が提供するOSSの性能試験ツールです。
CLIで実行することができ、JavaScriptでシナリオを作成できます。CLIから実行できるため、CIに組み込むことが簡単で、スクリプトベースで再現性の高い性能試験を実施できます。
運用してみよう
今回作成したディレクトリ
今回はシナリオを作成、複数シナリオある場合の管理方法など、テストコードを継続的にメンテナンスできることを目指していきます。以下の構成でディレクトリを作成してみました。
.
├── editHar.js # harファイル修正用スクリプト
├── scenario-suite1 # シナリオスイート単位のディレクトリ
│ ├── har # シナリオ作成元になるharファイル置き場
│ │ └── result.har
│ ├── scenario1.js # シナリオファイル
│ ├── scenario2.js
│ └── setup.js # シナリオ実行設定ファイル
├── scenario-suite2
│ ├── har
│ │ └── result.har
│ ├── scenario1.js
│ ├── scenario2.js
│ └── setup.js
├── scenario-suite1.js # シナリオ実行ファイル
├── scenario-suite2.js
└── utils.js # テスト共通のutil関数
この構成のポイントは次の3つです。
- シナリオごとに事前処理やシナリオ実行設定などの責務を分離できるようにする
- setupをスイート単位で集約する
- スイート単位でまとめて管理する
またシナリオ作成をできるだけ自動化するために、ブラウザ操作を録画して、それをもとにテストファイルを作成する方針を目指しました。
インストール
公式ドキュメントの指示通りにCLIをインストールします。
brew install k6
k6とコマンドを実行することでバージョン情報などを表示されることを確認します。
シナリオを作成する
今回はできるだけ楽をしてシナリオファイルを作成する方針で進めました。そのためにGrafanaの公式ツールを使って、harファイルから、テストファイルの原型を作成し、それを修正する方針で進めました。
本記事では説明のためにQiitaを例にしていますが、実際の負荷試験は必ず自分が管理している環境や許可を得た検証環境で実施してください。
harファイルを作る
ブラウザの開発者ツールを使用して、harファイルを作成します。今回はQiitaサイトから「トップ画面→マイページ→自分の記事にアクセス」するシナリオを作成します。
まず作成したいテストシナリオでの開始画面を開き、開発者ツールのネットワークタブを開きます。そして一度画面をリロードした後にネットワークタブの「ログを保持」を有効化します。

これでシナリオ作成の準備ができました。この状態から作成したいシナリオを一通り操作します。
操作が完了したら、開発者ツールのネットワークタブの右上あたりにあるダウンロードボタンからharファイルをダウンロードします。ダウンロードするとjson形式で通信履歴のデータが記載されています。
{
"log": {
"version": "1.2",
"creator": {
"name": "WebInspector",
"version": "537.36"
},
"pages":[(遷移した画面の情報)],
"entries": [(ここの通信の内容)]
}
harファイルを編集する
作成したharファイルには認証情報やテスト対象外の外部のAPIをたたいているケースがあると思います。負荷試験の性質上、それらの情報は不要になるので、harファイルからそれらの情報を削除してからシナリオを作成します。今回は「試験対象のドメイン以外の通信を削除」と「通信のヘッダーの削除」を行います。
この処理は試験対象ごとに要件が違うと思いますので、要件に合わせて実装するのがいいと思います。
今回はk6がJavaScriptベースなので、JavaScriptで修正用のscriptを作成して実行します。
const fs = require("fs");
const url = require("url");
const TARGET_DOMAINS = ["qiita.com"];
function isTargetDomain(requestUrl, domains = TARGET_DOMAINS) {
const parsedUrl = url.parse(requestUrl);
return domains.includes(parsedUrl.hostname);
}
function editHar(inputFile, outputFile) {
const har = JSON.parse(fs.readFileSync(inputFile, "utf-8"));
// 試験対象のドメインが含まれているか確認
if (!har.log || !har.log.entries) {
console.error("Invalid HAR file: missing log.entries");
return;
}
// Headerを削除
har.log.entries = har.log.entries
.filter((entry) => {
if (entry.request && entry.request.url) {
return isTargetDomain(entry.request.url);
}
return false;
})
.map((entry) => {
if (entry.request && entry.request.headers) {
entry.request.headers = [];
}
if (entry.response && entry.response.headers) {
entry.response.headers = [];
}
return entry;
});
fs.writeFileSync(outputFile, JSON.stringify(har, null, 2), "utf-8");
console.log(`Edited HAR file saved to ${outputFile}`);
}
if (require.main === module) {
const inputFile = process.argv[2];
const outputFile = process.argv[3];
if (!inputFile || !outputFile) {
console.error("Usage: node editHar.js <input.har> <output.har>");
process.exit(1);
}
editHar(inputFile, outputFile);
}
作成した editHar.jsスクリプトを使用して、先ほどダウンロードしたharファイルに前処理を行います。
この前処理を行ったharファイルからシナリオを作成していきます。
node editHar.js scenario-suite1/har/qiita.com.har scenario-suite1/har/result.har
Edited HAR file saved to scenario-suite1/har/result.har
harファイルからシナリオを作成する
harファイルからGragana公式ツールのhar-to-k6を使用して、シナリオファイルを作成します。インストール手順に従ってツールをinstallをし、コマンドを実行します。
npm install --save har-to-k6
npx har-to-k6 scenario-suite1/har/result.har -o scenario-suite1/scenario1.js
これでシナリオファイルが作成されます。しかしこのシナリオファイルはブラウザで操作したときに発生した情報そのままなので、可変にしたい場所(例:ドメイン情報、通信内容など)を修正していきます。
// Creator: WebInspector 537.36
import { sleep, group } from 'k6'
import http from 'k6/http'
export const options = {}
export default function main() {
let response
group('page_1 - https://qiita.com/', function () {
response = http.get('https://qiita.com/')
response = http.get('https://qiita.com/')
response = http.get('https://qiita.com/manifest.json')
response = http.get('https://qiita.com/official-events')
response = http.get('https://qiita.com/')
response = http.get('https://qiita.com/natume_nat/items/c3d904ff5f898ad243f3')
})
group('page_2 - https://qiita.com/spin_atop', function () {
...
})
group('page_3 - https://qiita.com/spin_atop/items/d65f9721bb63adc69c52', function () {
...
})
// Automatically added sleep
sleep(1)
}
シナリオファイルを修正する
シナリオファイルを微修正していきます。例えばpost通信をbodyは試験対象ごとに設定しないといけないケースもあるでしょうし、そもそもmockを使用しているケースならば中身を関係なく何でもいいケースがあると思います。ここでは次の3つを対応します
- 試験対象環境が複数あるので、path指定に置き換える
- 検証環境にアクセスするため、必要なヘッダを設定する
- カスタムメトリクスを追加する
これらの修正は utils.jsで実装し、そのファイルを各シナリオファイルに読み込んで使用します。方針としては、 k6で使われている httpクラスのラッパーを別で作成し、それをシナリオファイルに読み込ませて、必要な共通設定などを行っていきます。サンプルとしては以下です。
import http from "k6/http";
import { check } from "k6";
const PROJECT_HEADERS = {
"x-project-name": __ENV.PROJECT_NAME ?? "demo-k6",
};
const BASE_URL = __ENV.BASE_URL ?? "";
function resolveUrl(path) {
const base = BASE_URL.endsWith("/") ? BASE_URL.slice(0, -1) : BASE_URL;
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${base}${normalizedPath}`;
}
function mergeHeaders(params = {}) {
const existingHeaders = params.headers ?? {};
return {
...params,
headers: {
...PROJECT_HEADERS,
...existingHeaders,
},
};
}
export const httpWithHeaders = {
get(path, params = {}) {
const response = http.get(resolveUrl(path), mergeHeaders(params));
check(response, {
[`${path} status is ok`]: (r) => r.status <= 299 && r.status >= 200,
});
return response;
},
post(path, body, params = {}) {
const response = http.post(resolveUrl(path), body, mergeHeaders(params));
check(response, {
[`${path} status is ok`]: (r) => r.status <= 299 && r.status >= 200,
});
return response;
},
};
これをシナリオに読み込みます。通信処理は読み込む際に、httpとエイリアスすることで修正が少なくなります。
複数ドメインある場合はそれぞれで実装して読み込み修正します。
パス指定の修正はドメイン数が少ないと思うので、一括置換で対処します。
カスタムメトリクスは試験の要件ごとに異なると思うので、それぞれの要件に応じて実装します。今回はシナリオの実行回数を作成してみます
// Creator: WebInspector 537.36
import { sleep, group } from 'k6'
- import http from 'k6/http'
+ import { httpWithHeaders as http } from "../utils.js";
+ import { Counter } from "k6/metrics";
export const options = {}
+ const ScenaroCounter = new Counter("ScenarioCounter");
export default function main() {
let response
group('page_1 - /', function () {
+ response = http.get('/')
+ response = http.get('/')
+ response = http.get('/manifest.json')
+ response = http.get('/official-events')
+ response = http.get('/')
})
group('page_2 - /spin_atop', function () {
...
})
group('page_3 - /spin_atop/items/d65f9721bb63adc69c52', function () {
...
})
// Automatically added sleep
sleep(1)
+ ScenaroCounter.add(1);
}
シナリオ実行設定を定義する
公式ドキュメントの複数シナリオの設定方法を参考に、各シナリオの実行設定を決めていきます。 まず executorを決めます。executorとはシナリオの実行タイプの種類であり、仮想ユーザの数固定での実行や合計のシナリオ実行回数指定での実行などがあります。今回は 仮想ユーザ数が固定の constant-vusで設定していきます。executorを決めた後は、executorごとに決まっている設定値を決めていきますが、その値は公式ドキュメントを参考にしてください。 constant-vusの場合は、こちらです。
シナリオファイルscenario1.jsに実行設定を定義していきます。ここでシナリオの関数でsetupContextなるものが登場していますが、これについて後述します。
// Creator: WebInspector 537.36
import { sleep, group } from 'k6'
import { httpWithHeaders as http } from "../utils.js";
import { Counter } from "k6/metrics";
- export const options = {}
+ export const SCENARIO1 = {
+ exec: "scenario1", // シナリオの関数名
+ executor: "constant-vus", // executorの指定
+ vus: 3, // executorに対応する設定 (仮想ユーザ数)
+ duration: "1m", // executorに対応する設定 (実行時間)
+ tags: { // タグ
+ name: "scenario1_tag",
+ },
+ }
const ScenaroCounter = new Counter("ScenarioCounter");
- export default function main() {
+ export function scenaro1(setupContext){ # execで指定した関数名にする
let response
group('page_1 - /', function () {
response = http.get('/')
response = http.get('/')
response = http.get('/manifest.json')
response = http.get('/official-events')
response = http.get('/')
})
group('page_2 - /spin_atop', function () {
...
})
group('page_3 - /spin_atop/items/d65f9721bb63adc69c52', function () {
...
})
// Automatically added sleep
sleep(1)
ScenaroCounter.add(1);
}
シナリオ実行のための事前処理を定義する
シナリオによっては、APIへアクセスするために認証情報を事前に取得する必要などがあると思います。そのようなテスト実行時に1回だけ実行する必要がある処理を定義していきます。(シナリオごとに実行されるものではないので注意が必要です。)
今回考えたsetupの流れは以下です。
- 各シナリオが必要なsetup関数を定義
- setup.jsでそれらをまとめる
- k6のsetup()で一度だけ実行
- 各シナリオへの引数の
setupContextへと渡される
まずはシナリオごとに必要なsetup処理を定義していきます。ここではscenario1.jsにサンプルで適当に定義してみます。
// Creator: WebInspector 537.36
import { sleep, group } from 'k6'
import { httpWithHeaders as http } from "../utils.js";
import { Counter } from "k6/metrics";
+ import exec from "k6/execution";
export const SCENARIO1 = {
exec: "scenario1", // シナリオの関数名
executor: "constant-vus", // executorの指定
vus: 3, // executorに対応する設定 (仮想ユーザ数)
duration: "1m", // executorに対応する設定 (実行時間)
tags: { // タグ
name: "scenario1_tag",
},
}
+ /**
+ * Scenaro1の実行のために必要なsetup処理
+ * @return {
+ * userInfos: [ {sessionId}]
+ * }
+ */
+ export const scenario1SetUp = () => {
+ const sessionId = getAuthenticatedSessionId();
+ return {
+ userInfos: [ {sessionId}]
+ }
+ }
const ScenaroCounter = new Counter("ScenarioCounter");
+ /**
+ * @param {
+ "scenario_name1": Object,
+ "scenario_name2": Object,
+ } setupContext
+ */
export function scenaro1(setupContext){ # execで指定した関数名にする
+ const currentScenario = exec.scenario.name
+ const context = setupContext[currentScenario];
+ // contextを使った事前処理
+ console.log(context);
let response
group('page_1 - /', function () {
response = http.get('/')
response = http.get('/')
response = http.get('/manifest.json')
response = http.get('/official-events')
response = http.get('/')
})
group('page_2 - /spin_atop', function () {
...
})
group('page_3 - /spin_atop/items/d65f9721bb63adc69c52', function () {
...
})
// Automatically added sleep
sleep(1)
ScenaroCounter.add(1);
}
各シナリオで定義したsetup処理を setup.jsに読み込んでシナリオごとにObjectとして返します。
import { scenario1SetUp } from "./scenario1.js";
/**
* @return {
* "scenario_name": Object
* }
*/
export const setup = () => {
const scenario1Context = scenario1SetUp();
return {
SCENARIO1: scenario1Context,
};
};
このsetup関数の戻り値が各シナリオ関数の setupContextに渡されます。注意点として、すべてのシナリオで同一のものが渡されますので、どのシナリオのcontextなのかをわかるようにsetup関数で返してあげる必要があります。
シナリオをまとめる
最後にシナリオスイートをまとめて実行するファイルを作成します。今まで作成してきたシナリオの実行定義とsetup関数を読み込んで設定します。またテストスイート全体の成功条件であるthresholdsもここで設定します。threshold値については公式ドキュメントを参考にしてください。
export { setup } from "./scenario-suite1/setup.js";
export { scenario1 } from "./scenario-suite1/scenario1.js";
import { SCENARIO1 } from "./scenario-suite1/scenario1.js";
export const options = {
thresholds: {
ScenarioCounter: ["count<100"],
http_req_failed: ["rate<0.01"],
},
scenarios: {
SCENARIO1,
},
};
実行する
これでようやく実行できます。デバッグで実行するに当たっては大量アクセスにならないように、実行時間を短くして行います。今回は同じシナリオをscenario2として複製して、実行時間を10秒にして実行してみます。以下のコマンドでk6実行時の環境変数と実行するテストファイルを指定します。
k6 run -e BASE_URL=https://qiita.com -e PROJECT_NAME=qiita scenario-suite1.js
実行すると最終的には以下のような結果ログを確認することができます。これで性能試験の運用が始められると思います。
█ THRESHOLDS
http_req_failed
✗ 'rate<0.01' rate=8.33%
ScenarioCounter
✓ 'count<100' count=6
█ TOTAL RESULTS
checks_total.......: 216 19.755901/s
checks_succeeded...: 91.66% 198 out of 216
checks_failed......: 8.33% 18 out of 216
✓ / status is ok
✓ /manifest.json status is ok
✓ /official-events status is ok
✓ /userA/items/be45d3115ebe74a3e312 status is ok
✓ /userB/items/c3d904ff5f898ad243f3 status is ok
✓ /spin_atop status is ok
✗ /graphql status is ok
↳ 0% — ✓ 0 / ✗ 18
✓ /spin_atop/items/d65f9721bb63adc69c52 status is ok
✓ /embed-contents/link-card status is ok
✓ /api/ogp?url=https%3A%2F%2Fdeveloper.mozilla.org%2Fja%2Fdocs%2FWeb%2FHTTP%2FReference%2FHeaders%2FX-Frame-Options status is ok
✓ /api/ogp?url=https%3A%2F%2Fgithub.com%2Fmdn%2Fcontent%2Fpull%2F37774 status is ok
✓ /api/ogp?url=https%3A%2F%2Fdeveloper.mozilla.org%2Fja%2Fdocs%2FWeb%2FHTML%2FReference%2FElements%2Fiframe status is ok
CUSTOM
ScenarioCounter................: 6 0.548775/s
ScenarioCounter2...............: 6 0.548775/s
HTTP
http_req_duration..............: avg=229.15ms min=23ms med=122.85ms max=3.25s p(90)=427.15ms p(95)=716.64ms
{ expected_response:true }...: avg=247.34ms min=25.1ms med=126.69ms max=3.25s p(90)=438.03ms p(95)=741ms
http_req_failed................: 8.33% 18 out of 216
http_reqs......................: 216 19.755901/s
EXECUTION
iteration_duration.............: avg=5.4s min=3.77s med=5.4s max=7.04s p(90)=6.89s p(95)=6.97s
iterations.....................: 12 1.09755/s
vus............................: 6 min=6 max=6
vus_max........................: 6 min=6 max=6
NETWORK
data_received..................: 21 MB 1.9 MB/s
data_sent......................: 161 kB 15 kB/s
まとめ
今回、k6での運用方法を考えてみましたが考えた理由として負荷試験自体は頻繁にできるものではなくコードがメンテされずに放置されやすいと思ったのが始まりでした。実際私が案件で k6を触った当初もコードが全くメンテされておらずにほぼ0から運用を考えなければならない状態でした。自分の備忘もかねて書きましたが参考になればと思います。