Scala
idea
IntelliJ
負荷テスト
Gatling

負荷テストツールGatlingをIntelliJ IDEAから使う

背景

10年以上前にJMeterでクラスタリングして秒間何千件まで捌けるか計測したり正常系の動作確認テストの自動化したりしてたけど、ほぼ忘れてしまった。触ったらすぐ思い出せると思うけど設定ファイルがxmlになるので手動で修正する必要があるときつらいし、Gatlingの噂は昔から聞いていたのでこれを機に試してみることにした。

目標

  • せっかくScalaで記述できるんだからがっつりIDEから操作したい。
  • JMeterでやってたように使いこなしたい(クラスタリングについては後述)

前段

  • build.sbtにdependencies記述したけどIDEから実行できるようにならなくていろいろ検索したところ、maven archetypeがあることが判明
  • xmlから逃れるためにJMeterから移行してくるのにmavenか、、

公式のmaven archetypeに関するドキュメント: https://gatling.io/docs/2.3/extensions/maven_archetype/

セットアップ手順

  • Java8, IntelliJのインストールは省略。Java9では自作Simulationクラスのコンパイルに失敗するので注意(gatling.shでコンパイル時は2> /dev/nullにリダイレクトしてるのでエラーメッセージが表示されないの不親切)。
  • New -> Project... -> Maven -> Create From Archtype -> Add Archyype
name value
group id io.gatling.highcharts
artifact id gatling-highcharts-maven-archetype
version 2.2.0

※ 2017/12時点でmaven centralにある最新は2.3.0だけどこれだと動かなかった。こういう罠やめてほしい。

自分のプロジェクト情報を入れてひたすら準備完了を待つ。途中でmavenにgroup idを聞かれる。ここで入力した値がRecorderで操作を記録した時のパッケージ名に使われる。

シナリオ作成

ブラウザでの操作を記録

  • src/scala配下にできたRecorderオブジェクトを右クリック -> 実行
  • Gatling Recorderのダイアログがあがるので'Start'をクリック
  • ブラウザを立ち上げてlocalhost:8000をproxyに指定。なおChromeでProxy SwitchyOmegaプラグインを使用
  • 負荷テスト対象サイトにアクセス
  • Gatling Recorderで'Stop'をクリック

カスタマイズ

src/scala配下にRecordedSimulationクラスが作成される。内容は以下のような感じ

RecordedSimulation
class RecordedSimulation extends Simulation {

    val httpProtocol = http
        .baseURL("http://test.example.com")
        .inferHtmlResources()
        .acceptHeader("image/webp,image/apng,image/*,*/*;q=0.8")
        .acceptEncodingHeader("gzip, deflate")
        .acceptLanguageHeader("ja,en-US;q=0.9,en;q=0.8,pt;q=0.7,zh-CN;q=0.6,zh;q=0.5")
        .userAgentHeader("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.84 Safari/537.36")

    val headers_0 = Map(
        "Accept" -> "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
        "Upgrade-Insecure-Requests" -> "1")

    val headers_1 = Map(
        "Accept" -> "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
        "Authorization" -> "Basic ${encoded_authorization}",
        "Upgrade-Insecure-Requests" -> "1")

    val headers_2 = Map(
        "Authorization" -> "Basic ${encoded_authorization}",
        "Pragma" -> "no-cache")

    val headers_3 = Map(
        "Accept" -> "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
        "Authorization" -> "Basic ${encoded_authorization}",
        "Pragma" -> "no-cache",
        "Upgrade-Insecure-Requests" -> "1")

    val headers_4 = Map(
        "Accept" -> "*/*",
        "Authorization" -> "Basic ${encoded_authorization}",
        "Origin" -> "http://example.com",
        "Pragma" -> "no-cache")

  val top = exec(http("top")).get("/")
    val scn = scenario("RecordedSimulation")
        .exec(http("request_0")
            .get("/")
            .headers(headers_0)
            .check(status.is(401)))
        .pause(10)
        .exec(http("request_1")
            .get("/")
            .headers(headers_1)
            .basicAuth("${user}","${pass}")
            .resources(http("request_2")
            .get(uri1 + "/favicon/favicon.ico")
            .headers(headers_2)
            .basicAuth("${user}","${pass}"))
            .check(status.is(304)))
        .pause(6)
        .exec(http("request_3")
            .get("/")
            .headers(headers_3)
            .basicAuth("${user}","${pass}")
            .resources(http("request_4")
            .get(uri1 + "/fonts/AvenirSimple-Regular.woff")
            .headers(headers_4)
            .basicAuth("${user}","${pass}"),
            http("request_5")
            .get(uri1 + "/images/btnmulticolorbg.jpg")
            .headers(headers_2)
            .basicAuth("${user}","${pass}"),
            http("request_6")
            .get(uri1 + "/SVG/HLMark.svg")
            .headers(headers_2)
            .basicAuth("${user}","${pass}"),
            http("request_7")
            .get(uri1 + "/news/20171012_01/img.png")
            .headers(headers_2)
            .basicAuth("${user}","${pass}"),
            http("request_8")
            .get(uri1 + "/news/20171208_01/img.png")
            .headers(headers_2)
            .basicAuth("${user}","${pass}"),
            http("request_9")
            .get(uri1 + "/SVG/tobbg.svg")
            .headers(headers_2)


このままだと大変見づらいので整理する。

画像やcssなどのリソースファイル

Recorderで読み込むとリソースファイルを取得する処理が記述されるが、Gatlingはデフォルトでリソースファイルをとりに行くのでこの記述は不要。HttpProtocol#silentResoucesでこの機能をオフにすることもできる。

Acceptヘッダ

Chromeがどういう基準でAcceptを分けてるのかわからないけど、一種類に統一してHttpProtocolで指定すれば各リクエストでセットする必要はない。

exec()

execメソッドは単にActionBuilderをchainしているだけっぽい。

Execs.scala
trait Execs[B] {
  def exec(chains: ChainBuilder*): B = exec(chains.toIterable)
  private[core] def chain(newActionBuilders: Seq[ActionBuilder]): B = newInstance(newActionBuilders.toList ::: actionBuilders)
  ...

なのでexec()の親子関係は無視して大丈夫

BASIC認証

BASIC認証がかかっている場合、ブラウザは単に"$user:$pass"をBase64EncodeしてHeaderで送るだけなので、特にインタラクティブなやり取りは必要ない。HttpProtocolに.basicAuth()しておけば以後明示する必要はない。

負荷の調整

atOnceUsersで同時接続人数をセットすることもできるが、一気に負荷が増えるとやりすぎたときにクライアントが重すぎて止めれなくなったりしそうなのでrampUsersで徐々に増やしていく。
rampUsers(10) over (10 seconds)なら10秒かけて10人のユーザーを作成する。

修正後のシナリオ

TestSiteSimulation.scala
class TestSiteSimulation extends Simulation {
  // load config
  val users = rampUsers(10) over (10 seconds)
  // target
  val requestURLs = Vector(
    "/",
    "/news/",
    "/info/",
    "/service/",
    "/recruit/",
    "/about/"
  )
  val auth = ("${user}","${pass}")
  val httpProtocol = http
        .baseURL("http://example.com")
        .inferHtmlResources()
        .acceptHeader("text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8")
        .acceptEncodingHeader("gzip, deflate")
        .acceptLanguageHeader("ja,en-US;q=0.9,en;q=0.8,pt;q=0.7,zh-CN;q=0.6,zh;q=0.5")
        .userAgentHeader("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.84 Safari/537.36")
    .basicAuth(auth._1, auth._2)

  val actions = requestURLs.map(u => ChainBuilder.chainOf(http(u).get(u)))
  val scn = scenario("TeaserSimulation").exec(actions)

  // exec
  setUp(scn.inject(users)).protocols(httpProtocol)
}

圧倒的に短くなった。アクセスするURLはrequestURLsに記載していけばOK。urlやuser設定をconfファイルから読むようにすればプログラマ以外にも引き継げる。

設定ファイル

Simulation IDとRun Descriptionの省略

デフォルトだと毎回標準入力から聞かれる。設定ファイルやコマンドラインオプションで指定してあげることができる。ここではgatling.confに設定することで入力を省略した。

gatling.conf
gatling {
  core {
    runDescription = "From IDEA"
    mute = true
    ...

公式の設定ドキュメント: https://gatling.io/docs/2.3/general/configuration/

リトライ

AWSに対してテスト実行してみたところ、10ユーザーでもKO(多分OKの反対とKnock Outをかけてるんだろうけど普通にNGとかERRでよくない?)が発生したのでリトライ設定を追加

gatling.conf
 gatling {
   http {
     ahc {
       maxRetry = 5
       ...

クラスタリングについて

GatlingにはJMeterのようなクラスタ機能はなく、複数のクライアントで同時実行してログファイルを集計してグラフをつくるしかないみたい。ちょっとつらい。

その他

テストシナリオはいくつかのパターンが必要になるだろうし、コマンドラインでSimulation IDを指定して起動するようにすればCIで使うときに便利そう。ノンデグテストであれば継続的に自動テストする意味があるが、負荷テストは手動で実行すると思うのでそこまでやらないかも。やったらこの記事も更新する。