2016.04.04

負荷試験でgatlingを使ってみた

gatling top
はじめまして。昨年12月から次世代システム研究室に入ったM.Mです。
昨年12月からある開発案件に携わることになり、そして今月、その案件の負荷試験を実施方法の検討も含め担当することになりました。
その案件ではもともとJmeterを使用した負荷試験をしており、以前作ったテストシナリオを流用できるとの話も聞いたのですが、私が過去に少しだけ使ったことがあり興味を持ったgatlingを使いたいという話をして今回の負荷試験ではgatlingを使わせてもらいました。
エンジニアのやりたい気持ちを前向きに考えてくれる次世代システム研究室に入ってよかったと感じました!

前置きが長くなってしまいましたが、
今回は負荷試験ツールgatlingの紹介をしたいと思います。

目次

  1. インストールは難しいのか?インストール方法について
  2. テストシナリオはどのように作るのか?
  3. そして実行!
  4. 結果レポートってどのようなものが出力されるのか?
  5. 結果レポートのマージと負荷元スケールアウトについて
  6. いい方法が分からず力技でCookieを扱ってみた

1. インストールは難しいのか?インストール方法について

http://gatling.ioにアクセスし以下のようにヘッダー部分のRESOURCES->Downloadにアクセスします。
zipファイルがダウンロードできるようになっているので、ダウンロードして好きな場所に展開するだけです。(別途JDK7以上のインストールは必要です。)
gatling-download
※以下の説明はWindowsで実施。Cドライブ直下に展開したケースになります。尚、説明上展開したパスを${GATLING_HOME}としています。

2. テストシナリオはどのように作るのか?

gatlingのテストスクリプトはScalaで書かれています。でもScala未経験でも大丈夫です。
gatlingには負荷試験の実行だけでなくレコーダーツールが存在していて、実際に負荷試験を実施したい画面を操作するだけで自動でテストシナリオのスクリプトを作ってくれます。
それを後から必要に応じて修正していくだけです。

以下のような入力画面⇒確認画面⇒完了画面となる簡易ページを作ったのでそれで実際にテストシナリオを作ってみます。
gatling-testpage

◆レコーダーを使ってテストシナリオスクリプトを自動生成する

まずはレコーダーを開きます。
${GATLING_HOME}/bin配下にrecorder.batがあるので実行します。(今回の説明ではWindowsを利用しているので)
gatling-bin 実行すると以下の画面が表示されます。
gatling-recorder

◆プロキシの設定

上記画面の①の部分を見てください。
デフォルトではlocalhost HTTP/HTTPS 8000になっています。
今回はデフォルトのままブラウザのプロキシ設定をlocalhost 8000にします。
以下はIEの例です
ツール⇒インターネットオプション⇒接続タブからLANの設定を選び、プロキシ サーバーの設定を以下のようにします。
gatling-proxy
これで記録の準備が整いました。

◆記録開始

上記画面右下にある[start!]ボタンを押して記録開始!
以下のような画面が開きます。
gatling-recorder2 この状態で実際にテストしたいページを開き、作りたいテストシナリオ通りにアクセスしていくと、上記のExecuted Events欄に記載があるように実施したイベントが追記されていきます。
テストしたいページへのアクセスが終わったら、上記画面右上にある[Stop & Save]を押して終了します。

今回はデフォルトのまま記録したので、テストシナリオスクリプトは以下に作成されています。
${GATLING_HOME}/user-files/simulations/RecoderSimulation.scala

◆作られたソースの確認

import scala.concurrent.duration._
 
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import io.gatling.jdbc.Predef._
 
class RecordedSimulation extends Simulation {
 
  val httpProtocol = http
    .baseURL("http://testpage.local")
    .inferHtmlResources()
 
  val headers_0 = Map("Pragma" -> "no-cache")
 
  val uri1 = "http://testpage.local/test/top"
 
  val scn = scenario("RecordedSimulation")
    .exec(http("request_0")
      .get("/test/top")
      .headers(headers_0))
    .pause(9)
    .exec(http("request_1")
      .post("/test/top/confirm")
      .headers(headers_0)
      .formParam("_csrf", "SnlTM0tLbFktQGJKOiogNgI7AQsyLi8gfC0.Uj18L2kDFxt2DXs5Fw==")
      .formParam("name", "HogeHoge")
      .formParam("comment", "Hello!"))
    .pause(1)
    .exec(http("request_2")
      .post("/test/top/complete")
      .headers(headers_0)
      .formParam("_csrf", "cTVrUnV4LkwWDForBBliIzl3OWoMHW01R2EGMwNPbXw4WyMXM0h7Ag=="))
 
  setUp(scn.inject(atOnceUsers(1))).protocols(httpProtocol)
}
19行目: GETで/test/topにアクセス
23行目: POSTで/test/top/confirmにアクセス
25-27行目: パラメータ_csrf, name, commentを送信
30行目: POSTで/test/top/completeにアクセス
32行目: パラメータ_csrfを送信
という流れが分かります。
これを見ればGET, POSTでのリクエストの仕方、パラメータの送信の仕方が分かるかと思います。

_csrfはテストページに入力項目として存在していませんでしたが、 _csrfはCSRF対策としてhiddenとしてページが表示されるたびに異なる値が設定されるようになっており、次のページに遷移した際にパラメータで送られた_csrfの値が正しいかチェックされ、チェック後はその値は使えなくなるように実装されています。
(そのため、このテストスクリプトに記載のある_csrfの値自体はすでに使えない値になっています。)

どのようにすればいいのでしょうか?
また負荷を与える際はname, commentは固定値ではなく異なる値にしたい、
10qpsで10分間負荷を与えたいといった具合にこのスクリプトのままでは実際に負荷試験で使うことができません。

では、実際に以下の要件でテストスクリプトを修正します。
  1. _csrfの値を出力されたページから取得して、次ページへの遷移時にその値をパラメータとして使う
  2. name, commentの値はcsvファイルから取り込む
  3. 各ページ秒間5リクエストになるように3分間実行する

◆修正後

修正後は以下のようになりました。
(レコーダーで作られたRecoderSimulation.scalaをコピーしてファイル名をTestPageSimulation.scalaとして保存してクラス名をTestPageSimulationにしています。)
import scala.concurrent.duration._
 
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import io.gatling.jdbc.Predef._
 
class TestPageSimulation extends Simulation {
 
  val httpProtocol = http
    .baseURL("http://testpage.local")
    .inferHtmlResources()
 
  val headers_0 = Map("Pragma" -> "no-cache")
 
  val feeder = csv("inputdata.csv").circular
 
  val scn = scenario("TestPageSimulation")
    .exec(http("request_0")
      .get("/test/top")
      .headers(headers_0)
      .check(regex("""<input type="hidden" name="_csrf" value="([^"]+)">""").saveAs("_csrf")))
    .feed(feeder)
    .exec(http("request_1")
      .post("/test/top/confirm")
      .headers(headers_0)
      .formParam("_csrf", "${_csrf}")
      .formParam("name", "${name}")
      .formParam("comment", "${comment}")
      .check(regex("""<input type="hidden" name="_csrf" value="([^"]+)">""").saveAs("_csrf")))
    .exec(http("request_2")
      .post("/test/top/complete")
      .headers(headers_0)
      .formParam("_csrf", "${_csrf}"))
 
  setUp(scn.inject(constantUsersPerSec(5) during (180))).protocols(httpProtocol)
}
1. _csrfの値を取得して、次ページへの遷移時にその値をパラメータとして使う
21行目: 表示されたコンテンツから正規表現を使って_csrfの値を抜き取って_csrfというパラメータに保存しています。
26行目: 上記で保存した_csrfの値を送信する値として設定しています。

2. name, commentの値はcvsファイルから取り込む
15行目: inputdata.csvというファイルを使い、circular(上から順番に使って、使い終わったら再度上から順番に使っていく)と定義
(※csv以外のフォーマットやcircular以外にrandomとかもあります)
inputdata.csvは${GATLING_HOME}/user-files/data配下におきます。
内容は以下のようにします。
----------------------
name,comment           ・・・1行目はヘッダー行。スクリプトで使う変数名になります。
hogehoge,Hello!        ・・・2行目から実際のデータ
hogehuga,Hi!
...
hugahuga,Good Night.
----------------------
22行目: feedメソッドにて上記で定義したcsvファイルを使うようにしています。

3. 各ページ秒間5リクエストになるように3分間実行する
35行目: constantUsersPerSec(5)にて秒間5リクエスト、during (180)にて3分間実行すると定義
(※constantUsersPerSec以外に徐々に負荷を上げていくようなrampUsers, rampUsersPerSecなどもあります。)

3. そして実行!

${GATLING_HOME}/bin配下にgatling.batがあるので実行します。
gatling-bin gatling.batをダブルクリックすると以下のようにコマンドプロンプトが開きます。
gatling-exec どのテストを実行するか選ぶように入力を促されます。

はじめから存在しているcomputerdatabase.***と
今回レコーダーで作成したRecorderSimulation
そしてRecorderSimulationをコピーして作成したTestPageSimulationが存在しています。

[1]TestPageSimulationの1を入力してEnter
その後、他にも入力を促される項目がありますが、そのままで問題ないので何も入力しないでEnter
すると以下のようにテストが実行されていき、テストが完了すると最後に結果レポートの作成先が表示されます。
gatling-exec2

4. 結果レポートってどのようなものが出力されるのか?

以下は実際に実行して作成されたレポートになります。

◆以下全体の結果

Gatling-Stats-Global-Information

◆以下request_0だけの結果

Gatling-Stats-request_0
非常に分かりやすいレポートだと思います。
レスポンスタイムのグラフの下に秒間リクエスト数のグラフがあるため、比べながら見ることにより秒間リクエスト数の変化によってレスポンスタイムがどのように変わってくるのか分かりやすい。
秒間リクエスト数のグラフだけでなく、秒間のレスポンス数のグラフもあるので処理が詰まっているところが把握しやすい。
他にもパーセンタイルが出力されているので99%はレスポンスタイムを何秒以下にするといった負荷試験の評価項目にすることもできると思います。
また標準偏差も出力されているので、処理のばらつきを見つけて改善していくといったことにもつながるかと思います。

最近ではFluentd,Kibana,Elasticsearchでアクセス数などを把握できるところまですでに作りこんでいて、結果レポートを作らなくても把握できるというシステムもあるかとは思いますが、まだまだアプリのログを集計してExcelでグラフ作ってるよという方もいるのではないでしょうか?
私がgatlingを使ってみたいと思った一番の理由は結果レポートが気に入ったからです。

また上記レポートの上部にあるIndicatorsを見ると800ms, 1200msといった指標があります。
変更したいと思うこともあるかと思います。
実は負荷試験の出力結果にはsimulation.logというログファイルが存在しており、そのログファイルを利用することにより、負荷試験を再実行をしなくても設定ファイルの値を変更してgatling.batを実行することにより、レポートを再作成することができます。

例えば先ほどの結果レポートを修正したい場合
${GATLING_HOME}/conf/gatling.confの以下の場所を修正して
directory {
   #data = user-files/data
   #bodies = user-files/bodies
   #simulations = user-files/simulations
   reportsOnly = "testpagesimulation-1458629745315" # コメントをはずして変更したい結果レポートのパスを指定
   #binaries = ""
   #results = results
}

charting {
 #noReports = false
 #maxPlotPerSeries = 1000
 #accuracy = 10
 indicators {
   lowerBound = 100      # コメントをはずして好きな値に変更
   higherBound = 500    # コメントをはずして好きな値に変更
   #percentile1 = 50
   #percentile2 = 75
   #percentile3 = 95
   #percentile4 = 99
 }
}
後は負荷試験実施時と同様、gatling.batを実行すれば負荷試験は実施されずレポートだけ再作成されます。

5. 結果レポートのマージと負荷元スケールアウトについて

◆結果レポートのマージについて

たとえば、先ほどのテストスクリプトは各ページ秒間5リクエストを3分間実行しました。
次は秒間10リクエストで3分間、その次は秒間15リクエストで3分間といったように順次実行したとします。
実行のたびに結果レポートが増えていき、個別に見るのは非常に大変です。

先ほどレポートの設定値を変えたい場合などログファイルを利用してレポートを出しなおすことができると説明しましたが、実は複数の実行結果のログファイルを同じディレクトリに配置してレポートを出しなおすと、存在するログファイルをすべて読み取り、すべての実行結果がマージされた結果レポートが作成されます。

以下、実際に複数回実行した結果のログファイルを同じディレクトリに配置して出力したレポートです。
実施回数ごとに負荷(秒間リクエスト数)を徐々に上げていき10回目でレスポンスタイムが著しく悪くなっているのが分かります。
gatling-merge

◆負荷元スケールアウトについて

大規模の負荷試験であれば、gatlingを実行する負荷元サーバーが1台じゃ足りないということもあるでしょう。
先ほどの例では負荷をあげながら順次実行していきましたが、順次ではなく複数のサーバーで同時に実行すれば実現可能です。
最後にできたログをまとめてレポートを作れば複数台サーバーで同時に実行した結果がマージされ、最終的な結果レポートを見ることができます。

各サーバー毎の結果レポートは作らず、ログファイルだけを出力することができたり、上記の説明では問い合わせ形式で、実行するテストケースを選んで負荷試験を実施しましたが、問い合わせ形式ではなく以下のようにシナリオを指定して実行することができます。
実行例
 gatling.bat -s TestPageSimulation -nr
  -s で実行するテストシナリオを指定
  -nr でレポートを出さない指定
後は、特定時間に複数台の負荷元サーバーで同時に動くようにすればいいだけです。
ログファイルをファイル名変えて一箇所に配置した後にレポートを出力しないといけないけど、それぐらいはちゃちゃっとスクリプト作れば・・・。

6. いい方法が分からず力技でCookieを扱ってみた

最後にクッキーやセッションを扱ういい方法が見つからず無理やり力技で対応したサンプルのプログラムを共有します。
以下に紹介するスクリプトでなんとかうまく動いたので、私と同じようにいい方法が見つからずという方は参考にしてみてください。

やりたかった内容としては、ログイン済みのユーザー○○人が特定のページを○分間、秒間○○リクエストでアクセスするようにしたい。
repeatメソッドを使えば、1度だけログインした後に、特定のページに繰り返しアクセスというシナリオも作れはしますが、そのリピートが意図したqpsになるか分からない。リピートが終わった後またログイン処理とかさせたくない。
とはいえ、ログイン済みかどうかはセッション、Cookieを使って判断するアプリケーションのため、ログイン処理と特定のページを繰り返しアクセスする処理を分離するのが難しい。

そこで無理やり力技で実装したのが以下のサンプルです。
◆LoginToMakeCookieInfoFile.scala
・・・csvファイル(users.csv)にテストしたいユーザーの情報を記載、そのユーザーがログイン処理をしてログイン後のクッキー情報を別のcsvファイル(cookieinfo.csv)に保存
import scala.concurrent.duration._
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import io.gatling.jdbc.Predef._
import java.io.PrintWriter
import java.io.FileOutputStream
import java.io.OutputStreamWriter
import scala.io.Source
import io.gatling.http.cookie._
import java.net.URI
import com.ning.http.client.uri.Uri
import scala.util.matching.Regex
 
class LoginToMakeCookieInfoFile extends Simulation {
 
 var url = System.getProperty("url")
 if (url == null || url.length() == 0) {
  url = "・・・"
 }
 var host = ""
 var r : Regex = """[http|https]://(.+)""".r
 r.findAllIn(url).matchData.foreach(
   m =>
    host = m.group(1)
 )
 
 val httpProtocol = http
  .baseURL(url)
  .acceptHeader("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
  .acceptEncodingHeader("gzip, deflate")
  .acceptLanguageHeader("ja-JP")
  .userAgentHeader("Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko")
 
 val gatlingHome = System.getenv("GATLING_HOME")
 var src = Source.fromFile(gatlingHome + "/user-files/data/users.csv")
 val userCnt = src.getLines().length - 1
 src.close
 
 var file = new PrintWriter(new OutputStreamWriter(new FileOutputStream(gatlingHome + "/user-files/data/cookieinfo.csv")))
 file.println("phpsessid_key,phpsessid_val,・・・")
 file.close
 
 val headers_1 = Map(
  "Accept" -> "text/html, application/xhtml+xml, */*",
  "Pragma" -> "no-cache")
 
 val feeder = csv("users.csv").circular
 
 val login = scenario("Login to make phpsessid file(url: " + url + ", users: " + userCnt + ")")
  .exec(http("Login Page")
   .get("/auth/login")
   .headers(headers_1)
   .check(regex("""<input type="hidden" name="_csrf" value="([^"]+)">""").saveAs("_csrf"))
  )
  .pause(2)
  .feed(feeder)
  .exec(http("Login Execute")
   .post("/auth/login/execute")
   .headers(headers_1)
   .formParam("_csrf", "${_csrf}")
   .formParam("id", "${id}")
   .formParam("pass", "${pass}")
  )
  .exec(session => {
 
   session("gatling.http.cookies").validate[CookieJar].map {
    cookieJar =>
     var data = ""
 
     var r : Regex = "StoredCookie\\(([^=]+)=([^;]+);.+\\)".r
 
     val cookieStore = cookieJar.store.get(new CookieKey("phpsessid",host,"/")).get.toString()
     r.findAllIn(cookieStore).matchData.foreach(
       m =>
        data += m.group(1) + ","
         + m.group(2) + ","
     )
 
     ・・・省略・・・
 
     var file = new PrintWriter(new OutputStreamWriter(new FileOutputStream(gatlingHome + "/user-files/data/cookieinfo.csv", true)))
     file.println(data)
     file.close
   }
   session
  })
 
 setUp(
   login.inject(
     rampUsers(userCnt) over(30)
   ).protocols(httpProtocol)
 )
}
◆TopPageLoginSimulation.scala
・・・上記ログイン処理で作られたクッキー情報が保持されたcsvファイルを読み込みヘッダーにつけてログイン済みとみなしてTOPページにアクセス。
import scala.concurrent.duration._
 
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import io.gatling.jdbc.Predef._
 
class TopPageLoginSimulation extends Simulation {
 
 var url = System.getProperty("url")
 if (url == null || url.length() == 0) {
  url = "・・・"
 }
 var qps = Integer.getInteger("qps", 1).doubleValue()
 var during = Integer.getInteger("during", 10)
 
 val httpProtocol = http
  .baseURL(url)
  .acceptHeader("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
  .acceptEncodingHeader("gzip, deflate")
  .acceptLanguageHeader("ja-JP")
  .userAgentHeader("Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko")
 
 val headers_0 = Map("Accept" -> "text/html, application/xhtml+xml, */*")
 
 val feeder = csv("cookieinfo.csv").circular
 
 val scn = scenario("TopPageLogin(url: " + url + ", qps: " + qps + ", during: " + during + ")")
  .feed(feeder)
  .exec(addCookie(Cookie("${phpsessid_key}","${phpsessid_val}")))
  .exec(addCookie(Cookie("・・・","・・・")))
  .exec(http("Top Page")
   .get("/")
   .headers(headers_0)
  )
 
 setUp(
   scn.inject(
     constantUsersPerSec(qps) during(during)
   ).protocols(httpProtocol)
 )
}

まとめ

負荷試験の実行方法に関してはいろいろ工夫する必要があると感じましたが、はやりレポートに関しては結果をマージすることができたりと非常に良いと感じました。

次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。

皆さんのご応募をお待ちしています。

  • Twitter
  • Facebook
  • はてなブックマークに追加

グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。

 
  • AI研究開発室
  • 大阪研究開発グループ

関連記事