CLOVER🍀

That was when it all began.

負荷テストツール、Locustで遊ぶ

これは、なにをしたくて書いたもの?

  • Locustという負荷テストツールがあると聞き、テストシナリオをプログラムで書けるそうなので試してみようかと

Locust - A modern load testing framework

Locust コトハジメ - Qiita

今回は、どんなツールか把握するところを目標に試してみます。

Locust?

Pythonで書かれた、負荷テストツールです。

Locust - A modern load testing framework

GitHub上のstar数も多くて、割と人気のツールのように見えます。

GitHub - locustio/locust: Scalable user load testing tool written in Python

オフィシャルサイトやGitHubによると、以下のような特徴を持つようです。
GitHubの内容の方が細かいですね

  • テストシナリオをPythonコードで書くことが可能
  • スケーラブルで、分散実行が可能
  • Web UI付き
  • どのようなシステムでもテストができる(ただし、Locustのコア機能はWebをターゲットにしている)
  • 小さく作られているので、ハックが容易

Locustは、IOに関する部分にgeventというライブラリを使用しています。

What is gevent? — gevent 1.4.1.dev0 documentation

Gevent チュートリアル

ちなみに、Locustは「イナゴ」を意味するんですね。ロゴを見ると、確かに…。

f:id:Kazuhira:20190111205727p:plain

機能的には、いろいろ見ていくとApache JMeterなどの方が多機能に見えたりもするのですが、手段のひとつとしてちょっと
押さえてみるとしましょう。

Locustのインストール

では、まず最初にLocustをインストールします。

Installation — Locust 0.9.0 documentation

LocustでサポートされているPythonのバージョンは、こちら。

Installation / Supported Python Versions

今回は、Ubuntu Linux 18.04 LTS上で、Python 3.6で使ってみます。

pipでインストールするらしいので、バージョン確認。

$ python3 -V
Python 3.6.7


$ pip3 -V
pip 9.0.1 from /usr/lib/python3/dist-packages (python 3.6)

インストール。

$ python3 -m pip install locustio

シェルを起動しなおすと、locustが動かせるようになっています。

$ locust --version
[2019-01-03 04:30:31,327] bdbddbe84a18/INFO/stdout: Locust 0.9.0
[2019-01-03 04:30:31,327] bdbddbe84a18/INFO/stdout:

あとは、Max open fiilesには注意しておいた方がいいですよ、と。

Installation / Increasing Maximum Number of Open Files Limit

これで、準備は完了です。

今回のテストターゲット

さて、Locustを使ったテストシナリオを書きたいところですが、テスト対象がないとどうにもなりません。

ログインとかあった方がいいし…でも、作るの面倒だし…と悩んだ結果、Redmineを使うことにしました。

Docker Composeで、さっくりと用意。
docker-compose.yml

version: '3'
services:
  redmine:
    image: redmine:4.0.0
    ports:
      - "3000:3000"
    restart: always
    environment:
      REDMINE_DB_MYSQL: "mysql"
      REDMINE_DB_DATABASE: "redmine"
      REDMINE_DB_USERNAME: "user"
      REDMINE_DB_PASSWORD: "password"
  mysql:
    image: mysql:5.7.24
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: "secret"
      MYSQL_DATABASE: "redmine"
      MYSQL_USER: "user"
      MYSQL_PASSWORD: "password"

DockerHub / Redmine

DockerHub / MySQL

起動。

$ docker-compose up

テスト対象のユーザーは、デフォルトの「admin」をそのまま使うことにします。パスワードは、「admin-password」に
変更しました。

このRedmineへは、「http://192.168.0.3:3000」でアクセスするものとします。

Locustでテストを書く

では、いよいよテストを書いてみるとしましょう。

このあたりを見ながら…

Quick start — Locust 0.9.0 documentation

Writing a locustfile — Locust 0.9.0 documentation

https://github.com/locustio/locust/tree/master/examples

まずは書いてみたのが、こちら。
locustfile.py

from locust import HttpLocust, TaskSet, task

import re

class UserBehavior(TaskSet):
    def on_start(self):
        self.login()

    def on_stop(self):
        self.logout()

    def login(self):
        response = self.client.get("/login")

        csrf_param = re.search("<meta name=\"csrf-param\" content=\"([^\"]+)\" />", response.text).group(1)
        csrf_token = re.search("<meta name=\"csrf-token\" content=\"([^\"]+)\" />", response.text).group(1)

        self.client.post("/login", {"username": "admin", "password": "admin-password", csrf_param: csrf_token})
    
    def logout(self):
        response = self.client.get("/")

        csrf_param = re.search("<meta name=\"csrf-param\" content=\"([^\"]+)\" />", response.text).group(1)
        csrf_token = re.search("<meta name=\"csrf-token\" content=\"([^\"]+)\" />", response.text).group(1)

        self.client.post("/logout", {csrf_param: csrf_token})

    @task
    def top(self):
        self.client.get("/")

    @task(2)
    def mypage(self):
        with self.client.get("/my/page", catch_response = True) as response:
            if response.status_code != 200:
                response.failure("not authenticated???")

    @task
    def projects(self):
        self.client.get("/projects")

class RedmineUser(HttpLocust):
    task_set = UserBehavior
    min_wait = 500
    max_wait = 1000

最初に、HttpLocust(Locustのサブクラス)を継承したクラスを作成します。

class RedmineUser(HttpLocust):
    task_set = UserBehavior
    min_wait = 500
    max_wait = 1000

task_setには、実際にテストを記載したクラスを渡します。

min_waitとmax_waitは、タスク(後述)の間の待機時間の最小、最大をそれぞれ指定します(ミリ秒)。この間の時間で、タスク間で
ランダムにwaitします。

wait_functionに関数を指定することで、もっと細かくコントロールすることも可能なようです。

Locust class

続いて、テストの内容を定義したのが、こちらのTaskSetを継承したクラス。

class UserBehavior(TaskSet):

今回は、ユーザーのシナリオの開始、終了時にそれぞれログイン、ログアウトするように作成しました。
※ログイン、ログアウトにはCSRF対策への対応を入れておきました…

    def on_start(self):
        self.login()

    def on_stop(self):
        self.logout()

    def login(self):
        response = self.client.get("/login")

        csrf_param = re.search("<meta name=\"csrf-param\" content=\"([^\"]+)\" />", response.text).group(1)
        csrf_token = re.search("<meta name=\"csrf-token\" content=\"([^\"]+)\" />", response.text).group(1)

        self.client.post("/login", {"username": "admin", "password": "admin-password", csrf_param: csrf_token})
    
    def logout(self):
        response = self.client.get("/")

        csrf_param = re.search("<meta name=\"csrf-param\" content=\"([^\"]+)\" />", response.text).group(1)
        csrf_token = re.search("<meta name=\"csrf-token\" content=\"([^\"]+)\" />", response.text).group(1)

        self.client.post("/logout", {csrf_param: csrf_token})

実行するとわかりますが、この書き方だとユーザーあたり、1回しかログイン、ログアウトを行いません。

あとは、タスクの定義です。@taskデコレーターでタスクを定義します。

task decorator

    @task
    def top(self):
        self.client.get("/")

    @task(2)
    def mypage(self):
        with self.client.get("/my/page", catch_response = True) as response:
            if response.status_code != 200:
                response.failure("not authenticated???")

    @task
    def projects(self):
        self.client.get("/projects")

@taskデコレーターの引数にはweightを指定でき、タスク実行の割合を調整できます。省略した時のweightは1なので、今回の例では
mypageタスクはtopおよびprojectsに比べると2倍実行されることになります。

あと、特に説明なく使ってきましたが、self.clientと書いているのはLocustのHTTPクライアントです。実体はHttpSessionという
クラスで、HTTPのGETやPOST、PUTなどに対応するメソッドを実行できます。

HttpSession class

    @task
    def top(self):
        self.client.get("/")

また、catch_responseにTrueを設定すると、通常はResponseというクラスが返るところが、ResponseContextManagerが
返るようになり、success/failureといった、テストの成功、失敗を制御することができるようになります。

    @task(2)
    def mypage(self):
        with self.client.get("/my/page", catch_response = True) as response:
            if response.status_code != 200:
                response.failure("not authenticated???")

今回は、マイページへのアクセスは、未ログイン状態だとログインページへリダイレクトしようとするので、ログイン
できていることを確認するために、ひと手間加えました。

Response class

ResponseContextManager class

実行してみる

ここまでできたところで、実行してみましょう。

作成したlocustfile.pyがあるディレクトリで、以下のコマンドを実行します。

$ locust --host=http://192.168.0.3:3000
[2019-01-11 12:34:02,388] be01ed106c53/INFO/locust.main: Starting web monitor at *:8089
[2019-01-11 12:34:02,388] be01ed106c53/INFO/locust.main: Starting Locust 0.9.0

「--host」の意味は、テスト対象のホストを指します。例のように、「http://〜」の形式で指定します。

テストが書かれたファイルの名前はデフォルトで「locustfile.py」となり、名前が異なる場合は「-f」で指定するようです。

コンソールにも出ていますが、8089ポートでWeb UIがリッスンしていますので、「http://[Locustが動いているサーバー]:8089」に
アクセスしてみます。

f:id:Kazuhira:20190111213806p:plain

すると、図のようにWeb UIが現れ、「Number of users to simulate(ユーザー数)」と「Hatch rate(秒あたり何ユーザー増やしていくか)」を
求められるので、こちらを入力して「Start swarming」を押すと、テストが始まります。

今回は「Number of users to simulate」を20、「Hatch rate」を2にしてテスト開始。

テスト実行中、結果がリアルタイムで更新されていきます。

f:id:Kazuhira:20190111215030p:plain

テストを終了させるには、右上にある「STOP」を押します。

f:id:Kazuhira:20190111215056p:plain

STATUSが「STOPPED」になり、その下にある「New test」リンクを押すと、また新しいテストを始めることができます。
※「Number of users to simulate」と「Hatch rate」の入力を求められます

結果の読み方は、ある程度まんまですが、

  • requests … リクエストした回数
  • fails … 失敗したリクエストの回数
  • Median (ms) … 中央値
  • Average (ms) … 平均値
  • Min (ms) / Max (ms) … 最小、最大
  • Content Size (byte) … コンテンツのサイズ
  • reqs/sec … 秒あたりに実行したリクエスト数

各項目の名称をクリックすることで、ソートすることができたりもします。

最後の行には「Total」があり、こちらの「reqs/sec」とページの右上部にある「RPS」の値が一致します。

また、ここまで見ていたのは「Statics」なのですが、隣の「Chart」を押すとグラフが見れたり

f:id:Kazuhira:20190111215647p:plain

f:id:Kazuhira:20190111215718p:plain

「Failures」を押すと、リクエストに失敗した場合に、その理由が見れたりします。

f:id:Kazuhira:20190111215747p:plain

自分は、ログインに最初ずっと失敗していて、ここにずっとエラーが出ていました…。

あと、例外の情報を見たり、結果のダウンロードができたりもします。

f:id:Kazuhira:20190111215859p:plain

次のテストを実行したり、Locustを終了すると、結果はなくなってしまうので回収しておきましょう。

なお、Locust終了時にも、近い情報を得ることができます。

[2019-01-11 13:00:20,978] be01ed106c53/INFO/locust.main: Running teardowns...
 Name                                                          # reqs      # fails     Avg     Min     Max  |  Median   req/s
--------------------------------------------------------------------------------------------------------------------------------------------
 GET /                                                            532     0(0.00%)     112      21     568  |      97    5.40
 GET /login                                                        20     0(0.00%)      47      18     154  |      27    0.00
 POST /login                                                       20     0(0.00%)     365     247     542  |     320    0.00
 POST /logout                                                      20     0(0.00%)     838     623    1012  |     840    0.00
 GET /my/page                                                    1152     0(0.00%)     162      28     602  |     140   12.40
 GET /projects                                                    518     0(0.00%)     116      25     342  |     110    4.50
--------------------------------------------------------------------------------------------------------------------------------------------
 Total                                                           2262     0(0.00%)                                      22.30

Percentage of the requests completed within given times
 Name                                                           # reqs    50%    66%    75%    80%    90%    95%    98%    99%   100%
--------------------------------------------------------------------------------------------------------------------------------------------
 GET /                                                             532     97    130    150    160    200    250    340    430    570
 GET /login                                                         20     28     57     79     85     96    150    150    150    150
 POST /login                                                        20    360    380    480    490    540    540    540    540    540
 POST /logout                                                       20    840    880    880    960   1000   1000   1000   1000   1000
 GET /my/page                                                     1152    140    180    210    240    300    350    410    440    600
 GET /projects                                                     518    110    140    150    170    200    240    270    280    340
--------------------------------------------------------------------------------------------------------------------------------------------
 Total                                                            2262    120    160    180    200    270    330    430    570   1000

テストシナリオの説明の時にも書きましたが、on_startおよびon_stopで実行するように指定した、ログインおよびログアウトは
20回(=ユーザー数)しか実行されていません。

このあたりは、タスクを定義したクラスのライフサイクルの関係でしょうねぇ。

Web UIなしで実行する

こう書くと、UIから実行して停止もしなくてはいけない…?と思うかもしれませんが、「--no-web」オプションを使うことで、
Web UIなしでも実行することができます。

Running Locust without the web UI — Locust 0.9.0 documentation

この場合、「-c」で「Number of users to simulate」、「-r」で「Hatch rate」を指定する必要があります。

$ locust --host=http://192.168.0.3:3000 --no-web -c 20 -r 2 -t 180s

また、実行時間を決める場合は、「-t」で指定することができます。
※指定しないと、延々と実行し続けるので、Ctrl-cで止めましょう

「locust -h」で、ヘルプを見てみましょう。

結果がファイルとして欲しい場合は、「--csv」で結果を出力します。

Retrieve test statistics in CSV format — Locust 0.9.0 documentation

$ locust --host=http://192.168.0.3:3000 --no-web -c 20 -r 2 -t 180s --csv perf-test

「--csv」で与えた値をprefixにして、requests.csvとdistribution.csvの2つのファイルができあがります。

これは、Web UIからダウンロードできたものと同じです。

perf-test_requests.csv

"Method","Name","# requests","# failures","Median response time","Average response time","Min response time","Max response time","Average Content Size","Requests/s"
"GET","/",1011,0,99,110,22,631,4618,5.60
"GET","/login",20,0,26,39,15,100,4805,0.11
"POST","/login",20,0,320,343,238,505,16570,0.11
"POST","/logout",20,0,730,750,572,950,4377,0.11
"GET","/my/page",1967,0,140,158,27,598,10468,10.90
"GET","/projects",1012,0,98,108,25,560,5665,5.61
"None","Total",4050,0,120,137,15,950,7780,22.43

perf-test_distribution.csv

"Name","# requests","50%","66%","75%","80%","90%","95%","98%","99%","100%"
"GET /",1011,99,120,140,150,190,230,310,460,630
"GET /login",20,26,49,59,74,83,100,100,100,100
"POST /login",20,330,370,400,420,490,510,510,510,510
"POST /logout",20,750,790,890,890,930,950,950,950,950
"GET /my/page",1967,140,170,210,230,290,340,390,440,600
"GET /projects",1012,98,120,140,150,190,220,250,290,560
"Total",4050,120,150,170,190,250,310,390,490,950

もっとシナリオっぽいものを書いてみる

先ほど作成したシナリオは、タスクの重み付けだけ決めたものの、特に実行順は指定していませんでした。

このあたりをちゃんと定義する場合は、TaskSequenceと@seq_taskデコレーターを使います。

TaskSequence class

seq_task decorator

seq-senario-locustfile.py

from locust import HttpLocust, TaskSequence, seq_task

import re

class UserBehavior(TaskSequence):
    @seq_task(1)
    def login(self):
        response = self.client.get("/login")

        csrf_param = re.search("<meta name=\"csrf-param\" content=\"([^\"]+)\" />", response.text).group(1)
        csrf_token = re.search("<meta name=\"csrf-token\" content=\"([^\"]+)\" />", response.text).group(1)

        self.client.post("/login", {"username": "admin", "password": "admin-password", csrf_param: csrf_token})
    
    @seq_task(99)
    def logout(self):
        response = self.client.get("/")

        csrf_param = re.search("<meta name=\"csrf-param\" content=\"([^\"]+)\" />", response.text).group(1)
        csrf_token = re.search("<meta name=\"csrf-token\" content=\"([^\"]+)\" />", response.text).group(1)

        self.client.post("/logout", {csrf_param: csrf_token})

    @seq_task(2)
    def top(self):
        self.client.get("/")

    @seq_task(3)
    def mypage(self):
        with self.client.get("/my/page", catch_response = True) as response:
            if response.status_code != 200:
                response.failure("not authenticated???")

    @seq_task(4)
    def projects(self):
        self.client.get("/projects")

class RedmineUser(HttpLocust):
    task_set = UserBehavior
    min_wait = 500
    max_wait = 1000

シナリオを定義するクラスの継承元がTaskSequenceとなり、

class UserBehavior(TaskSequence):

タスクの実行順は@seq_taskで指定します。

    @seq_task(2)
    def top(self):
        self.client.get("/")

    @seq_task(3)
    def mypage(self):
        with self.client.get("/my/page", catch_response = True) as response:
            if response.status_code != 200:
                response.failure("not authenticated???")

    @seq_task(4)
    def projects(self):
        self.client.get("/projects")

今回は、ログイン、ログアウトもタスクに組み込みました。

確認。短めに、30秒間実行。

$ locust --host=http://192.168.0.3:3000 -f seq-senario-locustfile.py --no-web -c 20 -r 2 -t 30s

今回は、ログイン、ログアウトはユーザーの数だけ実行、ではありませんね。

[2019-01-11 13:17:27,596] be01ed106c53/INFO/locust.main: Running teardowns...
 Name                                                          # reqs      # fails     Avg     Min     Max  |  Median   req/s
--------------------------------------------------------------------------------------------------------------------------------------------
 GET /                                                            191     0(0.00%)     143      24     420  |     130    7.80
 GET /login                                                       107     0(0.00%)      57      15     207  |      40    3.80
 POST /login                                                      105     0(0.00%)     467     226     806  |     460    3.80
 POST /logout                                                      90     0(0.00%)     274      85     561  |     260    3.70
 GET /my/page                                                      96     0(0.00%)     220      34     509  |     190    3.80
 GET /projects                                                     94     0(0.00%)     152      28     388  |     150    3.80
--------------------------------------------------------------------------------------------------------------------------------------------
 Total                                                            683     0(0.00%)                                      26.70

Percentage of the requests completed within given times
 Name                                                           # reqs    50%    66%    75%    80%    90%    95%    98%    99%   100%
--------------------------------------------------------------------------------------------------------------------------------------------
 GET /                                                             191    130    160    190    200    230    270    310    380    420
 GET /login                                                        107     40     56     80     86    120    140    180    190    210
 POST /login                                                       105    460    500    560    580    630    680    750    770    810
 POST /logout                                                       90    270    330    360    390    440    510    560    560    560
 GET /my/page                                                       96    190    250    300    320    390    440    480    510    510
 GET /projects                                                      94    150    170    170    190    240    280    360    390    390
--------------------------------------------------------------------------------------------------------------------------------------------
 Total                                                             683    160    230    290    350    450    530    620    670    810

分散実行してみよう

最後に、分散実行してみます。

Running Locust distributed — Locust 0.9.0 documentation

Locustを実行するノードは、とりあえず動かしてみるということで、Dockerで3つ用意。172.17.0.2〜4とします。

シナリオは、先ほどのタスクの実行順を指定したものを使って実行。

分散実行時には、master(ひとつ)とslaveとなるノード(2つ)を選びます。Web UIは、masterでのみ動きます。

masterでは、「--master」を指定して実行。masterは、172.17.0.2のものを使います。

$ locust --host=http://192.168.0.3:3000 -f seq-senario-locustfile.py --master

slaveでは、以下のコマンドを実行。2つのノードとも、実行します。

$ locust --host=http://192.168.0.3:3000 -f seq-senario-locustfile.py --slave --master-host 172.17.0.2

つまり、slaveにもシナリオは配っておく必要があります、と。

この状態でmasterのWeb UIを見ると、右上に「SLAVES」の表示が追加されています。

f:id:Kazuhira:20190111222910p:plain

で、実行。操作方法自体は、通常と同じです。

f:id:Kazuhira:20190111222939p:plain

今回は、「Number of users to simulate」を20に、「Hatch rate」に2を指定しました。

そして、slaveひとつあたり、以下のようなログが出力されます。

[2019-01-11 13:28:23,783] dce69b8cc910/INFO/locust.runners: Hatching and swarming 10 clients at the rate 1 clients/s...
[2019-01-11 13:28:33,793] dce69b8cc910/INFO/locust.runners: All locusts hatched: RedmineUser: 10

ユーザーの半分が割り当たっていますね。

ということは、テスト実行時に指定したユーザーはslaveに分配されて実行される(masterは負荷をかける作業はしない)という
ことですね。

Web UIにも「Slaves」タブが増えていて、認識しているslaveの確認と、テストの実行中であれば何ユーザーがそれぞれの
slaveに割り当てられているか、確認することができます。

f:id:Kazuhira:20190111223331p:plain

ちなみに、slaveはmasterを停止すると自動的に停止します。

最後に、この分散実行をWeb UIなしで行ってみましょう。

この場合、先にslaveを必要な数だけ起動しておきます。

$ locust --host=http://192.168.0.3:3000 -f seq-senario-locustfile.py --slave --master-host 172.17.0.2

その後、masterを起動します。「--master」を付ける以外は、スタンドアロンでWeb UIなしで実行していた場合と同じです。

$ locust --host=http://192.168.0.3:3000 -f seq-senario-locustfile.py --master --no-web -c 20 -r 2 -t 30s --csv perf-test-dist

この順番を逆にすると、masterはslaveがいない間は待機状態になりますが、ひとつでもslaveを認識するとすぐにテストを
始めてしまいます…。

まとめ

Pythonで書かれた負荷テストツール、Locustを使ってみました。

Pythonで簡単にシナリオを書け、わかりやすいUIがあり、分散実行も可能といろいろ便利です。

その反面、Apache JMeterほどの機能はなさそうだったり(レコーダーとかも…)します。

パラメーターとかログインで使うユーザーを、CSVとかにできないの?というのも調べてみたのですが、自分で書こう!
という感じみたいですね。

How to Run Locust with Different Users | BlazeMeter

まあ、コンセプト的にはそうでしょうねぇ、と。

カスタムクライアントの作り方も。

Testing other systems using custom clients — Locust 0.9.0 documentation

概ね、基本的な範囲で気になるところはざっと触れたのではないでしょうか。使えるところでは、活用していきたいですね。