k6で負荷試験してpandasで集計した話

## はじめに

この記事はHRBrain Advent Calendar 2022 カレンダー3の19日目の記事です。

https://qiita.com/advent-calendar/2022/hrbrain

こんにちは！株式会社HRBrainでエンジニアをしているZamaです。

みなさんは負荷試験してますか？
画面をポチポチする機能テストはちゃんとやっていても、負荷試験は忘れられていたり先送りにされてしまいがちですよね……
そんな負荷試験ですが、k6を用いて実施したら結果表示がイマイチだったので詳細なログファイルをpandasで集計した話です！

## k6とは

[k6](https://k6.io)は負荷試験のツールで、JavaScriptでAPIの呼び出しなどを記述します。

```js
import http from 'k6/http';

export default function () {
  const res = http.get('https://example.com');
  check(res, {
    'response code was 200': (res) => res.status == 200,
  });
}
```

OSSなのでローカル環境から負荷試験を実施できますが、[k6 Cloud](https://k6.io/cloud/)というサービスを利用すれば複数台のEC2インスタンスから負荷をかけられたり、詳細でリッチに可視化された結果を確認できたりします。

## k6の結果表示

今回はローカルから負荷試験を実施しました。
その結果がこんな感じです。

![k6-results-stdout.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103036/84cb30eb-4e3b-d029-fdb2-74aa25835bfd.png)

試験の中では複数のAPIを叩いているのですが、結果がまとまってしまっています……
APIごとにレイテンシーのパーセンタイルを見たりできず、どのAPIが要改善なのか分かりません……

APIごとの結果を出力できないかと[ドキュメント](https://k6.io/docs/)を確認しましたが、それらしい記述は見当たりませんでした。
k6 Cloudを使えばAPIごとの結果含め色々見られますが、~~アカウント登録とか面倒くさい。~~
そこでもうちょっとドキュメントを漁ったところ、[CSV出力](https://k6.io/docs/results-output/real-time/csv/)が実行時のオプションにありました！

```
$ k6 run --out csv=test_results.gz script.js
```

上記のように`--out csv=<ファイル名>`とオプションを付けて実行すれば、詳細なログがCSVファイルに吐き出されます。
さらにファイル名を`~.gz`とすればgzipまでしてくれ、ログファイルは巨大になりがちなので至れり尽くせりです。

そのCSVファイルの中身はこんな感じです。

![スクリーンショット 2022-12-15 10.27.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103036/19920680-c577-8cc4-5be6-d36976a901ba.png)

1リクエストあたり複数行（`http_req~`）出力され、`url`列に叩いたAPIのURLが入ります。
`http_req_duration`が

- `http_req_sending`
    - サーバーにリクエストを送信するのにかかった時間
- `http_req_waiting`
    - サーバーからレスポンスが来るまでに待った時間
    - TTFB (Time to first byte)
- `http_req_receiving`
    - サーバーからレスポンスを受信するのにかかった時間

の合計になるので、これをAPIごとに集計してあげれば良さそうです。

## 集計の前に

後は`http_req_duration`をAPIごとに集計してあげるだけなのですが、その前に1つ問題があります。
URLのパスにIDが含まれる（e.g. `~/users/123/`）ことがよくありますが、単純に`url`列でGroupByしてあげるとこういったID違いが別のAPIとして集計されてしまいます。
集計の際に正規表現で何とかすることもできそうですが~~面倒くさい。~~

そこで再びk6のドキュメントを漁るとありました！
JavaScript内でAPIを呼ぶ際に、下記のように[`name`列を指定](https://k6.io/docs/using-k6/tags-and-groups/)できます。

```js
const res = http.get(
    'https://example.com/users/100/',
    {
        tags: {
            name: 'GET retrieve user'
        }
    }
);
```

これで`name`列でGroupByして集計すればOKです！

## pandasで集計

集計にはPythonのデータ解析ライブラリーであるpandasを使いました。

CSVファイルはこんな感じなので（関係ない列は割愛）

![スクリーンショット 2022-12-15 10.34.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103036/2198900e-8685-008c-c0ec-804fd331b133.png)

pandasではこうなりました。
（pandasは不慣れで汚いコードですがご容赦ください）

```python:aggregate.py
import os.path
import sys

import pandas as pd

input_file_path = sys.args[1]
df = pd.read_csv(
    input_file_path, 
    usecols=['metric_name', 'metric_value', 'name']
)

output_columns = [
    'API', '50%', '60%', '70%', '80%', '90%', '95%', '99%'
]
output = pd.DataFrame(columns=output_columns)

for endpoint in sorted(df['name'].unique()):
    output_row = [endpoint]
    rows = df[df['name'] == endpoint]

    http_req_durations = rows[rows['metric_name'] == 'http_req_duration']
    percentiles = http_req_durations['metric_value'].quantile([
        0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99
    ])
    output_row.extend(percentiles.values)

    output = pd.concat([
        output,
        pd.DataFrame([output_row], columns=output_columns)
    ])

root, ext = os.path.splitext(input_file_path)
output.to_csv(f'{root}_集計結果.csv', index=False)
```

これで`python aggregate.py test_results.gz`と実行すると、下表のようにAPIごとのレイテンシーのパーセンタイルが集計されます。

![スクリーンショット 2022-12-15 10.35.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103036/3b2ddc74-3ecb-a812-ca09-dceaea65ddd6.png)

やりました！
これで同時接続数を変えたり、サーバーのスペックを変えたりして、負荷試験ができるようになりました！

## まとめ

k6を用いて負荷試験を行うにあたり、詳細なログをCSVファイルに出力し、pandasでその集計を行いました。
`name`列を指定してあげると集計が楽になるよというだけの話ですが、誰かの役に立つと幸いです。

## We are hiring!

株式会社HRBrainでは、Power to the peopleを合言葉に多種多様な人事課題を解決するプロダクトを開発・運営しています。
日本の労働環境を良くしてやるぜ！みんなが元気に活躍できる社会にしてやるぜ！
そんな熱い気持ちを持った方、一緒に働きませんか？
皆様のご応募をお待ちしております！

https://www.hrbrain.co.jp/recruit