SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

Floodの負荷テストで複雑なシステムのボトルネックを特定した話

スマートキャンプでエンジニアリングマネージャー(EM)をしている瀧川です。

本記事では、訳あって助っ人で新規Webアプリの負荷テストをすることになり、複雑な仕様とタイトなスケジュールのなかFloodというサービスと出会い事なきを得た話をしようと思います。

今回のケースではFloodがマッチ度高くかなり助かりましたが、活躍できる用途は限られる印象もあるので、一事例として見ていただき、後半でメリデメにも触れていこうと思います。

ことの発端

現在スマートキャンプではオンラインイベントプラットフォームを新規開発しており、正式リリースに向けて機能改善を進めています。

そして直近では改善のためのアクションとして、小規模なイベントを開催してユーザーのFBを受けたり、データを見て改善ポイントを洗い出していました。

しかしそこで問題が起きました。

最初は社内のみで10人程度のイベント、次に50人、500人...といった具合で少しずつイベントの規模を大きくしていく戦略だったのですが、すぐにパフォーマンス問題が浮き彫りになりました。

ユーザーから見ると、チャットをしても反映に1時間かかる、新規ログインができないなどなど。

インフラとしては、アクセスユーザー数に対して不釣り合いなほど大量にスケールアウトしたサーバー郡。

これまでの開発で0-1フェーズの経験者が少なく、非機能要件を軽んじてきてしまったということでしょう(反省点1)。

そしてイベントのスケジュールが厳密に決まっていて、集客も動いておりリスケが困難だったため、緊急で助っ人として私も入ることになりました(反省点2)。

とてもあるあるな話で、良くないプロジェクトマネジメントでしたが、反省は次に活かすとして、そんなこんなで、初めて触るアプリケーションのボトルネックを短期間で調査して修正することになりました。

Floodとの出会い

アサイン当初は、仕様をざっくり把握して、jMeterかなにかでユーザーシナリオ作って回せばいいよねと思っていたのですが、以下の理由で頓挫しました。

(かなり粒度の細かい理由で恐縮ですが)

  1. WebSocketを中心とした複雑な手続き
  2. Ruby on RailsでStimulusReflexなどを利用した動的なUI変更

オンラインイベント用のアプリケーションのため、リアルタイム性のある機能が複数存在しており、それらをWebSocket中心に実装していました。

かなり大々的にWebSocketで実装されていたため、仕様の把握やリクエストパラメータとレスポンスの一覧作成などが困難だったのが1つ目の理由です。

(そもそもこのあたりがパフォーマンスに影響を与えている予感はありました)

また上記と併せて、アプリケーションはRuby on Railsで作られているのですが、StimulusReflexやCableReadyというGemを使っていたのも負荷テストを難しくさせる要因でした。

Action Cable, CableReady, StimulusReflex、ViewComponentを組み合わせると、サーバーからのイベントをトリガーにユーザーのUIを動的に変更する、リアクティブな実装が可能になります。

(最近でいうと界隈で話題になっているHotwireが近いかと思います)

これにより実装としてはシンプルになる一方で、リクエストとレスポンスの形式がGemによってブラックボックス化してしまいました(一時的なIDがGemによって付与されたリクエストなど)。 またレスポンスに含まれているViewComponentからの値取得が先々必要になるような手続きがあったりと、テストの実装を難しくしていたかなと考えています。

そういった難解さをいったん無視するために、ユーザーに近い上位のレイヤーで負荷テストをしてざっくりとネックになっている範囲を絞り込むのが良いかなと考え、そんな中出会ったのがflood.ioになります。

Floodとは

Floodは負荷テストを分散実行できるプラットフォームで、jMeterやGatlingで書かれたユーザーシナリオを分散実行することで大規模な負荷テストを可能にするサービスとなっています。

www.flood.io

その特徴としてあるのがSeleniumや独自開発しているElementといったブラウザレベルのテストツールにも対応していることになります。

ブラウザレベルのテストはjMeterやGatlingといったツールでのテストと比較し、必要とするマシンリソースが多くなるため、実行環境を用意・管理するのが困難になります。

しかしFloodでは簡単に分散実行できる仕組みが用意されており、効率的にマシンリソースを使うことができます。さらにSeleniumやElementと組み合わせることで手軽かつ効率的にブラウザレベルのテストを実行できるという点が最大のメリットになるかと思います。

(各種テストのProsConsについて、Floodのドキュメントにわかりやすくまとまっているのでぜひこちらも参考にしてください Choosing a tool - Guides

Flood Elementとは

ブラウザレベルの負荷テストをするためのツールとしてFloodのチームが開発しているのが、OSSのFlood Element(以降Elementと表記)です。

※ 余談ですがFloodは2017年にTricentisに買収されているようです。

element.flood.io

ElementはFloodで動かすだけではなく、ローカル環境でも簡単に実行でき、デバッグが容易です。

またスクリプトは、デフォルトでTypeScriptで記載でき、Puppeteerのようなインタフェースでかなり取っつきやすいものとなっています。

コマンド例

# Initialize
element init .
ls
# element.config.js example.perf.ts  node_modules      package.json      tsconfig.json     yarn.lock

# --no-headless: ヘッドレスブラウザではなく、実行時にブラウザが起動し動作する。デバッグ時に有効。
element run ./example.perf.ts --no-headless
# --mu: Multiple Users、TestSettingsでstagesのtargetが複数になっていた場合、複数ブラウザで実行される。ユーザーが重複して実行されないかデバッグに有効。
element run ./example.perf.ts --mu

いくつかTipsを含めたサンプルスクリプトを以下に記します。

シンプルですね。

example.perf.ts

import {step, TestSettings, TestData, By, beforeAll, afterAll, Browser, ENV, Until} from '@flood/element'

export const settings: TestSettings = {
    loopCount: Infinity,
    // 実行失敗時にスクリーンショット
    screenshotOnFailure: true,
    // ブラウザの指定
    browser: 'chrome'
}

const vars = {
    targetHost: 'https://challenge.flood.io',
    usersFile: './users.csv',
    // Floodで分散実行する際の設定値、ユーザーを重複なく分散するのに使用
    // 1GridあたりのNode数
    nodesPerGrid: 10,
    // 1NodeあたりのUser数
    usersPerNode: 50
}

interface User {
    email: string
    password: string
}

export default () => {
    // 以下はFloodで分散実行した際に、ユーザーを重複なく割り振るための実装
    // Floodには複数のGridとその中に複数Nodeを持ち、実行時に環境変数からIndexを取得できる
    const gridIndex = ENV.FLOOD_GRID_INDEX
    const nodeIndex = ENV.FLOOD_NODE_INDEX
    const nodesPerGrid = vars.nodesPerGrid
    const usersPerNode = vars.usersPerNode
    const startIndex = (gridIndex * nodesPerGrid * usersPerNode) + (nodeIndex * usersPerNode)
    const currentIndex = startIndex + ENV.BROWSER_ID
    // Userデータ読み込み
    TestData.fromCSV<User>(vars.usersFile).as('user').filter((u, i) => {
        return currentIndex == i
    })

    // 実行がループするたびに実行される
    beforeAll(async browser => {
        // Basic認証
        await browser.authenticate('hogehoge', 'fugafuga')
        await browser.visit(`${vars.targetHost}/events/${vars.event.slug}/login`)
        // Elementが表示されるまで停止
        await browser.wait(Until.elementIsVisible(By.css("h1.title")))
        // スクリーンショット保存
        await browser.takeScreenshot()
    })

    step('Login', async (browser: Browser, user: User) => {
        console.log(`[LOG] Envs(Place: ${ENV.FLOOD_GRID_INDEX}-${ENV.FLOOD_NODE_INDEX}-${ENV.BROWSER_ID})`)
        console.log(`[LOG] LogIn(UserEmail: ${user.email})`)
        const emailInput = await browser.findElement(By.id('user_email'))
        await emailInput.type(user.email)
        const passwordInput = await browser.findElement(By.id('user_password'))
        await passwordInput.type(user.password)
        const submitButton = await browser.findElement(By.css('input[type="submit"][value="ログイン"]'))
        await submitButton.click()
    })
}

users.csv

email,password,role
user_0@example.com,asdf1234
user_1@example.com,asdf1234
user_2@example.com,asdf1234
user_3@example.com,asdf1234

Floodを使ってみる

実際にFloodでElementを分散実行する設定を見ていきましょう。

まずスクリプトファイルやユーザー情報など関連するファイルをすべてアップロードします。

※ この際に複数TSファイルがあると正しくEntrypointを特定できず実行が失敗する可能性があるので気をつけてください。

必要に応じてSLOも設定できるようです。

ここで分散の設定をすることになります。

DemandはFlood側のインフラを利用し実行します。

Demandの場合は3リージョンまでの制限があり、Users per Regionが500usersなので、最大1500usersでの負荷テストが可能です。

※ 初期登録時だとUsers per Regionが50usersになっていましたが、サポートチャットで連絡し対応いただきました、参考までに。

Hostedは自前のAWSアカウントなどを紐づけておき、あらかじめFloodのUI上からGridとしてEC2インスタンスを起動しておき、その環境で分散実行できるものになります。

ちなみにFloodでいう、Gridはサーバーインスタンスの集合を表していて主にリージョンで別れていて、Gridの中のサーバーインスタンスのことをNodeとしています。 そしてテストが実行される仮想ブラウザがUserと表されています。

Hostedも無制限というわけではなく、1リージョン(Grid)につき10nodes、1nodeにつき50usersが上限となっています。

※ 1nodeにつき50usersがElement(ブラウザレベルテスト)を安定して動かす限界値とFloodでは定義しているようです。

なので2000usersで負荷テストをするには4リージョン(Grid) * 10nodes必要ということになります。

また、Hostedの場合はGrid作成時にVPCなども指定できるため、Closedな環境に対する負荷テストも実行できるかと思います。

実行すると以下のような結果が得られます。

各種メトリクス、実行ログ、ステップごとの失敗率やスクリーンショットなどを確認できます。

料金体系

VUH(Virtual User Hours)という単位を基準に従量課金となっています。

VUHは、 稼働ユーザー数 * 稼働時間 / 100 と定義されています。

例えば以下の画像の例だと 500users * 1hours / 100 = 5vuh となります。

Standardプランだと1vuhは$4.5なので、この例だと 5vuh * $4.5 = $22.5 となります。

※ 登録時に500VUHが無料で使えます。

使ってみた感想

ローカルでのスクリプト作成、Floodでの実行とトータルしてみたときに、負荷テストを実施するまでのコストが低かったです。

また今回の私のケースでは、クリティカルなパフォーマンス低下が起きていたのが500users〜2000users程度で小規模だったこともありFloodがマッチしていたかなと思います。

(それ以上多くのユーザーが必要になると、インフラの立ち上げも大変ですし、料金もかなりかかってくるかなと思います)

実際にjMeterで苦戦していたところで、Floodを導入し、数日であらかたのユーザーシナリオを作成し負荷テスト実行、ある程度ボトルネックとなっているアプリケーション機能を絞り込むことができました。

(修正自体もタフでしたが、なんとか無事乗り切ることができました、Floodに感謝)

なので、FloodとElementの使い所としては、少人数での利用が想定されるアプリケーションや最低限の性能保証を最短で得たいような場合がいいのではと感じました。

またFlood自体はjMeterやGatlingの実行も可能なため、Elementでユーザーに近い負荷テストをし、ボトルネックとなっている機能を絞り込んだらjMeterやGatlingで実行するといった併用も良いのかなと感じました。

個人的にはとてもシンプルなサービスで好きでした!

最後に

今回複雑なアプリケーションの負荷テストをするためにFloodを使った事例を紹介しました。

かなりシンプルなサービスで便利なため、覚えておくといざというときに活きてくると思います。

Floodは負荷テストを分散実行できる、Flood ElementはE2Eっぽい感じで負荷テストを実行できる、リリース初期や少人数が利用するアプリケーションの性能保証に有効、とだけ頭の片隅に入れて帰っていただければ幸いです!