これは、なにをしたくて書いたもの?
- Locustという負荷テストツールがあると聞き、テストシナリオをプログラムで書けるそうなので試してみようかと
Locust - A modern load testing framework
今回は、どんなツールか把握するところを目標に試してみます。
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
ちなみに、Locustは「イナゴ」を意味するんですね。ロゴを見ると、確かに…。
機能的には、いろいろ見ていくと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"
起動。
$ 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に関数を指定することで、もっと細かくコントロールすることも可能なようです。
続いて、テストの内容を定義したのが、こちらの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 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などに対応するメソッドを実行できます。
@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???")
今回は、マイページへのアクセスは、未ログイン状態だとログインページへリダイレクトしようとするので、ログイン
できていることを確認するために、ひと手間加えました。
実行してみる
ここまでできたところで、実行してみましょう。
作成した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」に
アクセスしてみます。
すると、図のようにWeb UIが現れ、「Number of users to simulate(ユーザー数)」と「Hatch rate(秒あたり何ユーザー増やしていくか)」を
求められるので、こちらを入力して「Start swarming」を押すと、テストが始まります。
今回は「Number of users to simulate」を20、「Hatch rate」を2にしてテスト開始。
テスト実行中、結果がリアルタイムで更新されていきます。
テストを終了させるには、右上にある「STOP」を押します。
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」を押すとグラフが見れたり
「Failures」を押すと、リクエストに失敗した場合に、その理由が見れたりします。
自分は、ログインに最初ずっと失敗していて、ここにずっとエラーが出ていました…。
あと、例外の情報を見たり、結果のダウンロードができたりもします。
次のテストを実行したり、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デコレーターを使います。
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」の表示が追加されています。
で、実行。操作方法自体は、通常と同じです。
今回は、「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に割り当てられているか、確認することができます。
ちなみに、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
概ね、基本的な範囲で気になるところはざっと触れたのではないでしょうか。使えるところでは、活用していきたいですね。