はじめに
k6用のプロジェクトテンプレートを作成したので、紹介します。
いざ負荷試験のスクリプトを作り始めるとなった際に、色々と整備して初期構築をするのも大変です。
- 複数のシナリオと、そのシナリオを束ねるシナリオセットはどう管理する?
- 負荷試験対象の環境情報(local, stg, etc…)を簡単に切り替えられうようにしたい
- シナリオの設定値(VU、イテレーション数等)は簡単に渡せるようにしたい
- コードフォーマットはなるべく統一させたい 等
- 自分なりに考えた良さげな構成を紹介する
ベースとなるプロジェクトを整備するだけでも大変なものです。
今回は、試行錯誤しつつ導き出した自分なりの良さげな構成例を紹介します。
チュートリアル上、エラーとなる箇所がある場合には、最新のリポジトリのソースをご覧ください。 https://github.com/gonkunkun/k6-template
想定する読者
- これから、k6を使って負荷試験のスクリプトの準備を始めようとしている人
- k6で開発を進める上でのテンプレート構成を知りたい人
クイックスタート
本テンプレートは、以下の前提を想定しています
- k6はローカルやEC2等のインスタンス上で動作する想定
- goやbrewの環境は既に準備されている想定
テンプレートは以下のリポジトリからダウンロード出来ます。
https://github.com/gonkunkun/k6-template
それではまずは、テンプレートをダウンロードしてきて、スモークテスト用のシナリオを動かすところまで進めてみましょう。
# clone repository git clone https://github.com/gonkunkun/k6-template cd ./k6-template # Instaration npm install # Install xk6 go install go.k6.io/xk6/cmd/xk6@latest xk6 build \ --with github.com/LeonAdato/xk6-output-statsd@latest \ --with github.com/grafana/xk6-dashboard@latest \ --with github.com/szkiba/xk6-enhanced@latest \ --with github.com/szkiba/xk6-dotenv@latest # Set environment variables cp .env.sample .env
# Install Redis brew install redis
## Start redis (if the installation destination of the homebrew app is /usr/local) redis-server /usr/local/etc/redis.conf
## Start redis (if the installation destination of the homebrew app is /opt/homebrew) redis-server /opt/homebrew/etc/redis.conf
ここまで出来たら、環境構築終了です。
実際にシナリオを動かしてみましょう。
# bundle
npm run bundle
# Execute smoke test scenario set.
./k6 run ./dist/loadTest.js --config ./src/sample-product/configs/smoke.json -e ENV=local
以下のようなログが流れればひとまず成功ですね。
~/D/g/template-of-k6 ❯❯❯ ./k6 run ./dist/loadTest.js --config ./src/sample-product/configs/smoke.json -e ENV=local main ✚ ✱ ◼
/\ |‾‾| /‾‾/ /‾‾/
/\ / \ | |/ / / /
/ \/ \ | ( / ‾‾\
/ \ | |\ \ | (‾) |
/ __________ \ |__| \__\ \_____/ .io
execution: local
script: ./dist/loadTest.js
output: -
scenarios: (100.00%) 2 scenarios, 2 max VUs, 1m30s max duration (incl. graceful stop):
* sampleScenario1: 1 iterations for each of 1 VUs (maxDuration: 1m0s, exec: sampleScenario1, gracefulStop: 30s)
* sampleScenario2: 1 iterations for each of 1 VUs (maxDuration: 1m0s, exec: sampleScenario2, gracefulStop: 30s)
INFO[0001] 0se: == setup() BEGIN =========================================================== source=console
INFO[0001] 0se: Start of test: 2024-03-20 17:54:57 source=console
INFO[0001] 0se: Test environment: local source=console
INFO[0001] 0se: == Check scenario configurations ====================================================== source=console
INFO[0001] 0se: Scenario: sampleScenario1() source=console
INFO[0001] 0se: Scenario: sampleScenario2() source=console
INFO[0001] 0se: == Check scenario configurations FINISHED =============================================== source=console
INFO[0001] 0se: == Initialize Redis ====================================================== source=console
INFO[0001] 0se: == setup() END =========================================================== source=console
INFO[0003] 2se: Scenario sampleScenario2 is initialized. Lens is 10000 source=console
INFO[0003] 2se: Scenario sampleScenario1 is initialized. Lens is 10000 source=console
INFO[0003] 2se: == Initialize Redis FNISHED =============================================== source=console
INFO[0003] 2se: sampleScenario1() start ID: 2, vu iterations: 1, total iterations: 0 source=console
INFO[0003] 2se: sampleScenario2() start ID: 2, vu iterations: 1, total iterations: 0 source=console
INFO[0004] 3se: sampleScenario1() end ID: 2, vu iterations: 1, total iterations: 0 source=console
INFO[0004] 3se: sampleScenario2() end ID: 2, vu iterations: 1, total iterations: 0 source=console
INFO[0004] 0se: == All scenarios FINISHED =========================================================== source=console
INFO[0004] 0se: == Teardown() STARTED =========================================================== source=console
INFO[0004] 0se: == Initialize Redis ====================================================== source=console
INFO[0004] 0se: == Teardown() FINISHED =========================================================== source=console
INFO[0004] 0se: == Initialize Redis FINISHED =============================================== source=console
█ setup
█ sampleScenario1
✓ Status is 200
█ sampleScenario2
✓ Status is 200
█ teardown
checks.........................: 100.00% ✓ 2 ✗ 0
data_received..................: 152 kB 43 kB/s
data_sent......................: 939 kB 268 kB/s
group_duration.................: avg=1.26s min=1.23s med=1.26s max=1.28s p(90)=1.28s p(95)=1.28s
http_req_blocked...............: avg=507.05ms min=505.29ms med=507.05ms max=508.82ms p(90)=508.46ms p(95)=508.64ms
http_req_connecting............: avg=233.66ms min=233.63ms med=233.66ms max=233.69ms p(90)=233.69ms p(95)=233.69ms
http_req_duration..............: avg=754.63ms min=733.82ms med=754.63ms max=775.44ms p(90)=771.28ms p(95)=773.36ms
{ expected_response:true }...: avg=754.63ms min=733.82ms med=754.63ms max=775.44ms p(90)=771.28ms p(95)=773.36ms
http_req_failed................: 0.00% ✓ 0 ✗ 2
http_req_receiving.............: avg=259µs min=216µs med=259µs max=302µs p(90)=293.39µs p(95)=297.7µs
http_req_sending...............: avg=186.5µs min=117µs med=186.5µs max=256µs p(90)=242.1µs p(95)=249.05µs
http_req_tls_handshaking.......: avg=272.21ms min=270.45ms med=272.21ms max=273.96ms p(90)=273.61ms p(95)=273.79ms
http_req_waiting...............: avg=754.18ms min=733.48ms med=754.18ms max=774.88ms p(90)=770.74ms p(95)=772.81ms
http_reqs......................: 2 0.570468/s
iteration_duration.............: avg=1.18s min=901.98µs med=1.26s max=2.21s p(90)=1.93s p(95)=2.07s
iterations.....................: 2 0.570468/s
vus............................: 2 min=0 max=2
vus_max........................: 2 min=2 max=2
running (0m03.5s), 0/2 VUs, 2 complete and 0 interrupted iterations
sampleScenario1 ✓ [======================================] 1 VUs 0m01.2s/1m0s 1/1 iters, 1 per VU
sampleScenario2 ✓ [======================================] 1 VUs 0m01.3s/1m0s 1/1 iters, 1 per VU
本プロジェクト構成の紹介
ただ単に手順に沿ってk6を動かしただけだと、中身がよく分からないですね。
ここからは、設定内容や意図をお伝えしていきます。
やりたかったことは以下の通り。
- 個々のシナリオとユースケース毎のシナリオセットを綺麗に管理する
- 環境変数(local, stg, prod等)を簡単に切り替える
- シナリオで使用するデータをCSVファイルから読み込む
- 開発中に便利なロギングの設定を入れる
- プロジェクト内のコードフォーマットを統一し、自動でチェックする
必要最低限、負荷スクリプトを開発するために必要な事柄は揃っていると思います。
なお、以下は出来ていないです
- テストコード(必要であれば適宜追加をお願いいたします)
- Redisの細かなユーザやパスワード設定。デフォルトのまま使っています
ディレクトリ構成は以下の通りです。
~/D/g/template-of-k6 ❯❯❯ tree -a main
.
├── .env
├── .github
│ └── workflows
│ └── lint.yml
├── README.md
├── assets
│ └── datas
│ └── sample-product
│ └── local
│ ├── sampleScenario1.csv
│ └── sampleScenario2.csv
├── dist
├── k6
├── src
│ └── sample-product
│ ├── common
│ │ ├── common.ts
│ │ ├── env.ts
│ │ └── redis.ts
│ ├── configs
│ │ └── smoke.json
│ ├── loadTest.ts
│ └── scenarios
│ ├── sampleScenario1.ts
│ └── sampleScenario2.ts
それぞれ、もう少し細かく説明していきます。
個々のシナリオとユースケース毎のシナリオセットを綺麗に管理する
シナリオセットとは、スモークテスト用に実行するシナリオ郡、ロードテスト用に実行するシナリオ郡、等のシナリオのまとまりを指しています。
開発中はVUの数が少ないスモークテストを実行しつつ、本番では大量のVUを用いるロードテスト用のシナリオを実行する。といった使い方が考えられます。
そのため、シナリオとシナリオセットは多対多の関係になることが多いです。
なので、これらのファイルの管理方法は事前に考えておくのがベターです。
そこで、本プロジェクトでは以下のディレクトリ構成としています。
├── src
│ └── sample-product
│ ├── common
│ │ ├── common.ts
│ │ ├── env.ts
│ │ └── redis.ts
│ ├── configs
│ │ └── smoke.json
│ ├── loadTest.ts
│ └── scenarios
│ ├── sampleScenario1.ts
│ └── sampleScenario2.ts
シナリオは/scenariosディレクトリ配下に配置しておき、実際に実行する際には親ファイル(loadTest.ts)を呼び出します。
シナリオセットの管理は、/configsディレクトリ配下で行っています。
シナリオセット毎に、jsonのconfigファイルを準備するイメージです。
jsonのconfigファイルは以下のフォーマットとなっています。
ここで、どのシナリオを、どの程度の負荷で動作させるのか設定します。
{
"scenarios": {
"sampleScenario1": {
"exec": "sampleScenario1",
"executor": "per-vu-iterations",
"startTime": "0s",
"vus": 1,
"iterations": 1,
"maxDuration": "60s"
},
"sampleScenario2": {
"exec": "sampleScenario2",
"executor": "per-vu-iterations",
"startTime": "0s",
"vus": 1,
"iterations": 1,
"maxDuration": "60s"
}
}
}
あとはk6実行時に、上記のjsonファイルのコマンドラインの引数に渡すだけです。
./k6 run ./dist/loadTest.js --config ./src/sample-product/configs/smoke.json -e ENV=local
こうすることで、複数のシナリオを構造的に管理しつつ、実行するシナリオセット(スモークテスト、負荷テスト、etc…)を簡単に切り替えることが出来ます。
環境変数(local, stg, prod等)を簡単に切り替える
負荷試験用のスクリプトを開発する上で、環境の選択は非常に重要です。
エンドポイントを間違ってprodに負荷をかけちゃった…
なんて事態が発生するのも避けたいです。
そこで、このテンプレートプロジェクトでは、「.env」から環境変数を読み込ませるようにしています。
また、不慮の事故が発生しないよう、基本的な環境変数は「env.ts」でexportされた値のみ使用するようにしています。
~/D/g/template-of-k6 ❯❯❯ tree -a main
.
├── .env
├── assets
│ └── datas
│ └── sample-product
│ └── local
│ ├── sampleScenario1.csv
│ └── sampleScenario2.csv
├── src
│ └── sample-product
│ ├── common
│ │ ├── common.ts
│ │ ├── env.ts
│ │ └── redis.ts
env.tsでは .env にの環境変数を読み込んで、未設定の環境変数に対してデフォルト値を設定した上で、外部のモジュールから利用可能なようにexportしています。
// @ts-ignore
import { parse } from "k6/x/dotenv"
import { toBoolean } from './common'
// eslint-disable-next-line @typescript-eslint/naming-convention
const _ENV = parse(open("../.env"))
export const DEBUG =toBoolean(_ENV.DEBUG) || false
export const ENV = _ENV.ENV || 'local'
export const REDIS_ENDPOINT = _ENV.REDIS_ENDPOINT || 'redis://localhost:6379'
export const AMOUNT_OF_INDEX_SIZE_FOR_TEST_DATA = _ENV.AMOUNT_OF_INDEX_SIZE_FOR_TEST_DATA || 10000
export const SAMPLE_PRODUCT_ENDPOINT = _ENV.SAMPLE_PRODUCT_ENDPOINT || 'localhost'
また、どの環境用のテストデータを利用するのか、テストデータ保管用のディレクトリは環境名で分離させています。
├── assets
│ └── datas
│ └── sample-product
│ └── local
│ ├── sampleScenario1.csv
│ └── sampleScenario2.csv
どの環境のテストデータを使うのかも、k6実行時のパラメータとして渡します。
./k6 run ./dist/loadTest.js --config ./src/sample-product/configs/smoke.json -e ENV=local
シナリオで使用するデータをCSVファイルから読み込む
負荷試験の準備をしていると、事前にユーザデータを準備して、そのユーザを使って負荷をかけたくなります。
ログインが必要なアプリケーションに対する負荷試験の場合が当てはまります。
基本的には、各シナリオがassets配下にあるcsvファイルを読み込めるようにしています。
├── assets
│ └── datas
│ └── sample-product
│ └── local
│ ├── sampleScenario1.csv
│ └── sampleScenario2.csv
また、各シナリオ毎に、各VU(Virtual User)がどこまでのユーザデータを利用済なのかを管理するために、Key/Value storeであるRedisを間に挟めています。
以下はsampleScenario1.tsの抜粋です。
csvファイルからユーザデータは読み込みつつも、csvファイルのインデックス情報はRedisからpopしてくるようにしています。
const users = new SharedArray('sampleScenario1', function () {
return papaparse.parse(open(${SCENARIO_FILES_DIR()}/sampleScenario1.csv
), { header: true }).data
})
export default async function sampleScenario1(): Promise<void> {
if (loopCounterPerVU === 0) {
const index = Number(await client.lpop('sampleScenario1'))
user = users[index]
}
loopCounterPerVU++
なぜこのような一手間かけた実装としているのか、その理由については以下の記事を御覧くださいませ。
開発中に便利なロギングの設定を入れる
開発中のみ、デバッグログを流したい。
という話はあるあるだと思います。
本プロジェクトでは、以下のようなfunctionをcommon.ts配下に準備して、その他全てのスクリプトから利用するようにしています。
export function debugOrLog(textToLog: string): void {
if (env.DEBUG) {
const millis = Date.now() - start
const time = Math.floor(millis / 1000)
console.log(${time}se: ${textToLog}
)
}
}
呼び出す側は以下のようにして呼び出すだけですね。
debugOrLog(
sampleScenario1() start ID: ${user.ID}, vu iterations: ${loopCounterPerVU}, total iterations: ${exec.scenario.iterationInTest}
,
)
こうすることで、環境変数側でデバッグログの有無を制御出来ます。
本番の負荷試験中に無意味なログが流れ続けると、それが負荷試験のボトルネックになり得るので、このような仕組みを用意して簡単ロギングの有無を切り替えられるようにすると一安心です。
プロジェクト内のコードフォーマットを統一し、自動でチェックする
これは大したことはしていないので、特に話すことも無いですが…
最低限のCIをgithub actions側に持たせていたり、lintやprettierの設定を入れています。
詳しくはリポジトリを直接ご覧くださいませ。
おわりに
今回は、k6で負荷スクリプトを開発するためのベースとなるプロジェクト構成の例を紹介しました。
この構成にもメリット・デメリットはあると思うので、自分の用途に合うように適宜カスタマイズしてもらえると嬉しいです。
この記事が少しでも皆様のお役に立てれば幸いです。
【追記】 k6側の拡張機能のアップデートも進んでおります。 例えば、本記事執筆後に以下のような修正を加えています。
こちらも併せてご覧くださいませ。