ローカル環境を使わずに k6 で負荷試験を書いてみた

## はじめに
CI/CD で負荷試験を実行するために Grafana k6 を使ってみたので導入部分を記事にしてみました

## Grafana k6 とは
オープンソースで開発されている負荷試験用ツール
並列実行数や実行時間を指定して API を呼び出し、閾値を設けて試験の合否を判定できます
2025年5月6日にバージョン 1.0.0 が公開されました[^1]
[^1]: 0 系の開発は以前から行われています

## k6 を使ってよかった点
使っていて感じたメリットを書きました
* シングルバイナリ
バイナリファイルをダウンロードして配置するのみで実行できるため、リモート環境でも手軽に試せます
* TypeScript でテストコードを作成可能
npm で`types/k6`が提供されているため、専用コマンドにも TypeScript の補完が使えます
また、引数に直接`.ts`拡張子のファイルを指定して実行できます
* バイナリが重すぎない
zip 化して Lambda Layer として登録できます

## 負荷試験を Lambda で実行する
手軽にテストするため Lambda で k6 を呼び出す関数を実装しました
* リモート環境で編集しながら実行できます
* API やパイプラインに組み込みやすいです
* Step Functions を使って並列実行やリトライ処理を簡単に設定できます

## テストする API
REST API と GraphQL API を検証するために API Gateway と AppSync を使って API を作成します
今回はテスト用なので API Gateway は mock で作成して AppSync もデータソースのない JavaScript リゾルバで実装しました
他のサービスに接続せず、単純な文字列のみを返す API にしています
また、条件を揃えるためにどちらの API も API Key を設定しました

## k6 バイナリを Lambda Layer に登録
1. [Git Hub](https://github.com/grafana/k6/releases)から`linux-arm64.tar.gz`をダウンロードして解凍します
`linux-amd64.tar.gz`でも大丈夫です、その場合は以降で指定するアーキテクチャーを`arm64`から`x86_64`に読み替えてください
2. `bin`という名前のディレクトリを作成して、解凍した`k6`のバイナリファイルを入れて zip 化します
    ```
    .
    └── bin/
        └── k6                    ← k6 バイナリ
    ```
3. AWS コンソールで Lambda Layer を作成します
zip ファイルは 30 MB より少し大きいくらいのサイズなので、50 MB 以内に収まります
4. アーキテクチャーに`arm64`を指定して、ランタイムに`Node.js`を指定して作成します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4080608/837aa96a-3185-4aaf-9562-4dde5ae9aaf3.png)

## K6 を実行する Lambda
REST API 用の Lambda と GraphQL API 用の Lambda を別々に作成します
* ランタイム
Node.js（今回は`Node.js 22.x`を指定）
* アーキテクチャ
arm64（Lambda Layer とそろえます）
* タイムアウト
デフォルトが　3 秒なのでタイムアウトしないように 30 秒に変更します
* メモリ
テストで使用されるメモリがデフォルトの 128MB を超えるので 1024MB 以上に設定します[^2]
[^2]: 今回は vus の値を 1000 にしたので、最大使用メモリの値は 400~600MB 程度でした
* 環境変数
`API_KEY`を定義します
* ディレクトリ構成
    ```
    .
    ├── handler.mjs               ← Lambda ハンドラー
    ├── scripts/
    │   ├── k6.ts                 ← k6 テストスクリプト（TypeScript）
    └── layers/
        └── k6-layer.zip          ← /bin/k6 を含んだ layer（Lambda では /opt/bin/k6 に展開）
    ```
* hander.mjs
    ```javascript
    import { spawnSync } from "child_process";
    import { fileURLToPath } from "url";
    import { dirname, join } from "path";
    
    export async function handler() {
      const scriptPath = join(
        dirname(fileURLToPath(import.meta.url)),
        "scripts",
        "k6.ts"
      );
      const options = [
        "run",
        "--env",
        `API_KEY=${process.env.API_KEY}`,
        scriptPath,
      ];
      const result = spawnSync("/opt/bin/k6", options, { encoding: "utf-8" });
      console.info("k6 stdout:", result.stdout);
      if (result.error || result.status !== 0) {
        throw new Error();
      }
    }
    ```
    Lambda Layer で登録された k6 をコマンドにして、引数に`k6.ts`ファイルを指定して実行させます
    エラーを受け取った場合はエラー終了にして、閾値を下回った場合も Lambda を異常終了させます
    API Key は環境変数で指定いして`process.env.API_KEY`をコマンドのオプションとして渡しています
* k6.ts 
下記は GraphQL API 用の実装例です
    ```typescript
    import http from "k6/http";
    import { check, sleep } from "k6";
    
    export const options = {
      vus: 1000,
      duration: "10s",
      thresholds: {
        http_req_failed: ["rate<0.01"],
        http_req_duration: ["p(95)<1000"],
        checks: ["rate>0.9"],
      },
    };
    
    export default function () {
      const params = {
        headers: {
          "Content-Type": "application/json",
          "x-api-key": __ENV.API_KEY,
        },
      };
      const query = JSON.stringify({ query: "query MyQuery { test }" });
      const res = http.post(
        "https://<エンドポイント>.appsync-api.<リージョン>.amazonaws.com/graphql",
        query,
        params
      );
      const body = JSON.parse(res.body);
      check(body, {
        "body check": (b) => b.data.test.includes("Hello from AppSync!"),
      });
      sleep(1);
    }
    ```
    パラメータを以下のように設定します
    * vus: 並列実行数（今回は 1000 件）
    * duration: 試験実行時間（今回は 10 秒）
    * thresholds
      * http_req_failed: リクエスト失敗率（上記では 1% 未満）
      * http_req_duration: レスポンス応答時間（上記では 95% が 1 秒未満）
      * checks: 関数の中で設定した check の成功割合（上記では 90% より多い）
    
    コマンドの引数で渡した API Key は`__ENV`で取得して設定します
    また、API 実行ごとに 1 秒間の待機時間を入れています

## Step Function で並列実行
並列にテストを走らせる Step Functions を作成します
今回は、REST API と GraphQL API それぞれを試験する Lambda を並列で実行する処理を記載しました
```json
{
  "StartAt": "Parallel",
  "States": {
    "Parallel": {
      "Type": "Parallel",
      "Branches": [
        {
          "StartAt": "API Gateway Load Test",
          "States": {
            "API Gateway Load Test": {
              "Type": "Task",
              "Resource": "arn:aws:states:::lambda:invoke",
              "Arguments": {
                "FunctionName": "arn:aws:lambda:<リージョン>:<アカウントID>:function:<関数名>:$LATEST"
              },
              "End": true
            }
          }
        },
        {
          "StartAt": "AppSync Load Test",
          "States": {
            "AppSync Load Test": {
              "Type": "Task",
              "Resource": "arn:aws:states:::lambda:invoke",
              "Arguments": {
                "FunctionName": "arn:aws:lambda:<リージョン>:<アカウントID>:function:<関数名>:$LATEST"
              },
              "End": true
            }
          }
        }
      ],
      "Catch": [
        {
          "ErrorEquals": [
            "States.ALL"
          ],
          "Comment": "error",
          "Next": "Fail"
        }
      ],
      "Next": "Succeed"
    },
    "Succeed": {
      "Type": "Succeed"
    },
    "Fail": {
      "Type": "Fail"
    }
  },
  "QueryLanguage": "JSONata"
}
```
実行すると以下のような画面が表示されます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4080608/18e50314-95de-48e4-8e29-0237cd8a753c.png)
Lambda の実行結果は CloudWatch で確認できます

## 実行結果
REST API と GraphQL API それぞれで以下のような実行結果が得られます
```bash
         /\      Grafana   /‾‾/  
    /\  /  \     |\  __   /  /   
   /  \/    \    | |/ /  /   ‾‾\ 
  /          \   |   (  |  (‾)  |
 / __________ \  |_|\_\  \_____/ 

     execution: local
        script: /var/task/scripts/k6.ts
        output: -

     scenarios: (100.00%) 1 scenario, 1000 max VUs, 40s max duration (incl. graceful stop):
              * default: 1000 looping VUs for 10s (gracefulStop: 30s)


running (00.9s), 1000/1000 VUs, 0 complete and 0 interrupted iterations
default   [   9% ] 1000 VUs  00.9s/10s

running (01.9s), 1000/1000 VUs, 984 complete and 0 interrupted iterations
default   [  19% ] 1000 VUs  01.9s/10s

running (02.9s), 1000/1000 VUs, 1977 complete and 0 interrupted iterations
default   [  29% ] 1000 VUs  02.9s/10s

running (03.9s), 1000/1000 VUs, 2971 complete and 0 interrupted iterations
default   [  39% ] 1000 VUs  03.9s/10s

running (04.9s), 1000/1000 VUs, 3963 complete and 0 interrupted iterations
default   [  49% ] 1000 VUs  04.9s/10s

running (05.9s), 1000/1000 VUs, 4960 complete and 0 interrupted iterations
default   [  59% ] 1000 VUs  05.9s/10s

running (06.9s), 1000/1000 VUs, 5958 complete and 0 interrupted iterations
default   [  69% ] 1000 VUs  06.9s/10s

running (07.9s), 1000/1000 VUs, 6958 complete and 0 interrupted iterations
default   [  79% ] 1000 VUs  07.9s/10s

running (08.9s), 1000/1000 VUs, 7958 complete and 0 interrupted iterations
default   [  89% ] 1000 VUs  08.9s/10s

running (09.9s), 1000/1000 VUs, 8958 complete and 0 interrupted iterations
default   [  99% ] 1000 VUs  09.9s/10s

running (10.9s), 0032/1000 VUs, 9954 complete and 0 interrupted iterations
default ↓ [ 100% ] 1000 VUs  10s


  █ THRESHOLDS 

    checks
    ✓ 'rate>0.9' rate=100.00%

    http_req_duration
    ✓ 'p(95)<1000' p(95)=33.92ms

    http_req_failed
    ✓ 'rate<0.01' rate=0.00%


  █ TOTAL RESULTS 

    checks_total.......................: 9986    906.876199/s
    checks_succeeded...................: 100.00% 9986 out of 9986
    checks_failed......................: 0.00%   0 out of 9986

    ✓ body check

    HTTP
    http_req_duration.......................................................: avg=16.58ms min=8.6ms med=12.97ms max=302.7ms p(90)=18.85ms p(95)=33.92ms
      { expected_response:true }............................................: avg=16.58ms min=8.6ms med=12.97ms max=302.7ms p(90)=18.85ms p(95)=33.92ms
    http_req_failed.........................................................: 0.00%  0 out of 9986
    http_reqs...............................................................: 9986   906.876199/s

    EXECUTION
    iteration_duration......................................................: avg=1.06s   min=1s    med=1.01s   max=2.28s   p(90)=1.22s   p(95)=1.52s  
    iterations..............................................................: 9986   906.876199/s
    vus.....................................................................: 32     min=32        max=1000
    vus_max.................................................................: 1000   min=1000      max=1000

    NETWORK
    data_received...........................................................: 9.0 MB 815 kB/s
    data_sent...............................................................: 1.7 MB 151 kB/s




running (11.0s), 0000/1000 VUs, 9986 complete and 0 interrupted iterations
default ✓ [ 100% ] 1000 VUs  10s
```


## 結果
今回のような簡単な API であれば 1000 並列くらいであれば安定していました
応答速度は実行ごとにばらつきがありましたが平均的には REST API と GraphQL API であまり差はなかったです
また、どちらも API でも安定して 95% 以上の応答が 100ms 以内になっていました

## 終わりに
開発環境等を用意せずに AWS マネジメントコンソールのみで実装できました
サーバーレス構成で負荷試験が実行できるので、コストを抑えて試しやすいと感じました

## 参考
[k6](https://grafana.com/docs/k6/latest/)
[サーバーレスで負荷試験！Step Functions + Lambdaを使ったk6の分散実行](https://speakerdeck.com/mokopoi/sabaresudefu-he-shi-yan-step-functions-plus-lambdawoshi-tutak6nofen-san-shi-xing)