見出し画像

APIの性能テストにLocustを使ってみた

はじめに

こんにちは。SHIFTのインフラ・アーキテクトの岡田です。

テストツールのLocustを使用してAPIの性能テストを行う機会がありました。そこで得た知見を、よく使用する機能に絞って紹介したいと思います。「Locustを使ってすぐに性能テストを始めたい!」という方の一助になれば幸いです。

Locustって何?

Locust( https://locust.io/ )はWebアプリケーションやAPIの性能テストを行うための、オープンソースの負荷テストツールです。同様のものとしてはJMeterやk6などが有名ですが、Locustの特徴は何といってもPythonでテストスクリプトを書けることです。

テストスクリプトは普通のPythonプログラムなので、使い慣れたIDEはそのまま使用できます。また、Pythonの豊富なパッケージを使ってテスト結果を処理したり、CI/CDに性能テストを組み込むことなども出来ますね。

Locustのインストールは簡単です。Python(3.7以降)をインストールして、

$ pip3 install locust

を実行するだけです。

Locustプログラミングのお作法

Hello Locust

それでは早速Locustのテストスクリプトを作ってみましょう。ターゲットとなるアプリケーションのURLは

https://example.com/test/locust-test

で、メソッドはGETだとします。 本ブログではこのアプリケーションを例として、それに負荷をかけるためのLocustスクリプトをいくつか紹介していきますが、example.comは架空のものです。スクリプトを実行する際はご自身でテスト用のAPIをご用意ください。

まず、このアプリケーションに対して負荷をかけるための、(ほぼ)ミニマムのLocustスクリプトを次に示します。

from locust import HttpUser, task, constant_throughput                   hellolocust.py      

class HelloLocust(HttpUser):
    host = "https://example.com"
    wait_time = constant_throughput(1)
    
    @task(1)
    def hello_locust(self):        
        with self.client.get("/test/locust-test", catch_response=True) as response:
            if response.status_code != 200:
                response.failure("statusCode is not 200")
                

このようにLocustスクリプトでは、HttpUserを継承したクラスを作成します。HttpUserはHTTPリクエストを発生させるユーザーを表すクラスですが、ブラウザーと考えればよいと思います。

継承クラス(ここではHelloLocust)のメソッドのうち、taskでデコレートされたものがLocustによって実行されます。ここで重要なのはclientですが、これはHttpSessionクラスのインスタンスです。HttpSessionはLocustのクラスですが、Pythonのrequests.Sessionクラスを拡張したもので、ほぼ元のSessionクラスと同じように使用できます。違いはURLのパス部分のみ指定できる点です。ホスト部分は以下のようにHttpUserクラスのhostプロパティで指定します。

host = "https://example.com"

また、拡張のひとつが上の例でも使用しているcatch_responseです。これにより、テストの成功・失敗の条件を自由に設定できます。

wait_time = constant_throughput(1)

については後程説明します。まずはLocustを実行してみましょう。上記のスクリプトをhellolocust.pyとして保存して、同じディレクトリーで次のコマンドを実行してください。毎秒1ユーザーずつ、4ユーザーまで増加させるという指定です。

$ locust -f hellolocust.py --headless --users 4 --spawn-rate 1

次のような出力が1秒間に1回ずつ、画面に表示されると思います。Avg、Minなどはミリ秒単位のレスポンス時間です。Ctrl+Cで実行を中断できます。

Type     Name                     # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
--------|-----------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
GET      /test/locust-test           114     0(0.00%) |     36      18     368     34 |    4.00        0.00
--------|-----------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
         Aggregated                  114     0(0.00%) |     36      18     368     34 |    4.00        0.00

スループットを調整する

それでは上で飛ばした

wait_time = constant_throughput(1)

について説明します。これはユーザー当たりの秒間のスループットの指定で、この例ではユーザー当たり毎秒1リクエストを発生させることになります。上の例では4ユーザーで実行したので、req/sが4.00になっています。わかりやすいですね。

注意する点として、constant_throughputはスループットを遅くする方向の調整としてのみ働くということです。例えばレスポンスに2秒要するアプリケーションに対してconstant_throughputを0.5以上にすることは意味がありません。

次に2つのAPIに同時に負荷をかけるケースを考えてみます。この場合、APIによってかける負荷を変えたい場合がよくあると思います。ここでは

  • /test/locust-test  には毎秒1リクエスト

  • /test/locust-test-2 には毎秒3リクエスト

の負荷をかけることとします。そのために先ほどのスクリプトを以下のように少し修正します。

class HelloLocust(HttpUser):                                             multimethod.py
    host = "https://example.com"
    wait_time = constant_throughput(1)
    
    @task(1)
    def hello_locust(self):
        with self.client.get("/test/locust-test", catch_response=True) as response:
            if response.status_code != 200:
                response.failure("statusCode is not 200")
                
    @task(3)
    def hello_locust_2(self):
        with self.client.get("/test/locust-test-2", catch_response=True) as response:
            if response.status_code != 200:
                response.failure("statusCode is not 200")

このように、@taskデコレーターの引数でそれぞれのメソッドを呼び出す重みを変えることが出来ます。このスクリプトをmultimethod.pyとして保存し、先ほどと同じく次のコマンドを実行します。

$ locust -f multimethod.py --headless --users 4 --spawn-rate 1

ここでは、

  • ユーザー数 4

  • ユーザー当たりのスループット 毎秒1リクエスト

  • メソッドに対する重みづけ 1:3

としたので、結果は以下のように目標通り(req/sの所をご覧ください。)となりました。なるほどですね。

Type     Name                     # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
--------|-----------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
GET      /test/locust-test            31     0(0.00%) |     26      18      39     26 |    1.00        0.00
GET      /test/locust-test-2          81     0(0.00%) |     28      17     104     25 |    3.00        0.00
--------|-----------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
         Aggregated                  112     0(0.00%) |     27      17     104     25 |    4.00        0.00

上の例では、locust-testとlocust-test-2のレスポンス時間はほぼ同じ(平均で26msecと28msec)でした。それではレスポンス時間が大きく異なるAPIに任意の負荷(秒間のスループット)をかける場合はどうなるでしょうか?以下のテストケースを考えてみます。

一つの方法は、以下のように異なるレスポンス時間を持つAPIごとにHttpUserを継承したクラスを用意することです。

class FastApp(HttpUser):                                                   multiclass.py
    :
    wait_time = constant_throughput(ct_fast)
    
    @task(weight_1)
    def fast_1(self):
        with self.client.get("/test/locust-fast-1", catch_response=True) as response:
            :
                
    @task(weight_2)
    def fast_2(self):
        with self.client.get("/test/locust-fast-2", catch_response=True) as response:
            :
            
class SlowApp(HttpUser):
    :
    wait_time = constant_throughput(ct_slow)
                
    @task(1)
    def slow(self):
        with self.client.get("/test/locust-slow", catch_response=True) as response:
            :

調整できるパラメーターは以下の5種類です。

  • 2つのクラスのスループット ct_fastとct_slow

  • 2つのメソッドの重みづけ  weight_1とweight_2

  • 実行ユーザー数       user

それでは順番に考えてみましょう。まずlocust-slowはレスポンス時間が5秒なので、1ユーザ当たりのスループットは0.2秒になります。ct_slowはこの値より大きくする意味はないので、ct_slow = 0.2とします。locust-slowのテスト要件は2リクエスト/秒なので、SlowAppクラスには10ユーザー割り当てる必要があります。locustコマンドで指定した実行ユーザー数は各クラスに均等に割り振られるので、FastAppクラスにも10ユーザー割り当てられることになります。つまり全体で実行ユーザー数は20にする必要があります。

また、locust-fast-1とlocust-fast-2への負荷を合計する8リクエスト/秒となります。これをFastAppクラスに割り当てられるユーザー数10で割るとct_fast = 0.8と求められます。2つのメソッドの重みづけはそれぞれの負荷(リクエスト/秒)の比にすればよいので、weight_1 = 3、weight_2 = 5となります。

それではこのスクリプトをmulticlass.pyとして保存し、次のコマンドを実行してみます。

$ locust -f multiclass.py --headless --users 20 --spawn-rate 10

すると予想通り以下の出力が得られました。面白いですね。

Type     Name                     # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
--------|-----------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
GET      /test/locust-fast-1         113     0(0.00%) |    146     118     552    140 |    3.00        0.00
GET      /test/locust-fast-2         204     0(0.00%) |    138     117     512    130 |    5.00        0.00
GET      /test/locust-slow            60     0(0.00%) |   5087    5022    5404   5022 |    2.00        0.00
--------|-----------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
         Aggregated                  377     0(0.00%) |    928     117    5404    140 |   10.00        0.00

このようにメソッドレベルの負荷の重みづけ(@taskデコレーターの引数で指定する)、クラスレベルのスループット(constant_throughputの引数で指定する)、コマンドレベルの実行ユーザー数(--usersの引数で指定する)を組み合わせることで、テストで達成したいスループットを実現することになります。

初期処理を書く

APIのテストでは、初回だけ認証処理を実行したいことがよくあります。Locustにはそのための便利なメソッドon_startが用意されています。on_startメソッドに書かれた処理は、ユーザーごとに最初に一回だけ実行されます。以下は認証にClient Credentials Grantを使用するAPIをテストするためのLocustスクリプトの例です。他には、ユーザーIDのプールの中から一つのIDを選択するような処理の記述にもon_startが使えます。

auth_url = "https://example.com/oauth2/token"                              clientauth.py
authrization = "Basic base64-encoded-id:password"

class ClientAuth(HttpUser):
    @task(1)
    def some_test(self):
        headers = {
            "Authorization": self.access_token,
            "Content-Type": "application/json"
        }
        data = {...}        
        with self.client.post("/test/some-test", json=data, headers=headers, catch_response=True) as response:
        
    def on_start(self):
        headers = {
            "Authorization": authorization,
            "Content-Type": "application/x-www-form-urlencoded",
            "Accept": "application/json"
        }
        body = {
            "grant_type": "client_credentials",
            "scope": "example-scope"
        }
        response = requests.post(auth_url, data=body, headers=headers)
        response_body = response.json()
        self.access_token = response_body["access_token"]
        

CSVファイルへ出力する

これまでの例ではLocustのテスト結果を標準出力に表示していましたが、実際に性能テストを行う時は、CSVファイルに結果を出力したいことが多いと思います。次のコマンドでmulticlass.pyの結果をCSVファイルに出力してみましょう。ここでは-tオプションも併用して、5分間実行すると自動的にテストが終了するようにしています。これについても後述します。

locust -f multiclass.py --headless --csv filename --users 20 --spawn-rate 10 -t 5m

このコマンドを実行すると、以下の4つのファイルがカレントディレクトリーに作成されます。

  • filename_stats.csv

    • テスト結果のサマリーです。APIごとに秒間のリクエスト数や、レスポンス時間の平均・最小・最大値とパーセンタイル値が記録されます。レスポンス時間は全てミリ秒単位です。

  • filename_stats_history.csv

    • テスト実行中の1秒ごとの直近10秒間の統計情報が記録されます。デフォルトではAPIが集約されたもの(Aggregatedと表示されています)の統計情報しか記録されませんが、--csv-full-historyオプションを指定することにより、APIごとの統計情報も記録されるようになります。

  • filename_exceptions.csv

    • テスト実行中に例外が発生すると記録されます。

  • filename_failures.csv

    • ResponseContextManagerクラス(pythonのrequests.ResponseクラスのサブクラスとしてLocustが拡張したもの)のfailureメソッドで出力した内容が記録されます。


以下はfilename_stats.csvの例です。画像が小さいので、別タブで開いてみてください。

また、-tオプションでテスト実行時間を指定できます。s(秒)、m(分)、h(時間)の指定が可能で、300s、5m、1h30mなどと書くことが出来ます。

ツール自体の性能はどのくらいだろう?

ところで、Locustを実行するためのマシンの性能はどの程度必要でしょうか?

AWS上にLocust実行用のEC2(インスタンスタイプ:t2.micro)を用意して、複数のAPIに同時に負荷をかけるスクリプトで試してみました。合計で秒間200リクエストの負荷をかけるスクリプトを実行した時のCPU使用率は25%程度でした。試したのはシンプルなスクリプトなので、複雑なスクリプトを組んだ場合はこれ以上のCPUが必要になると思います。

おわりに

Locustの使い方を簡単に紹介させていただきました。Pythonプログラミングの経験があればすぐに使い始めることが出来るのが大きなメリットだと思います。

本ブログではLocustの多彩な機能のうち、ごく一部に触れただけです。Dockerで実行したり、高負荷をかけるために分散構成をとることが出来るなど、便利な機能がたくさんあるので、公式ドキュメント(英語版しかないことが玉に瑕ですが)を参考にもっとLocustを活用していこうと思います。



執筆者プロフィール:岡田 明
大手ITベンダーで20年間アーキテクトとして金融、製造、保険、通信、流通などのお客様のITシステムの開発に携わってきた。主に要件定義や基本設計などの上流工程で、システムの方向性を決める役割を果たしてきた。
SHIFTには2年前に入社し、現在はパブリッククラウドのインフラ設計・構築を担当している。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/