k6で気軽に負荷試験を実行してみよう!
ZOZO Advent Calendar 2024 カレンダー Vol.8 の 20 日目の記事です。
今年も黒トイプードル琥珀の2歳の誕生日にアドベントカレンダーを書くことができました。
Happy Birthday!!

k6とは
Grafana Labs社が開発している負荷試験ツールです。
k6はオープンソースのCLIツールですが、 「Grafana Cloud k6」というクラウドベースSaaSツールも提供されています。
Golangで開発されていますが、テストシナリオはJavaScriptで書けるので導入敷居も低くく試しやすい特徴があると思います。
では、早速試していきましょう。
負荷試験対象のエンドポイント
今回はバグ管理システムをイメージした次のAPIをテストすることとします。
(アプリケーションのサンプルコード等は端折らせていただきます)
- GET /bugs:バグ一覧検索
- GET /bugs/{id}:バグ検索(単体)
- POST /bugs:バグの登録
- DELETE /bugs/{ID}:バグ削除
- POST /bugs/{id}/comments:コメントの書き込み
実行環境
ありがたいことにDockerイメージが用意されているので利用させていただきます。
compose.yamlでアプリケーションの起動とセットで用意していきます。
app: build: context: . ports: - "8080:8080" healthcheck: test: "curl --fail --silent localhost:8080/health | grep UP || exit 1" interval: 1s timeout: 10s retries: 30 k6: image: grafana/k6:latest container_name: k6 working_dir: /work volumes: - ./scripts:/scripts - ./docs:/work ports: - "6565:6565" depends_on: app: condition: service_healthy profiles: - k6
シナリオの作成
冒頭でも説明しましたが、シナリオはJavaScriptで作成します。
Docker composeのvolumesで指定した「scripts」に「scenario.js」を作成します。
import http from 'k6/http'; import { sleep, check } from 'k6'; import { scenario, vu } from 'k6/execution'; import { htmlReport } from "https://raw.githubusercontent.com/benc-uk/k6-reporter/main/dist/bundle.js"; import { textSummary } from "https://jslib.k6.io/k6-summary/0.0.1/index.js"; export const options = { scenarios: { 'scenarios': { executor: 'ramping-vus', startTime: '2s', gracefulStop: '5s', startVUs: 2, stages: [ {duration: '10s', target: 3}, {duration: '10s', target: 5}, {duration: '10s', target: 0}, ] }, }, thresholds: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<300'], }, }; const BASE_URL = 'http://app:8080'; const SLEEP_DURATION = 0.5; export default function () { // バグの作成 const postResponse = http.post(BASE_URL + '/v1/bugs', JSON.stringify({ title: 'title' + scenario.iterationInTest, content: 'content' + scenario.iterationInTest, account_id: vu.idInTest, }), { headers: { 'Content-Type': 'application/json', }, }); check(postResponse, { 'is status 201': (r) => r.status === 201, }); let bugUri = postResponse.headers['Location']; sleep(SLEEP_DURATION); // バグの検索 const getResponse = http.get(BASE_URL + bugUri); check(getResponse, { 'is status 200': (r) => r.status === 200, }); sleep(SLEEP_DURATION); // バグの複数検索 const getAllResponse = http.get(BASE_URL + '/v1/bugs'); check(getAllResponse, { 'is status 200': (r) => r.status === 200, }); sleep(SLEEP_DURATION); // バグの削除 const deleteResponse = http.del(BASE_URL + bugUri); check(deleteResponse, { 'is status 204': (r) => r.status === 204, }); } export function handleSummary(data) { let returnData = { stdout: textSummary(data, { indent: " ", enableColors: true }) }; let outputDatetime = formatDate(new Date(), 'yyyyMMdd_HHmmss'); let outputHtml = outputDatetime + "_summary.html"; returnData[outputHtml]=htmlReport(data); return returnData; } function formatDate (date, format) { format = format.replace(/yyyy/g, date.getFullYear()); format = format.replace(/MM/g, ('0' + (date.getMonth() + 1)).slice(-2)); format = format.replace(/dd/g, ('0' + date.getDate()).slice(-2)); format = format.replace(/HH/g, ('0' + date.getHours()).slice(-2)); format = format.replace(/mm/g, ('0' + date.getMinutes()).slice(-2)); format = format.replace(/ss/g, ('0' + date.getSeconds()).slice(-2)); format = format.replace(/SSS/g, ('00' + date.getMilliseconds()).slice(-3)); return format; }
シナリオの説明
scenariosで設定している内容が負荷試験のシナリオ条件になります。
設定内容は次のようになっています。
- 開始2秒はリクエストは送らない
- 2秒経過後は時間経過とともにユーザが増える
- 開始10秒で0.5秒間ウエイトを置いてリクエストを送るユーザが2から3まで段階的に増える
- 開始20秒から30秒でユーザが3から5まで段階的に増える
- 開始30秒から10秒かけて段階的にユーザが減少する
徐々にユーザ数を増やしてリクエスト数も増えていくシナリオです(ユーザ数や実施時間が短いのはローカル上で動かしているため)。
thresholdsはテスト対象サービスの期待性能の合格・不合格を判断するしきい値を設定しています。
- rate<0.01:エラーが1%を超えない
- p(95)<300:リクエストの95%は300ms以下であること
このしきい値があることで健全性をチェックすることも可能になります。
実行
Docker composeでシナリオファイルを指定し実行します。
実行すると次のようにテスト結果が出力されます。
ただ今回は handleSummaryメソッドでHtmlレポート出力を設定しているので、docsディレクトリにもレポートが出力されます。
❯ docker compose run --rm -T k6 run - < scripts/scenario.js [+] Running 1/1 ✔ Container app-1 Started 0.1s /\ Grafana /‾‾/ /\ / \ |\ __ / / / \/ \ | |/ / / ‾‾\ / \ | ( | (‾) | / __________ \ |_|\_\ \_____/ execution: local script: - output: - scenarios: (100.00%) 1 scenario, 5 max VUs, 37s max duration (incl. graceful stop): * scenarios: Up to 5 looping VUs for 30s over 3 stages (gracefulRampDown: 30s, startTime: 2s, gracefulStop: 5s) time="2024-12-19T05:50:26Z" level=info msg=setup source=console ~~ 略 ~~ running (33.0s), 1/5 VUs, 55 complete and 0 interrupted iterations scenarios ↓ [ 100% ] 1/5 VUs 30s time="2024-12-19T05:50:59Z" level=info msg=teardown source=console time="2024-12-19T05:50:59Z" level=info msg="[k6-reporter v2.3.0] Generating HTML summary report" source=console ✓ is status 201 ✓ is status 200 ✓ is status 204 checks.........................: 100.00% ✓ 280 ✗ 0 data_received..................: 523 kB 16 kB/s data_sent......................: 35 kB 1.1 kB/s http_req_blocked...............: avg=83.88µs min=1.04µs med=9.27µs max=2.97ms p(90)=203.51µs p(95)=276.43µs http_req_connecting............: avg=43.28µs min=0s med=0s max=1.21ms p(90)=145.79µs p(95)=192.45µs ✓ http_req_duration..............: avg=23.16ms min=3.81ms med=12.76ms max=299.11ms p(90)=50.36ms p(95)=61.91ms { expected_response:true }...: avg=24.31ms min=3.81ms med=11.69ms max=299.11ms p(90)=52.97ms p(95)=65.59ms ✗ http_req_failed................: 20.00% ✓ 56 ✗ 224 http_req_receiving.............: avg=323.89µs min=7.58µs med=364.31µs max=1.53ms p(90)=657.65µs p(95)=733.17µs http_req_sending...............: avg=38.71µs min=3.54µs med=25.95µs max=597.33µs p(90)=66.02µs p(95)=91.87µs http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s http_req_waiting...............: avg=22.8ms min=3.54ms med=12.41ms max=298.92ms p(90)=49.81ms p(95)=61.45ms http_reqs......................: 280 8.442988/s iteration_duration.............: avg=1.62s min=1.55s med=1.59s max=2.14s p(90)=1.63s p(95)=1.68s iterations.....................: 56 1.688598/s vus............................: 1 min=0 max=5 vus_max........................: 5 min=5 max=5 running (33.2s), 0/5 VUs, 56 complete and 0 interrupted iterations scenarios ✓ [ 100% ] 0/5 VUs 30s time="2024-12-19T05:50:59Z" level=error msg="thresholds on metrics 'http_req_failed' have been crossed"

出力項目の説明
無事に負荷テストを実行することができました。どの項目がどのような意味なのかわかりにくいと思うので簡単にまとめてみました。
| 項目 | 説明 |
|---|---|
| checks | テストで宣言した check() アサーションのうち、実際に完了した数 |
| data_received | 受信したデータの量 |
| data_sent | 送信されたデータの量 |
| http_req_blocked | 開始前にリクエストがブロックされた時間。このメトリックは、リクエストが送信される前の遅延を示します。これには、ネットワーク接続のセットアップにかかる時間や、以前のリクエストによる待機時間などが含まれます |
| http_req_connecting | TCP 接続の確立に費やされた時間。具体的には、テスト マシンとサーバー間の TCP 接続を確立するのにかかる時間を測定します |
| http_req_duration | HTTP リクエストの継続時間。このメトリックは、HTTP リクエストを開始してから応答が完全に受信されるまでにかかる時間を測定します |
| http_req_failed | エラーにより失敗したリクエストの割合。これは、テスト中に失敗したリクエストの数を確認するのに役立ちます |
| http_req_receiving | HTTP 応答の受信に費やされた時間。これは、サーバーからの応答データの受信にかかる時間を測定します |
| http_req_sending | HTTP リクエストの送信に費やされた時間。これには、ヘッダーと本文を含むすべての HTTP リクエスト データをサーバーに送信するのにかかる時間が含まれます |
| http_req_tls_handshaking | TLS/SSL ハンドシェイクに費やされた時間。このメトリックは、安全な通信チャネルを確立するために使用される暗号化ハンドシェイク プロセスの継続時間を報告します |
| http_req_waiting | サーバーからの応答を待つのに費やされた時間。これは多くの場合、リクエスト期間の最大の部分であり、リクエストの送信終了から応答の受信開始までの時間を測定します |
| http_reqs | HTTP リクエストの合計数。このメトリックは、テスト中に行われた HTTP リクエストの合計数を表します |
| iteration_duration | セットアップとティアダウンを含む、テスト スクリプトの 1 回の完全な反復にかかる合計時間。これは、実行された各ユーザー シナリオの開始から終了までの時間を反映します |
| vus | 仮想ユーザーの数。このメトリックは、テスト中の任意の時点でアクティブな同時仮想ユーザーの数を示します |
| vus_max | 仮想ユーザーの最大数。これは、テスト中にアクティブだった仮想ユーザーのピーク数を示します。これは、テストの規模を理解するのに役立ちます |
終わりに
いかがだったでしょうか?
JavaScriptを作る必要がありますが、JavaScriptなら学習コストも高くなく気軽に作ることができました。
また、徐々にユーザ数・リクエスト数を増やすなどのかゆいところにも手が届く印象です。
これをCIで定期的に実行させればサービスの健全性も可視化することができそうですね。よければ参考にしてみてください。