技術(tech)

k6 – 負荷試験を簡単に始められるプロジェクトテンプレート

はじめに

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++

 

なぜこのような一手間かけた実装としているのか、その理由については以下の記事を御覧くださいませ。

 

k6 - 同一シナリオのVU間でステートを共有する場合はRedisを使おう負荷試験のツールとして、k6は便利です。 そんなk6ですが、単体で利用すると、同一シナリオの異なるVU(Virtual User)間でのみmutableな値を共有する方法がありません。 本記事では、Redisを用いて上記の課題を解決する方法を紹介します。...

 

開発中に便利なロギングの設定を入れる

開発中のみ、デバッグログを流したい。
という話はあるあるだと思います。

本プロジェクトでは、以下のような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側の拡張機能のアップデートも進んでおります。 例えば、本記事執筆後に以下のような修正を加えています。

k6 - 環境変数を扱うにはxk6-dotenvがオススメなので使い方を紹介開発を進めていると管理しなければならない環境変数が多くなってきます。 k6でも同じ話が起きがちなのです。 本記事では環境変数の管理をする...

こちらも併せてご覧くださいませ。