🦗

LocustでRailsアプリに負荷試験やる

2020/09/22に公開

唐突にやりたくなった。 Locust は Python スクリプトで記述する負荷試験ツール。 Locust には簡単なチュートリアルがある。

チュートリアルの中でログインしてるが、だいぶ昔から必須のCSRFの取り回しの記載が無い。チュートリアル後に、仕事で作ってるRailsアプリの負荷試験やってみようとすると、いきなり躓く。ログインできねえじゃねえかと。ちょっとフォローを交えた記事を書いてみる。

Locust入れる

Locust と lxml (CSRFトークン切り出しのため) を入れる。lxmlでなくともreでどうとでもなりそうな気がする。

pip3 install locust lxml
locust -V

この記事は Locust 1.2.3 で動作確認しています。

Redmineを立てる

rails new してたら連休が終わる。Rails製でログイン機構があり、docker compose でパッと立つものとしてRedmineを使うことにした。 https://hub.docker.com/_/redmine から拾ってきた redmine の yml で起動する。このとき Redmine のコンテナの環境変数に LOG_LEVEL=DEBUG を追加して、状態が見えるようにしておく。

# LOG_LEVEL: DEBUG を追加してる
version: '3.1'

services:

  redmine:
    image: redmine
    restart: always
    ports:
      - 8080:3000
    environment:
      REDMINE_DB_MYSQL: db
      REDMINE_DB_PASSWORD: example
      REDMINE_SECRET_KEY_BASE: supersecretkey
      LOG_LEVEL: DEBUG

  db:
    image: mysql:5.7
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: example
      MYSQL_DATABASE: redmine

立てる。ブラウザ操作がログで見えるようにしておく。

docker-compose up -d
docker-compose logs -f redmine

http://localhost:8080/login をブラウザで開き、 admin admin で初回ログインする。パスワードは hogehoge に設定する。ログアウトして、間違ったログイン情報でログイン失敗するときのログも観察しておこう。

locustfileを書く

Locustの負荷試験シナリオは locustfile に書く。 locust コマンドも指定がなければカレンドディレクトリの locustfile.py を探して実行する。 Locust が用意している HttpUser を継承したクラスを書く。クラス名は何でも構わない。

今回はログイン後の状態で負荷をかけまくりたいので on_start にログイン操作を書く。いきなり /login にPOSTしてもログインできない。RailsではCSRFのセキュリティトークンは /login を GET したときのHTMLに埋め込まれているので、正規表現なりXPathなりで抜き出してくる。

あとはプラプラ巡回するコードを書く。

import time
from locust import HttpUser, task, between
from lxml import html

class QuickstartUser(HttpUser):
    wait_time = between(1, 2)

    def on_start(self):
        response = self.client.get("/login")
        tree = html.fromstring(response.text)
        auth_token = tree.xpath('//form/input[@name="authenticity_token"]/@value')[0]
        self.client.post(
            "/login",
            json={
                "username": "admin",
                "password": "hogehoge",
                "authenticity_token": auth_token
            }
        )

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

    @task(3)
    def admin_page(self):
        self.client.get("/admin")

    @task(3)
    def any_page(self):
        self.client.get("/projects")
        self.client.get("/my/page")

Locust で Redmine にログインできているか確認する

これ端折ると、ログインできてない状態で空振りな負荷をかけてることが、後になって発覚したりする。

並列数(--user)を1、実行時間(--run-time)を短くして、 docker-compose で流れてるログを眺める。ブラウザ操作でログインできたとき、失敗したときのログとも見比べて、ログインできてるか確認する。

locust -f ./locustfile.py --headless --host http://localhost:8080 --user 1 --run-time 3s

self.client.post() で渡すべきJSONの構造はウェブアプリ毎に千差万別のため、都度で調べる必要があるだろう。

Locust でフルボッコにする

きちんとログインできることを確認できたら、並列数と実行時間を増やして、負荷試験にとりかかる。

locust -f ./locustfile.py --headless --host http://localhost:8080 --user 10 --run-time 300s

実行中も進捗が表示される。 --run-time が経過すると終了し、レスポンスタイムの成績を集計したレポートが出る。

[2020-09-22 01:17:45,143] sasasin/INFO/locust.main: Cleaning up runner...
 Name                                                          # reqs      # fails  |     Avg     Min     Max  Median  |   req/s failures/s
--------------------------------------------------------------------------------------------------------------------------------------------
 GET /                                                              2     0(0.00%)  |      21      16      27      16  |    0.07    0.00
 GET /admin                                                        12     0(0.00%)  |      19      14      21      20  |    0.40    0.00
 GET /login                                                         1     0(0.00%)  |      15      15      15      15  |    0.03    0.00
 POST /login                                                        1     0(0.00%)  |      35      35      35      35  |    0.03    0.00
 GET /my/page                                                       6     0(0.00%)  |      27      23      36      26  |    0.20    0.00
 GET /projects                                                      6     0(0.00%)  |      23      21      27      23  |    0.20    0.00
--------------------------------------------------------------------------------------------------------------------------------------------
 Aggregated                                                        28     0(0.00%)  |      22      14      36      22  |    0.94    0.00

Response time percentiles (approximated)
 Type     Name                                                              50%    66%    75%    80%    90%    95%    98%    99%  99.9% 99.99%   100% # reqs
--------|------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
 GET      /                                                                  27     27     27     27     27     27     27     27     27     27     27      2
 GET      /admin                                                             21     21     21     21     22     22     22     22     22     22     22     12
 GET      /login                                                             15     15     15     15     15     15     15     15     15     15     15      1
 POST     /login                                                             35     35     35     35     35     35     35     35     35     35     35      1
 GET      /my/page                                                           26     26     27     27     37     37     37     37     37     37     37      6
 GET      /projects                                                          24     24     25     25     27     27     27     27     27     27     27      6
--------|------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
 None     Aggregated                                                         22     24     26     26     27     35     37     37     37     37     37     28

devise で認証機構を組んでいる場合

HTTP POSTしてる内容が若干異なるため、self.client.post() のJSONの作りも違う。たとえば devise のサンプルアプリ https://github.com/imhta/rails_6_devise_example を立てて、sign upして、sign inしてみる。そのときのChromeデベロッパーコンソールで見えたPOST情報と、railsのログから、LocustからPOSTすべきJSONの構造は、これが正解。

self.client.post(
    "/users/sign_in",
    json={
        "user": {
            "email": "admin",
            "password": "hogehoge",
        },
        "authenticity_token": auth_token
    }
)

他のgemで認証機構を組んだ場合も、微妙に異なっていると思われる。

参考

Discussion