【負荷試験ツール】Locustの試験シナリオをhar2locustで生成してみた

この記事は[NTTコムウェア Advent Calendar 2023](https://qiita.com/advent-calendar/2023/nttcomware) 5日目の記事です。

## はじめに
NTTコムウェアの雲雀です。好きなモビルスーツはグフカスタムです。
ガトリングシールドがたまらなくカッコいいですよね！

そんな私は普段システムの負荷試験に[Gatling](https://gatling.io/)を愛用しているのですが、今年はGatling以外の負荷試験ツールを触る機会があったので、そのなかのひとつ[Locust](https://locust.io/)を紹介したいと思います。

![locust.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629072/ff484603-14c9-d82d-fc86-b0e3bc7c3c5f.png)

Locustのアイコンを見て「何だありゃ？！バッタか？」と思うかもしれませんが、イナゴだそうです。時々イナゴの大量発生がニュースになったりしますが、大量の負荷（イナゴ）を発生させるイメージからこのアイコンになったんじゃないかなーと。

ちなみに[har2locust](https://github.com/SvenskaSpel/har2locust)はLocustの試験シナリオを生成するツールです。
Locustにhar2locustを組み合わせて使うことで、素早く負荷試験のシナリオを作成して試験に取り掛かれます。

例えるなら、ゼフィランサスがフルバーニアンになる感じです。
・・・こじつけ過ぎですね。本題に移ります。

## Locustとhar2locustの概要
Locustは試験シナリオをPythonで書くことができる負荷試験ツールです。
Load-Test-as-Codeとして、アプリケーションのコードと一緒に負荷試験の内容もコードで管理できるため、Pythonエンジニアには扱いやすいツールかと思います。

har2locustはブラウザの操作を記録したHARファイル[^1]からLocustの試験シナリオを生成するジェネレータです。
Pythonがいくら書きやすい言語だといっても、ゼロから試験シナリオを手書きで用意するのは大変です。ラクできるところはラクしましょう。

[^1]:HARはHTTP ARchiveのことで、ブラウザとWebサイトの間のセッションデータをjson形式で保存したファイルです。

## har2locustでシナリオを生成
Locustについて調べた際、基本的な使い方は各種ブログ等で情報が出てくるなか、シナリオ生成に用いるhar2locustには殆ど触れられていなかったので、この記事ではhar2locustにフォーカスしてみます。

ということで、以降はhar2locustでシナリオを生成する流れを説明していきます。

### 事前準備
シナリオ生成の元データとなるブラウザ操作の記録を取得します。

例として[Locustのドキュメントサイト](https://docs.locust.io/en/stable/index.html)を試験対象システムとして見立て、Microsoft EdgeでブラウジングしてHARファイルを作成してみます。

予めブラウザのキャッシュやCookieを削除してから開発者ツールを開いておき、画像赤枠部の「ログの保持」を有効にして、[Locustのドキュメントサイト](https://docs.locust.io/en/stable/index.html)に接続します。

![har1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629072/76d8bd4d-3c85-d03f-aad7-ce2ddf70584e.png)

ページ遷移もしてみましょう。上図の青枠部をクリックして他のページを覗いてみます。
（この後の説明の都合上、画像のあるページを選んでいます。）

![har2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629072/42758571-39af-6f8b-4e5d-843a81389928.png)

上図の赤枠部にセッションデータが記録されましたので、HARファイルに保存します。
右クリックを押して、「コンテンツを含むすべてをHARとして保存する」を実行してください。

![har3.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629072/3923d7fd-85da-d710-8213-c442305111b9.png)

保存したHARファイル(docs.locust.io.har)の中身をテキストエディタ等で覗いてみると、ページ遷移の情報や、各リクエストの情報が記録されていますね。
（注）以下の例はHARファイルから説明上必要な個所のみ抽出しています。

```json:docs.locust.io.har
{
  "log": {
    "pages": [
      {
        "id": "page_1",
        "title": "https://docs.locust.io/en/stable/index.html",
      },
      {
        "id": "page_2",
        "title": "https://docs.locust.io/en/stable/quickstart.html",
      }
    ],
    "entries": [
      {
        "_resourceType": "document",
        "pageref": "page_1",
        "request": {
          "method": "GET",
          "url": "https://docs.locust.io/en/stable/index.html",
        }
      },
      {
       "_resourceType": "other",
        "pageref": "page_1",
        "request": {
          "method": "GET",
          "url": "https://docs.locust.io/favicon.ico",
        }
      }
      {
        "_resourceType": "stylesheet",
        "pageref": "page_1",
        "request": {
          "method": "GET",
          "url": "https://docs.locust.io/en/stable/_static/css/theme.css",
        }
      },
      {
        "_resourceType": "script",
        "pageref": "page_1",
        "request": {
          "method": "GET",
          "url": "https://www.google-analytics.com/analytics.js",
        }
      },
      {
        "_resourceType": "document",
        "pageref": "page_2",
        "request": {
          "method": "GET",
          "url": "https://docs.locust.io/en/stable/quickstart.html",
        }
      },
      {
        "_fromCache": "memory",
        "_resourceType": "script",
        "pageref": "page_2",
        "request": {
          "method": "GET",
          "url": "https://docs.locust.io/en/stable/_static/documentation_options.js",
        }
      },
      {
        "_resourceType": "image",
        "pageref": "page_2",
        "request": {
          "method": "GET",
          "url": "https://docs.locust.io/en/stable/_images/webui-splash-screenshot.png",
        }
      },
```

よく見ると、Google Analyticsなど試験対象システム以外へのリクエストや、キャッシュを利用するため実際には発出されないリクエストの情報も含まれています。
これらは不要なので、jqコマンドでフィルタリングしておきます。

```bash
$ cat docs.locust.io.har | \
  jq 'del(.log.entries[] | select(.request.url|contains("https://docs.locust.io")|not))' | \
  jq 'del(.log.entries[] | select(._fromCache == "memory" or ._fromCache == "disk"))' > SampleScenario.har
```

リダイレクト先のファイル名はこの後シナリオを生成する際にクラス名として使われるため、全角文字は使わないようにしましょう。
ここではSampleScenario.harというファイル名にしています。

これでシナリオ生成の元データが準備できました。

### Locustとhar2locustのインストール
次はLocustとhar2locustをインストールしていきます。
Locust本体のインストールや実行方法は検索すると情報が出てくるので詳細はそちらにお任せして、ここでは実行したコマンドだけ載せています。
har2locustはLocust本体には含まれていないため、以下のコマンドでLocustと一緒にインストールします。

```bash
$ pip install locust har2locust
```

本記事の執筆時にインストールされたバージョンは以下でした。
```bash
$ pip freeze | grep locust
har2locust==0.8.4
locust==2.19.1
```

### har2locustの実行
har2locustの実行はコマンド引数でHARファイルを読み込ませるだけです。

```bash
$ har2locust SampleScenario.har > sample_scenario.py
```

実行するとシナリオが生成されます。中身を見てみましょう。

```python:sample_scenario.py
from locust import task, run_single_user
from locust import FastHttpUser

class SampleScenario(FastHttpUser):
    host = "https://docs.locust.io"
    default_headers = {
        # snip
    }

    @task
    def t(self):
        with self.client.request(
            "GET",
            "/en/stable/index.html",
            headers={
                # snip
            },
            catch_response=True,
        ) as resp:
            pass
        with self.client.request(
            "GET",
            "/favicon.ico",
            headers={
                # snip
            },
            catch_response=True,
        ) as resp:
            pass
        with self.client.request(
            "GET",
            "/en/stable/quickstart.html",
            headers={
                # snip
            },
            catch_response=True,
        ) as resp:
            pass

if __name__ == "__main__":
    run_single_user(SampleScenario)
```

・・・ん？
なんだかリクエストが少ないような。ページ遷移も分かれてないような。。。

そうなのです。har2locustはデフォルトだとページ遷移の情報を扱わず、JavaScriptやCSS、画像などのリクエストを除外してシナリオを生成しようとします。

ページ遷移毎にリクエストをまとめて、ページ遷移間に思考遅延を挟んだり、画像などの静的コンテンツも扱うには物足りない気配です。

また、試験結果を確認する際、各リクエストがどの画面のものなのか分かるように表示したいですが、このままだと対応できません。

### har2locustのカスタマイズ
まだだ！まだ終わらんよ！

har2locustの[README](https://github.com/SvenskaSpel/har2locust#har2locust)にこんなことが書いてあります。

> At its core har2locust uses a jinja2 template to define the output. You can easily change that to customize your output, or you can go even further and use the plugin system to make any kind of changes to the processing/output.

jinja2のテンプレートやプラグインをいじって、カスタマイズできるのだと！

グフが強化されてグフカスタムになるように、har2locustもいい感じにカスタマイズしていきましょう。

#### テンプレートの作成
har2locustがシナリオ出力に使用するjinja2のテンプレートを独自のものに差し替えていきます。

ページ遷移の順序性を考慮したシナリオを作成したいので、[SequentialTaskSet](https://docs.locust.io/en/stable/tasksets.html#sequentialtaskset-class)を採用した以下のテンプレートを用意しました。

HARファイルに含まれるページ遷移の情報からページ毎にリクエストを1つの[task](https://docs.locust.io/en/stable/writing-a-locustfile.html#tasks)にまとめて、各taskを上から順に実行していきます。なお、各task間にはwait_timeで設定した秒数の思考遅延が挟まれます。

これをlocust.jinja2というファイル名でhar2locustを実行するカレントディレクトリに配置します。

```python:locust.jinja2
from locust import task, run_single_user, constant
from locust import FastHttpUser, SequentialTaskSet

base_url = '{{host}}'

class {{class_name}}TaskSet(SequentialTaskSet):
    wait_time = constant(10)
    {% if default_headers %}
    default_headers = {
        {% for h in default_headers %}
        '{{ h.name }}': '{{ h.value }}',
        {% endfor %}
    }
    {% endif %}

    def on_start(self):
        self.client.headers = {{class_name}}TaskSet.default_headers

    {% set pages = [] %}
    {% for e in entries %}
      {{ pages.append(e.pageref) or "" }}
    {% endfor %}

    {% set ns_pg = namespace(counter = 0) %}
    {% set ns_req = namespace(counter = 0) %}
    {% for page in pages|unique %}
    {% set ns_pg.counter = ns_pg.counter + 1 %}
    @task
    def page_{{ '%02d' % ns_pg.counter }}(self):
        page_name = "画面名"
        {% for e in entries %}
        {% if e.pageref == page %}
        {% set ns_req.counter = ns_req.counter + 1 %}
        {% set request = e.request %}
        with self.client.{{ request.method | lower }}(
            name="{{ '%02d' % ns_pg.counter }}-" + page_name + "-{{ '%03d' % ns_req.counter }}" + "-{{ request.url.split('/')[-1] }}",
            {% if "http://" in request.url or "https://" in request.url %}
            url="{{ request.url }}",
            {% else %}
            url=base_url + "{{ request.url }}",
            {% endif %}
        {% if request.headers %} headers={
            {% for h in request.headers %}
            '{{ h.name }}': '{{ h.value }}',
            {% endfor %}},
        {% endif %}
        {% if request.postData %} data={
            {% for param in request.postData.params %}
            '{{ param.name }}': '{{ param.value }}',
            {% endfor %}},
        {% endif %}
            catch_response=True,
        ) as resp:
            pass
        {% endif %}
        {% endfor %}
    {% endfor %}

class {{class_name}}(FastHttpUser):
    tasks = [{{class_name}}TaskSet]
    host = '{{host}}'

if __name__ == "__main__":
    run_single_user({{class_name}})
```

#### プラグインの設定
har2locustにはresourcefilterというプラグインが組み込まれており、シナリオ内で扱うリクエストをHARファイル内にある"_resourceType"フィールドの値でフィルタリングしています。

＜READMEからの抜粋＞
>  --resource-types RESOURCE_TYPES  
>      Commas separated list of resource types to be included in the locustfile. Supported type are `xhr`,
>      `script`, `stylesheet`, `image`, `font`, `document`, `other`. Defaults to xhr,document,other.

デフォルトではxhr,document,otherのみが指定されていますが、ここでは静的コンテンツも扱うため、例として画像についても対象とするよう設定してみます。

har2locust.confという名前のファイルを、テンプレートと同じくhar2locustを実行する際のカレントディレクトリに配置することで設定可能です。

```:har2locust.conf
resource-types = xhr,document,other,image
```

### シナリオ生成
har2locustがカスタマイズできたので、改めてシナリオを生成します。

```bash
$ har2locust SampleScenario.har > sample_scenario_custom.py
```

```python:sample_scenario_custom.py
from locust import task, run_single_user, constant
from locust import FastHttpUser, SequentialTaskSet

base_url = "https://docs.locust.io"

class SampleScenarioTaskSet(SequentialTaskSet):
    wait_time = constant(10)
    default_headers = {
      # snip
    }

    def on_start(self):
        self.client.headers = SampleScenarioTaskSet.default_headers

    @task
    def page_01(self):
        page_name = "画面名"
        with self.client.get(
            name="01-" + page_name + "-001" + "-index.html",
            url=base_url + "/en/stable/index.html",
            headers={
              # snip
            },
            catch_response=True,
        ) as resp:
            pass
        with self.client.get(
            name="01-" + page_name + "-002" + "-favicon.ico",
            url=base_url + "/favicon.ico",
            headers={
              # snip
            },
            catch_response=True,
        ) as resp:
            pass

    @task
    def page_02(self):
        page_name = "画面名"
        with self.client.get(
            name="02-" + page_name + "-003" + "-quickstart.html",
            url=base_url + "/en/stable/quickstart.html",
            headers={
              # snip
            },
            catch_response=True,
        ) as resp:
            pass
        with self.client.get(
            name="02-" + page_name + "-004" + "-webui-splash-screenshot.png",
            url=base_url + "/en/stable/_images/webui-splash-screenshot.png",
            headers={
              # snip
            },
            catch_response=True,
        ) as resp:
            pass
    # snip
class SampleScenario(FastHttpUser):
    tasks = [SampleScenarioTaskSet]
    host = "https://docs.locust.io"

if __name__ == "__main__":
    run_single_user(SampleScenario)
```

今度は静的コンテンツのリクエストも含まれており、ページ遷移の順序性も考慮されたシナリオになっています。
(page_name = "画面名"を実際の画面名に合わせて編集すると、レポートを見るときにより分かりやすくなります。今回はこのまま編集せずに進めます。）

シナリオを読み込ませてLocustを実行してみましょう。

```bash
$ locust -f sample_scenario_custom.py
```
Locustが起動するとWebUIに接続できるようになります。

![exec1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629072/cd42fcf0-5352-6607-02f9-c97145849512.png)

「Start swarming」を押して試験開始です。

![exec2.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3629072/df2b300e-0f17-685c-d794-7d8e2af25d34.png)

こいつ…動くぞ！

各リクエストがどの画面のものなのかも分かりやすく表示できています。

と、このまま終わりたいところですが、最後に補足と注意書きを。

## おわりに

今回の試験シナリオの例はステートレスなWebサイトが対象で扱う内容も静的コンテンツのみだったため、HARファイルから生成した試験シナリオがそのまま動きましたが、ステートフルなWebサイトを対象にする場合は動的な値を扱う箇所をアレコレ編集する必要があります。

とはいえ、ゼロから手書きで作るよりは断然ラクになりますので、Locustを使う際はぜひhar2locustも一緒に使ってみてください。

なお、本記事の試験シナリオはあくまで説明上の例です。負荷量を上げてLocustのサイトに大量のリクエストを発生させることは絶対にやめてください。

悪いことするとサンタさんではなくお巡りさんが来てしまうので、下記注意事項をご覧になって良いクリスマスをお過ごしください。

### 注意事項

- 本内容については、Dos攻撃を助長するものではございません。悪用を禁止致します。
- あくまで自身のサイトのテスト目的での利用に限ります。

---
※ 記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。