こんにちは、サイオステクノロジー武井です。今回は、SAMLをベースとした認証システムや、CSRFトークンが必須なWebアプリケーションの負荷テストを簡単にするツール「Gatling」の使い方と実践的なスクリプトをご紹介します。
Gatlingとは?
Webアプリケーションへの負荷テストツールです。同じようなものにJMeterがありますが、Gatlingの最大のメリットは、ScalaベースのDSLによって、シナリオを作成できるということです。
単純なシナリオであれば、わざわざDSLによって定義することはありません。JMeterにも、JMeter自身をプロキシとすることによって、Webブラウザでの操作をそのままシナリオに落とし込める方法が存在します。
ただし、複雑なシナリオの場合にはそのようにはいきません。例えば、不正なアクセスを防ぐ仕組みであるCSRFトークンは、Webアプリケーションへの、ある画面へのアクセスごとに任意のトークンを画面に埋め込みます。アクセスする際は、その画面に埋め込まれたトークンをサーバーに送出、サーバー側でそのトークンの有効性を確認します。
このCSRFトークンを負荷テストのシナリオで再現するためには、画面に埋め込まれたトークンを抽出する必要はあります。例えばCSRFトークンが<input type=”hidden” name=”tokne” value=”XXX”>のように埋め込まれていた場合は、正規表現か何かで、XXXを抽出しなければなりません。
また、SAMLの認証のシナリオにおいても同様です。SAML Responseを送出する方法として、Post Bindingが採用されていた場合、IdP側で認証が成功すると、そのHTTPレスポンスにSAML ResponseをJava ScriptのOnLoadでPostするようなHTMLが返ってきます。この場合も、正規表現か何かで、HTMLからSAML Responseを抽出しなければいけません。
このように、認証のような複雑なシナリオでは、HTTPレスポンスを解析して必要な値を抽出するという複雑な処理が必要になります。このようなときには、やはりDSLでシナリオを記述できるGatlingがラクチンです。JMeterでもできなくはないですが、あのXMLの設定で複雑なシナリオを実現するのはJMeterの独自仕様を理解しなければいけないので骨が折れます。Gatlingであれば、多少Scalaの知識があれば、Scalaのコーディング技法で、シナリオを自由に定義することができます。
では、様々なユースケースごとにGatlingの素晴らしをご紹介していきたいと思います。
インストール
何はともあれ、まずはインストールです。GatlingはScalaで記述しますので、Javaがインストールされていることが前提となります。
以下のURLにアクセスして、「DOWNLOAD NOW! 」をクリックして、Gatling本体をダウンロードします。
https://gatling.io/open-source
Gatlingを任意のディレクトリに解凍して配置すればインストールは環境です。以下の例では、/opt/gatlingというディレクトリに配置する例です。
# mkdir /opt/gatling # mv gatling-charts-highcharts-bundle-3.3.1.zip /opt/gatling # unzip gatling-charts-highcharts-bundle-3.3.1.zip # mv gatling-charts-highcharts-bundle-3.3.1/* /opt/gatling/
ユースケースその1:簡単な負荷テストをする
まずは超簡単な負荷テストを実施してみたいと思います。シナリオは以下とします。
- https://hoge.example.com/というURLに対して、同時10接続でアクセスする。MethodはGETとする。
上記のシナリオは以下のように定義します。
import scala.concurrent.duration._ // 使用するライブラリをインポートする。 import io.gatling.core.Predef._ import io.gatling.https.Predef._ import io.gatling.jdbc.Predef._ class UseCase01 extends Simulation { // 送出するHTTPヘッダを定義する。 val httpsProtocol = https .header("User-Agent","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:75.0) Gecko/20100101 Firefox/75.0") .header("Accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") .header("Accept-Language","ja,en-US;q=0.7,en;q=0.3") .header("Accept-Encoding","gzip, deflate, br") .header("DNT","1") .header("Connection","keep-alive") .header("Upgrade-Insecure-Requests","1") // シナリオの名前を定義する。任意の名称でよい。 val scn = scenario("RecordedSimulation") // これより送出するHTTPリクエストの定義を行う。まずはHTTPリクエストごとに名前をつける。 // 後ほど作成されるレポートで利用するだけなので、区別しやすい任意の名称でよい。 .exec(https("SP") // 負荷テスト対象のURLを定義する。ここで定義したURLに対して負荷がかかる。 .get("https://hoge.example.com")) // 同時10ユーザーでアクセスする。 setUp(scn.inject( atOnceUsers(10) )) }
このシナリオを実行します。シナリオのファイルを/opt/gatling/user-files/simulationsディレクトリに配置します。
# cp UseCase01.scala /opt/gatling/user-files/simulations
実行します。
# cd /opt/gatling/bin # ./gatling.sh -s UseCase01 --run-description ""
以下のように結果が出力されます。
================================================================================ ---- Global Information -------------------------------------------------------- > request count 10 (OK=10 KO=0 ) > min response time 198 (OK=198 KO=- ) > max response time 280 (OK=280 KO=- ) > mean response time 241 (OK=241 KO=- ) > std deviation 28 (OK=28 KO=- ) > response time 50th percentile 251 (OK=251 KO=- ) > response time 75th percentile 255 (OK=255 KO=- ) > response time 95th percentile 278 (OK=278 KO=- ) > response time 99th percentile 280 (OK=280 KO=- ) > mean requests/sec 10 (OK=10 KO=- ) ---- Response Time Distribution ------------------------------------------------ > t < 800 ms 10 (100%) > 800 ms < t < 1200 ms 0 ( 0%) > t > 1200 ms 0 ( 0%) > failed 0 ( 0%) ================================================================================
request countのKOが0であれば、全てのリクエストは成功しています。OKが成功、KOが失敗を表しています。
ユースケースその2:フォームに入力した値をPostするケース
次に、フォームに入力した値をPostするようなケースを考えてみます。ログイン画面でIDとパスワードを入力して送信するというのがよくある例ですね。以下のようなシナリオを考えてみます。
- ログイン画面はhttps://hoge.example.comというURLとする。
- ユーザー名のパラメータ名はusername、パスワードのパラメータ名はpasswordとする。
- https://hoge.example.com/loginというURLに対して、ユーザー名とパスワードをPostする。
- Content-Typeはapplication/x-www-form-urlencodedとする。
- 上記のシナリオを同時10接続で実施する。
上記のシナリオは以下のように定義します。
import scala.concurrent.duration._ // 使用するライブラリをインポートする。 import io.gatling.core.Predef._ import io.gatling.https.Predef._ import io.gatling.jdbc.Predef._ class UseCase02 extends Simulation { // 送出するHTTPヘッダを定義する。 val httpsProtocol = https .header("User-Agent","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:75.0) Gecko/20100101 Firefox/75.0") .header("Accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") .header("Accept-Language","ja,en-US;q=0.7,en;q=0.3") .header("Accept-Encoding","gzip, deflate, br") .header("DNT","1") .header("Connection","keep-alive") .header("Upgrade-Insecure-Requests","1") // シナリオの名前を定義する。任意の名称でよい。 val scn = scenario("RecordedSimulation") // これより送出するHTTPリクエストの定義を行う。まずはHTTPリクエストごとに名前をつける。 // 後ほど作成されるレポートで利用するだけなので、区別しやすい任意の名称でよい。 .exec(https("login") // ログイン画面のURLを定義する。 .post("https://hoge.example.com/")) .exec(https("post") // ユーザーIDとパスワードをPostするURLを定義する。 .post("https://hoge.example.com/login") // PostするユーザーIDとパスワードの値を定義する。 .formParam("username", "ntakei") .formParam("password", "password")) // 同時10ユーザーでアクセスする。 setUp(scn.inject( atOnceUsers(10) )) }
実行方法は、ユースケースその1と同じですので割愛します。
ユースケースその3:CSRFトークンを送出するケース
だんだん複雑になってきます。CSRFトークンを送出するケースを考えてみます。以下のようなシナリオを考えてみます。ユースケースその2にCSRFトークンを送出する処理を追加しました。
- ログイン画面はhttps://hoge.example.comというURLとする。
- ユーザー名のパラメータ名はusername、パスワードのパラメータ名はpasswordとする。
- ログイン画面にはCSRFトークンが、<input type=”hidden” name=”tokne” value=”XXX”>のように埋め込んである。
- https://hoge.example.com/loginというURLに対して、ユーザー名とパスワードをPostする。
- Content-Typeはapplication/x-www-form-urlencodedとする。
- 上記のシナリオを同時10接続で実施する。
上記のシナリオは以下のように定義します。
import scala.concurrent.duration._ // 使用するライブラリをインポートする。 import io.gatling.core.Predef._ import io.gatling.https.Predef._ import io.gatling.jdbc.Predef._ class UseCase03 extends Simulation { // 送出するHTTPヘッダを定義する。 val httpsProtocol = https .header("User-Agent","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:75.0) Gecko/20100101 Firefox/75.0") .header("Accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") .header("Accept-Language","ja,en-US;q=0.7,en;q=0.3") .header("Accept-Encoding","gzip, deflate, br") .header("DNT","1") .header("Connection","keep-alive") .header("Upgrade-Insecure-Requests","1") // シナリオの名前を定義する。任意の名称でよい。 val scn = scenario("RecordedSimulation") // これより送出するHTTPリクエストの定義を行う。まずはHTTPリクエストごとに名前をつける。 // 後ほど作成されるレポートで利用するだけなので、区別しやすい任意の名称でよい。 .exec(https("login") // ログイン画面のURLを定義する。 .post("https://hoge.example.com/") // 正規表現でCSRFトークンを取り出し、tokenという変数に格納する。 .check(regex("""""").saveAs("token"))) .exec(https("post") // ユーザーIDとパスワードをPostするURLを定義する。 .post("https://hoge.example.com/login") // PostするユーザーIDとパスワードの値を定義する。 .formParam("username", "ntakei") .formParam("password", "password") // 先程ログイン画面から取り出したCSRFトークンを定義する。 .formParam("token", "${loginurl}")) // 同時10ユーザーでアクセスする。 setUp(scn.inject( atOnceUsers(10) )) }
実行方法は、ユースケースその1と同じですので割愛します。
ユースケースその4:SAMLに対応したIdPに負荷をかけるケース
最後はSAMLに対応したIdPに負荷をかけるケースを考えてみます。シナリオは以下のようになります。
- SAMLに対応したIdP(以降、IdPと呼称)のURLはidp.exmaple.comとする。
- IdPは正しいユーザー名とパスワードを入力すれば認証は完了とする。
- SAMLに対応したSP(以降、SPと呼称)のURLはsp.exmaple.comとする。
- SPの仕様はHTTPレスポンスとして、IdPに入力したユーザー名がそのまま返ってくるものとする。
- SPにSAML Responseを返す方法は、Post bindingsとする。
- Post BindigsでSAML Responseを返す際、SAML Responseのパラメータ名はSAMLResponse、Relay Stateのパラメータ名はRelayStateとする。
- IdPに入力するユーザー名はusers.csvに以下のように定義し、上から順番にIdPにPostするものとする。
username,password test001,password1 test002,password2 test003,password3
上記のシナリオは以下のように定義します。
import scala.concurrent.duration._ import scala.util.parsing.json.JSON import io.gatling.core.Predef._ import io.gatling.https.Predef._ import io.gatling.jdbc.Predef._ class UseCase04 extends Simulation { // IdPへのログインユーザー情報を定義したCSV(users.csv)をfeederに読み込む。 val feeder = csv("/opt/gatling/user-files/data/users.csv") // シナリオの名前を定義する。任意の名称でよい。 val scn = scenario("RecordedSimulation") // これより送出するHTTPリクエストの定義を行う。まずはHTTPリクエストごとに名前をつける。 // 後ほど作成されるレポートで利用するだけなので、区別しやすい任意の名称でよい。 // SPのシナリオを定義する。 .exec(https("SP") // SPのURLを定義する。 .get("https://sp.example.com/sp.php") // HTTPリクエスト時に送信するヘッダを定義する。 .header("User-Agent","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:75.0) Gecko/20100101 Firefox/75.0") .header("Accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") .header("Accept-Language","ja,en-US;q=0.7,en;q=0.3") .header("Accept-Encoding","gzip, deflate, br") .header("DNT","1") .header("Connection","keep-alive") .header("Upgrade-Insecure-Requests","1") // 念の為、1秒ポーズを入れる。 .pause(1) // CSVのユーザー情報を上から順番に取得する設定をする。 .feed(feeder.circular) // IdPにログインするシナリオを定義する。 .exec(https("IdP") // IdPのログイン情報のPost先のURLを定義する。環境変数から取得したクライアントIDをマッピングする。 .post("https://idp.example.com/login") // HTTPリクエスト時に送信するヘッダを定義する。 .header("User-Agent","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:75.0) Gecko/20100101 Firefox/75.0") .header("Accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") .header("Accept-Language","ja,en-US;q=0.7,en;q=0.3") .header("Accept-Encoding","gzip, deflate, br") .header("Content-Type","application/x-www-form-urlencoded") .header("Origin","https://idp.example.com") .header("DNT","1") .header("Connection","keep-alive") .header("Upgrade-Insecure-Requests","1") // IdPへのログインに必要な情報(ログインID、パスワードなど)を設定する。この情報がPostされる。 // CSVで定義したユーザー名とパスワードがPostされる。 .formParam("username", "${username}") .formParam("password", "${password}") // レスポンスからSAML ResponseとRelayStateを取得してセッションに格納する。 .check(regex("""name=\"SAMLResponse\" value=\"(.+)\"""").saveAs("samlp")) .check(regex("""name=\"RelayState\" value=\"(.+)\"""").saveAs("relaystate"))) .exec(session => { // セッションに格納したSAML Responseなどの情報を取得する。 val samlp = session("samlp").as[String] // RelayState内の:を:(セミコロン)に置換する。 val relaystate = session("relaystate").as[String].replace(":", ":") // Mapから取得した情報をセッションに格納する。後のシナリオで取り出す。 var session1 = session.set("samlp", samlp) .set("relaystate", relaystate) session1}) // 念の為、1秒ポーズを入れる。 .pause(1) // SPにSAMLResponseをPostするシナリオを定義する。 .exec(https("SAMLP2SP") // SPにSAMLResponseをPostする先のURLを定義する。 .post("https://sp.example.com/POST") // HTTPリクエスト時に送信するヘッダを定義する。 .header("User-Agent","Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:75.0) Gecko/20100101 Firefox/75.0") .header("Accept","text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") .header("Accept-Language","ja,en-US;q=0.7,en;q=0.3") .header("Accept-Encoding","gzip, deflate, br") .header("Content-Type","application/x-www-form-urlencoded") .header("Origin","https://idp.example.com") .header("DNT","1") .header("Connection","keep-alive") .header("Upgrade-Insecure-Requests","1") // 前の画面から取得したSAMLResponseとRelayStateをPostする。 .formParam("RelayState", "${relaystate}") .formParam("SAMLResponse", "${samlp}") // SPの画面にはログインしたユーザーIDが表示されるので、そのレスポンスをチェックする。 .check(bodyString.is("${username}"))) // 同時10ユーザーでアクセスする。 setUp(scn.inject( atOnceUsers(10) )) }
実行方法は、ユースケースその1と同じですので割愛します。
まとめ
いかがでしたでしょうか?ScalaによるDSLでシナリオを定義するGatlingであれば、認証システムへの負荷テストも簡単に実現できますね!!もうGatlingなしでは生きていけません。No Gatling, No Life!!