Believe you can

If you can dream it, you can do it.

k6で気軽に負荷試験を実行してみよう!

ZOZO Advent Calendar 2024 カレンダー Vol.8 の 20 日目の記事です。

今年も黒トイプードル琥珀の2歳の誕生日にアドベントカレンダーを書くことができました。
Happy Birthday!!

k6とは

Grafana Labs社が開発している負荷試験ツールです。
k6はオープンソースのCLIツールですが、 「Grafana Cloud k6」というクラウドベースSaaSツールも提供されています。

k6.io

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"

Htmlレポート

出力項目の説明

無事に負荷テストを実行することができました。どの項目がどのような意味なのかわかりにくいと思うので簡単にまとめてみました。

項目 説明
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で定期的に実行させればサービスの健全性も可視化することができそうですね。よければ参考にしてみてください。