Liberent-Dev’s blog

株式会社リベル・エンタテインメントのテックブログです。

負荷試験のためにLocust試してみた

みなさま、初めまして。
システム開発部のK.Mです。

今回はサーバアプリケーションに対する負荷試験をする時に使用するツールの1つであるLocustを使用手順や使用時に発生した諸々をご紹介していきます。

なぜLocust?

負荷試験で使用するツールとして真っ先に名前が出てくるのは、Apache JMeterになります。

しかし、このJMeterですが、

  • テスト用のシナリオを作る時にUI上でポチポチ選択することで時間が思ったより取られる。
    • GUIで多機能だが、どの項目が何を表すのかが、分かりにくい点がある。
  • テスト用のシナリオがXMLでも用意出来るが、こちらも分かりにくいXMLを読み解いて書いたりするのに時間が取られる。
  • リクエストやレスポンス部分の暗号化とかセキュリティ用の機能があると、その機能をOFFにしないとテストがやりにくい。
    • リクエストとレスポンスを独自に暗号化している案件で、JMeterに暗号化・複合化を入れ込む方法が無く、一時的に暗号化OFFにして試験をしたこともありました。

等々、色々と使う上での難点がありました。

JMeterで難点になっている部分を解消するために、テスト用シナリオをプログラムで書けるツールを探しているところ、pythonで書けるLocustを発見したので使ってみることにしました。

Locustを使う以前は、k6というjsベースでシナリオを書けるツールやvegetaというgoベースでシナリオを書けるツールも使ってみましたが、それぞれ一長一短ありますが現時点ではLocustが上記に挙げたJMeterの難点が無く、使いやすいと個人的には感じています。

負荷試験とは?

負荷試験とは、何?という方向けに簡単な説明から入ります。

Webアプリケーションやアプリ用のAPIサーバは、WebサーバやDBサーバなどの様々なシステムやサーバと連携して動いているので、リリース時に想定しているアクセス(または想定している以上のアクセス)があっても正常に動くことを確認するために行う試験のこととなります。
(どこまで耐えられるかという観点のテストも存在しています。)

図1

一例で上記のような構成があった場合に、どこがネックになってしまうのかを判断する必要があり1つ1つのサーバ単体で確認するのではなく、連携して確認して問題ないかの有無を判断します。

リリース前のサーバに対して負荷試験は必ず2回以上は実施しておく必要があると自分は考えております。

  • 1回目・・・問題点を洗い出し、修正。
  • 2回目・・・1回目で修正した部分が問題無いか及び修正したことで全体に問題無いかを確認。

2回目に問題があれば3回目、4回目と繰り返すことになりますが、スケジュールの問題で回数が行えないことが多いことがあるので、リリース前のギリギリに負荷試験を実施するのではなく、スケジュールを考えて事前に行ったり、スケジューリングをすることが重要です。

Locustインストール手順

注意点:2021年8月3日でのLocustの最新版はv1.6.0になっておりますが、今回記載の手順はv1.3.1ベースとなります。最後に少しだけ存在するv1.3.1とv1.6.0での差分を記載しております。

  • 動作確認環境

macであれば、下記の公式サイト記載の手順でインストールが可能です。
https://docs.locust.io/en/stable/installation.html

  1. python3.6以上をインストール
  2. pip3 install locustコマンドでlocustをインストール
  3. locust -Vで下記のようなバージョン情報が表示されればインストール完了
$ locust -V
locust 1.3.1

windowsですが、ptyhon3.6以上をインストールしておりpipコマンドが使える状態になっていればインストール可能かと考えられますが、自分の環境では試せておりません。

Locust実行手順

こちらも公式サイト記載の手順でLocustを動かすことが可能です。 https://docs.locust.io/en/stable/quickstart.html

  1. クイックスタートページに記載されている実行例のpythonコードをコピーしてファイルを作成します。ファイル名は任意の名前でOK。今回はlocustTest.pyとしております。
  2. locust -f locustTest.pyとコマンドを打って、locustを起動させます。
    作成したpythonファイルが別ディレクトリにある場合は、フルパスで指定すればOK。
  3. 問題なく起動したら下記のようなログが表示がされます。
$ locust -f locustTest.py  
[2020-10-23 16:21:05,401] MacBook-Pro.local/INFO/locust.main: Starting web interface at http://0.0.0.0:8089 (accepting connections from all network interfaces)  
[2020-10-23 16:21:05,413] MacBook-Pro.local/INFO/locust.main: Starting Locust 1.3.1

正常に起動することを確認後、ブラウザを開いて下記の手順を行います。

  1. ブラウザでhttp://localhost:8089へアクセスします。アクセスすると下記のような画面が表示されますので、各種値を入れてStart swarmingボタンを押す。
    ・Number of total users to simulate:負荷試験として使用するユーザー数
    ・Spawn rate:ユーザーを追加していく時間の間隔(単位は秒)
    ・Host:アクセスするURL 図2

  2. 下記のような画面に遷移しコードに書いたテストシナリオが動き出します。今回作成しているクイックスタート通りのシナリオだと永遠に動き続けるため、適当なところで右上のSTOPボタンを押すことで、テストが停止します。 図3

クイックスタートのシナリオが適当なURLへのアクセスになっているので全てエラーになりますが、Webブラウザにて結果などが視覚的に見えるのは使い勝手が良いです。

また、「Charts」などのリンクを押すと、結果をグラフで見ることが出来ます。 図4 図5 図6

下記のようなレポートもDL可能です。 図7

また、実行してたlocustコマンドを終了した時にも同じような結果が表示されます。

[2020-10-23 17:28:33,904] MacBook-Pro.local/INFO/locust.main: Running teardowns...
[2020-10-23 17:28:33,905] MacBook-Pro.local/INFO/locust.main: Shutting down (exit code 1), bye.
[2020-10-23 17:28:33,905] MacBook-Pro.local/INFO/locust.main: Cleaning up runner...
 Name                                                          # reqs      # fails  |     Avg     Min     Max  Median  |   req/s failures/s
--------------------------------------------------------------------------------------------------------------------------------------------
 GET /hello                                                         3   3(100.00%)  |      20       2      53       3  |    0.11    0.11
 GET /item                                                        114 114(100.00%)  |       7       3      82       6  |    4.26    4.26
 POST /login                                                        5   5(100.00%)  |       7       5       8       7  |    0.19    0.19
 GET /world                                                         3   3(100.00%)  |       3       2       6       3  |    0.11    0.11
--------------------------------------------------------------------------------------------------------------------------------------------
 Aggregated                                                       125 125(100.00%)  |       8       2      82       6  |    4.67    4.67

Response time percentiles (approximated)
 Type     Name                                                              50%    66%    75%    80%    90%    95%    98%    99%  99.9% 99.99%   100% # reqs
--------|------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
 GET      /hello                                                              3      3     54     54     54     54     54     54     54     54     54      3
 GET      /item                                                               6      6      7      7     10     24     33     53     82     82     82    114
 POST     /login                                                              7      8      8      9      9      9      9      9      9      9      9      5
 GET      /world                                                              3      3      6      6      6      6      6      6      6      6      6      3
--------|------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
 None     Aggregated                                                          6      6      7      7     10     24     53     54     82     82     82    125

Error report
 # occurrences      Error                                                                                               
--------------------------------------------------------------------------------------------------------------------------------------------
 5                  POST /login: "HTTPError('404 Client Error: Not Found for url: http://localhost:8080/login')"        
 3                  GET /hello: "HTTPError('404 Client Error: Not Found for url: http://localhost:8080/hello')"         
 3                  GET /world: "HTTPError('404 Client Error: Not Found for url: http://localhost:8080/world')"         
 114                GET /item: "HTTPError('404 Client Error: Not Found for url: /item')"                                
--------------------------------------------------------------------------------------------------------------------------------------------

Locustのシナリオ書き方例

一例としてサーバとのセッション確立するAPIを呼んでから、ユーザー新規作成のAPIを呼ぶサンプルコード例と下記します。

import time, hashlib, logging, base64, json
from locust import HttpUser, task, TaskSet

Secret = ""

# ヘッダー作成用の処理
def headerCreate(path, id, body):
    if id == "":
        headers = { 'Content-Type': 'application/json',
                    'Accept': 'application/json',
                    'Accept-Encoding': '' }
    else:
        headers = { 'Content-Type': 'application/json',
                    'Accept': 'application/json',
                    'Accept-Encoding': '',
                    'X-Id': id }

    return headers

# セッション作成用
def startSession(self, post):
    path = "/startSession"
    res = self.client.post(path, post, headers=headerCreate(path, self.id, post))

    return res 

# ユーザー作成用
def userCreate(self, post):
    path = "/userCreate"
    res = self.client.post(path, post, headers=headerCreate(path, self.id, post))

    return res

# ホーム画面用
def home(self):
    path = "/home"
    post = "{}"
    res = self.client.post(path, post, headers=headerCreate(path, self.id, post))

    return res

# シナリオ用のクラス
class UserBehavior(TaskSet):
    tasks = {home: 1}

    def on_start(self):
        self.id = ""
        appVersion = "0.0.1"

        # 1回目の/startSession(新規ユーザー用のIDとセッションIDを渡す)
        res = startSession(self, json.dumps({'appVersion':appVersion, 'os': 1}))
        logging.info(res.json())
        resJson = res.json()
        self.id = resJson['id']
        self.sessionID = resJson['sessionId']


        # 2回目の/startSession(id設定して、サーバ側にセッション保持してもらう
        res = startSession(self, json.dumps({'appVersion':appVersion, 'id':self.id,'os': 1}))
        logging.info(res.json())

        # サーバ側セッション保持したので、ユーザー作成
        res = userCreate(self, json.dumps({'name':'testname', 'family_name': 'myouji', 'given_name':'namae'}))
        logging.info(res.json())

# 最初に呼ばれるクラス
class User(HttpUser):
    tasks = {UserBehavior: 1}
    min_wait = 2000
    max_wait = 5000
  • self.client.postがLocustにてHTTPでPOSTを実施する処理となります。
  • HttpUserを引数にしているクラスから呼ばれる形となります。

    • tasksに呼び出すクラスと優先度をリスト型で設定すると、優先度が高いものを優先して動作するようになります。
      • 例えば、tasks = {A: 15, B: 1} みたいな書き方をするとAが15倍、Bより実施される
    • min_waitがテストを実施する待ち時間の最小、max_waitが最大(ms指定)上記のサンプルだと2〜5秒間隔で各種テストが実施されることになる。)
  • 今回のサンプルだとUserBehaviorが呼ばれるクラスになるので、そのクラスの引数にTaskSetを設定しておく

    • UserBehaviorにもtasksを設定しておく
    • UserBehaviorクラスのon_startが最初に呼ばれるのでon_start内に最初に動く処理を記載する。
    • tasksに定義したものはon_startが動いた後に一定間隔で呼び出される(今回の場合だとhomeにアクセスするAPIがユーザー作成が終わった後に2〜5秒間隔で呼ばれる)

Dokerを使ったMaster-Slave設定による沢山の負荷をかける手順

Dokerfileを作っておき、GKEやEKSなどのKubernetes環境を使ってWebUI用のマスターサーバ1台に、負荷実施を行うワーカーサーバのインスタンスを指定台数分起動して負荷を大量にかけれる環境が作成可能となっています。
(1台のPCでサーバへの負荷をかけるアクセスを行うのには限界があるので、負荷をかけるPCを複数台用意してアクセスする形となります。)

今回の手順では、Googleが用意してくれているDokerfileをベースにGKE環境で動かすまでの流れとなります。

ローカルでdocker動かしてみる

一先ず確認のために、ローカル環境にてdockerを動かしてみます。 (dockerはインストール済み想定とします)

  • docker-compose.yamlを準備します
    • locustのdockerイメージは公式サイトで用意されているのでこちらを利用します。こちら
    • 下記サンプルとなります。
version: "3.4"

x-common: &common
  image: locustio/locust
  volumes:
    - ./tests/:/tests

services:
  locust-master:
    <<: *common
    ports:
      - 8089:8089
    command: -f /tests/locustTest.py --master -H http://host.docker.internal-:8080

  locust-slave:
    <<: *common
    command: -f /tests/locustTest.py --worker --master-host locust-master
  • docker-compose.yamlと同じ場所にtestsディレクトリ作っておき、そこにシナリオファイル(locustTest.py)を入れます。
  • docker-compose up -dでdocker起動します。
  • docker-compose up -d --scale locust-slave=3とするとmaster1台、slave3台でdocker起動させることが可能となります。 図8
  • 注意点
    • start時のhost指定をhttp://localhost:8080としていると、docker側のlocalhost:8080へのアクセスになってしまうので、自PCのローカルで動かしているサーバにアクセスさせるためにはローカルのIPアドレスhttp://192.168.XXX.XXX:8080など)を設定するか、新しいバージョンのdockerであればhttp://host.docker.internal:8080で自PCのlocalhostへアクセスが可能となります。

GKE環境への構築と実施

Googleが用意してくれている情報があるのですが、内容がLocustのバージョン1.0.0未満のものになっており、1.0.0以降のバージョンでは修正必要な場所が何箇所かあったため、実際に実施した手順を記しておきます。

  • GKEのクラスタ作成コマンド 事前にgcloudコマンドでデフォルト設定になっている対象プロジェクトは確認しておくこと。今回は例としてload-test-environmentというプロジェクトを用意して対象プロジェクトとして手順記載しております。
$ gcloud container clusters create gke-load-test \
       --zone asia-northeast1-a \
       --scopes "https://www.googleapis.com/auth/cloud-platform" \
       --enable-autoscaling --min-nodes=2 --max-nodes=5 \
       --scopes=logging-write,storage-ro \
       --addons HorizontalPodAutoscaling,HttpLoadBalancing \
       --preemptible
  • --preemptible指定をして、プリエンプティブノードを有効にして、費用を抑える形としています。
  • コマンドの結果、GCPのGKEコンソールにて下記のような形でクラスタが作成されます。デフォルトのままだとブートデスクが100GBだったので不要であれば--disk-sizeで調整が可能となります。

図9 図10 図11

$ gcloud container clusters get-credentials gke-load-test \
      --zone asia-northeast1-a \
      --project load-test-environment
Fetching cluster endpoint and auth data.
kubeconfig entry generated for gke-load-test.
  • このコマンドを実施することで、kubectlコマンドでGKEに繋がるようになります。

  • ここにあるGoogleが用意している各種ファイルを取得します。git cloneをしてもいいのですが、不要なファイルとかがあって消したかったので今回はzipでDL→展開、下記記載の修正をかけたものを使用しています。

    • sample-webappディレクトリは今回不要なので削除する。(サンプルとして用意されたWebAPI用のソース)
    • 展開後の直下にあるDockerfile、flow.groovy、LICENSE、README.mdは不要なので削除
    • docker-imageディレクトリのlicensesディレクトリは不要なので削除(ライセンス情報入っているので念のため読んでおきましょう。)
    • 残ったファイルを使用するのですがディレクトリ構成上、一つ深い場所になってしまっているのでdocker-imageとkubernetes-configに入っているファイルを一つ上のディレクトリに移動し、docker-imageとkubernetes-configのディレクトリ自体は削除します。
    • Dokerfileの中身を修正。
# ADD licenses /licenses
  • RUN pip install -r /locust-tasks/requirements.txt前にRUN pip install --upgrade pipを追加
  • locust-tasks/tasks.pyの中身がテストシナリオのコードになるので、事前に作成したものに書き換えておく
  • locust-tasks/requirements.txtを修正
    • locustioを削除する
    • msgpack-pythonを削除する
    • locust==1.3.1を追加する
    • gevent==20.9.0に変更する
    • msgpack==1.0.0に変更する
    • greenlet==0.4.17に変更する
    • Flask==1.1.2に変更する
    • Werkzeug==1.0.1に変更する
  • locust-tasks/run.sh内部の--slave--workerに変更する
  • 最終的なディレクトリとファイル構成は下記のようになります
gke-docker/
  ├─ locust-tasks/
  |   ├─ requirements.txt
  |   ├─ run.sh
  |   └─ tasks.py
  ├─ Dockerfile
  ├─ locust-master-controller.yaml
  ├─ locust-master-service.yaml
  └─ locust-worker-controller.yaml
  • dockerイメージの作成
    • 修正したDockerfileがあるディレクトリで下記のコマンド実行する
$ gcloud builds submit --tag gcr.io/load-test-environment/locust-tasks:latest .
  • 問題なければ、下記のように成功ログが出る
ID                                    CREATE_TIME                DURATION  SOURCE                                                                                       IMAGES                                           STATUS
51c7f5f2-8f79-42bd-891f-8d9e58d1bf19  2020-11-10T06:56:47+00:00  54S       gs://load-test-environment_cloudbuild/source/1604991406.41-f50507523a4742d6a362f8ee052644bd.tgz  gcr.io/load-test-environment/locust-tasks (+1 more)  SUCCESS
  • 正常に作成されているか確認
$ gcloud container images list | grep locust-tasks
gcr.io/load-test-environment/locust-tasks
Only listing images in gcr.io/load-test-environment. Use --repository to list images in other repositories.
  • Locustのマスター、スレーブのデプロイ

    • 各種yamlを環境に合わせて書き換える。
      • locust-worker-controller.yamlとlocust-master-controller.yamlに[TARGET_HOST]と[PROJECT_ID]という仮の文字が入っているので適切なものに変更
        • 今回だと[PROJECT_ID]はload-test-environment、[TARGET_HOST]はアクセスするURLに変更
        • locust-worker-controller.yamlのreplicasが5になっているので3にしておく
  • デプロイ時のコマンド

kubectl apply -f locust-master-controller.yaml  
kubectl apply -f locust-master-service.yaml  
kubectl apply -f locust-worker-controller.yaml  
  • Locustのデプロイ確認
$ kubectl get pods -o wide
NAME                             READY   STATUS              RESTARTS   AGE   IP       NODE                                           NOMINATED NODE   READINESS GATES
locust-master-6594b6b4c-dgw9c    0/1     ContainerCreating   0          33s   <none>   gke-gke-load-test-default-pool-f28047d7-jr1d   <none>           <none>
locust-worker-5bc77dc5b6-q5dnc   0/1     ContainerCreating   0          20s   <none>   gke-gke-load-test-default-pool-f28047d7-jr1d   <none>           <none>
locust-worker-5bc77dc5b6-tvb2f   0/1     ContainerCreating   0          20s   <none>   gke-gke-load-test-default-pool-f28047d7-cc8c   <none>           <none>
locust-worker-5bc77dc5b6-z9cfg   0/1     ContainerCreating   0          20s   <none>   gke-gke-load-test-default-pool-f28047d7-zmfn   <none>           <none>
  • サービスの確認(IPはセキュリティの都合上一部マスクしてます。)
$ kubectl get services
NAME            TYPE           CLUSTER-IP     EXTERNAL-IP     PORT(S)                                        AGE
kubernetes      ClusterIP      10.XX.YY.1    <none>          443/TCP                                        120m
locust-master   LoadBalancer   10.XX.YY.12   34.XX.YY.ZZ   8089:32725/TCP,5557:32211/TCP,5558:31841/TCP   116s
  • 今回の場合だと、EXTERNAL-IPに設定されている、34.XX.YY.ZZ:8089にアクセスするとLocustの初期画面が表示されるので、テスト実施が可能になる。 (EXTERNAL-IPが未設定(<pending>)の場合があるので、その場合は少し時間をおくと設定される。)

  • テスト実施して不要になったらGKEクラスタを削除しておきましょう。1日以上未使用の状態が続くのであれば費用面を考えて削除しておいた方が良いでしょう。

gcloud container clusters delete gke-load-test --zone asia-northeast1-a

Locustのアップデート

執筆当時にインストールしたものがv1.3.1だったため、最新リリースのv1.6.0へ更新する場合は、pip3でのアップデート方法でOKです。
pip3 install -U locust

$ pip3 install -U locust
〜ログ省略〜
Successfully installed Flask-Cors-3.0.10 locust-1.6.0

$ locust -V
locust 1.6.0

GKE部分の差分

  • locust-tasks/requirements.txtを修正
    • locust=1.3.1からlocust==1.6.0に変更する

まとめ

個人的に、下記の点から新規でJMeterを使うならLocustを使った方が良いかと感じました。

  • シナリオファイルがptyhonで作成可能な点
  • 一度、環境構築の仕方が分かってしまえばGKEなどのKubernate環境にてLocustを使った負荷をかけるのは簡単かつ低費用(サーバ使用料は負荷をどれだけかけるか次第になるが、大した金額にはならない)で可能な点
    • しかも構築方法はGoogleが用意してくれているのでGKEだとそこまで苦労せずに環境構築が可能になっている。

ただ一点確認出来てなくて気になっている部分があり、負荷を増加するためにスレーブ(ワーカー)からのアクセス数とかサーバ数を増やすと、GKEのネットワーク周りで何か問題出そうな気がしなくもないので、事前に負荷試験自体の試験はしておいた方が良いかもと感じています。

既にメジャーバージョンアップされた2.0.0が出ているので、これも触ってみて導入手順やシナリオ周りに変更点ありそうであれば、こちらで紹介したいとおもいます。

最後に

リベル・エンタテインメントでは、このような最新技術などの取り組みに興味のある方を募集しています。もしご興味を持たれましたら下記サイトにアクセスしてみてください。
https://liberent.co.jp/recruit/