負荷試験ツールlocustのシナリオ構成サンプルを作成してみた

Photo by Victor Freitas on Unsplash

Python製の負荷試験ツールであるlocustを使う機会があった。試行錯誤して構成サンプルを作成してみた。

gorou-178/locust-sample

シナリオをレゴのように組み合わせて作成できるため便利だと思っている。Python初心者であるため改善余地は多分にあるはず。

以下を工夫してみた。

シナリオ全体で共有したいデータの共有方法

locustは、イベント駆動で各スレッドがリクエストして負荷をかける。masterとworkerを分けて、シナリオの並列実行が可能(単独でも実行可能)。 シナリオのベースクラスとなる HttpUser に直接タスクを書くことができる。この場合データ共有はあまり気にしなくてすむ。

locustにはTaskSetを複数組み合わせて実行したり(ネストさせることもできる)、実行順序を保証するような仕組みもある。TaskSetで最小単位で分割して、組み合わせて実行できるようにした方が管理しやすく変更しやすい。 このTaskSetを複数利用すると、TaskSet側でデータを共有できなければいけなくて困った。たとえば認証や、ユーザー毎に取得したデータ。これらデータは、「シナリオ全体で利用したい」ケースが多いはず。

そこで、HttpUser を継承した BaseHttpUser を作った。この BaseHttpUser クラスに認証情報やユーザーデータを保持しておく。

class BaseHttpUser(HttpUser):
    abstract = True

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.test_data = "test_data"

TaskSetはself.userで HttpUser インスタンスを参照できるため、以下のようにして参照ができる。

self.user.<オリジナルデータ>

ちなみに、 HttpUser を継承する場合は必ず abstract = True を設定すること。これにより適切に BaseHttpUser が利用できる。

APIリクエストクラスをmodule化

テストしたいAPIのリクエスト(ユースケース単位)で関数にしておくことで、シナリオ側で任意に呼びだすことができるためmodule化した。

関数は、locustのHttpSessionクラスを受け取り関数側で利用する形にしている。これは、HttpSession.certでクライアント証明書を設定する仕組みがある。アクセスさせるユーザー毎に証明書を切り替える可能性があるためこの形にした。

def get(http_session: HttpSession, todo_id: int):
    http_session.request_name = "/todos/{todo_id}"
    http_session.get(f"/todos/{todo_id}",
                     headers={"Content-Type": "application/json"}
                     )
    http_session.request_name = None

【2023/03/04 追記】

コンテキストマネージャーを利用することで、request_nameの設定とエラー処理ができることが分かったため追記。直接 HttpSession.request_name の設定をしなくてすむため多少スッキリする。 with文は複数指定可能で、以下は rename_requestget の2つを指定してる。またtimeoutエラーは response.status_code が0になる。

def get(http_session: HttpSession, todo_id: int):
    with http_session.rename_request("/todos/{todo_id}"), \
            http_session.get(f"/todos/{todo_id}",
                             headers={"Content-Type": "application/json"},
                             timeout=10,
                             catch_response=True
                             ) as response:
        if 200 <= response.status_code < 300:
            response.success()
            return response.json()
        if response.status_code == 0:
            response.failure(f"timeout response: todo_id = {todo_id}")
        else:
            response.failure(f"error response: status_code = {response.status_code}")
        return None

【追記終わり】

また、 HttpSession.request_name の設定でリクエストグループの設定も行っている。HttpSession.request_name でリクエスト名を設定しておくことで、たとえば /todos/{id} のようにパスが統一されない場合バラバラに計測されてしまう。

デフォルトではリクエストパスが request_name になってしまうため、 /todos/{id} のように名前を設定することでグルーピングされ、まとめて計測される。むしろ必ず設定しておいた方がよいと思っている。

ただしこれには罠があって、 request_name を設定して続けて別のリクエストをした場合、この名称を引き継いでしまうため、サンプルでは request_name=None で消している(もっとスマートに消したい…)。

locustfileはTaskSetの組み合わせ

細かくユースケースのユーザーの操作にあたる処理をTaskSetに分割できると、locustfileで指定する tasks は、TaskSetを組み合わせることで指定できるようになるはず。 以下はTodoを作成して取得するシナリオを実行するlocustfile。とてもシンプルになった。

class TodosTest(BaseHttpUser):
    tasks = {CreateTodoScenario}
    wait_time = constant(1)

locustfileは実行するシナリオとwait_timeなどの実施方法のみ記述すればよくなりとてもスッキリする。また、各TaskSetの内容がドキュメント化されていれば、内容を詳しく知らなくてもシナリオを組み立てて実行ができるようにもなる。

まとめ

負荷試験ツールlocustに入門してみて、個人的に作りやすい形ができたためサンプルとして作成してみた。 Pythonはほぼやったことがなく手探りで進めていたが形になってよかった。

駄文

ゼルダBoWをプレイすることを優先させているため、ブログお休みがち。 2, 3日休んでひたすらプレイしていたいと思わせるくらいに楽しい。新作が発売されるまでにはクリアしたい。