クラウドソリューション開発部の藤井です。
負荷試験ツール k6 の紹介記事の第2弾として、応用例を解説させていただきます。
k6 って何だろうという方は、ぜひこちらの記事をご参照ください。
k6 は公式サイトに「シンプルなテストでも、無いよりはまし」とあるように、最初から複雑な設定やコードがフルセットで必要ではなく、JavaScriptで提供されている小さな独立したモジュールを柔軟に組み合わせて使うことができるようになっています。
今回は試験対象として、Cookieを使ったログイン機能を持つ典型的なWebアプリケーションを想定し、少し複雑な以下の方法について、コードの例を交えて解説致します。
・HARファイル利用による試験シナリオ定義の簡略化
・一括実行とGroup別の実行結果の表示
・VUSごとのログイン情報の事前定義
・Cookieと動的データの利用(ログイン認証とクロスサイトスクリプティング対策による意図しないリダイレクトの抑制対応)
HARファイルの利用
HARの作成
k6 の試験シナリオを作成・実行するにあたり、1リクエストずつ JavaScriptで定義していくのではなく、ブラウザで実際に動かしたときのクロールデータを元にして実行するようにしたい場合に、HAR(HTTP アーカイブ) を利用するという手段があります。HARには HTTPリクエストとレスポンスの情報が記録されます。
HARについては k6 公式から、 Google Chrome や Firefox の拡張機能や、HARコンバーター が提供されていますが、今回は公式のツール群は使わずに Chromeの開発者ツールとテキストエディタで HARを作成します。これは、普段は使わないブラウザ拡張機能をインストールしたくないことと、ひとつのスクリプトで一括実行したいためです。
Chromeの開発者ツールでは、送受信される画像データなども含めて「コンテンツと一緒にHARとしてすべて保存」する方法も提供されていますが、この記事では簡略化のため、画像など(いわゆるメディアデータ)のテストは不要とします。ですので代わりに、右クリックメニューの「コピー」→「HARとしてすべてコピー」で、軽量なテキストデータのみで HARを作成します。
以下の手順で作成できます。
1. Chrome開発者ツールを F12 等で開き、ネットワークタブを開きます
2. 開きたい画面を開く前に、ネットワークタブの「消去」ボタンを押下して、綺麗にしておきます
3. 開きたい画面を開くと、ネットワーク通信が走ります
4. 通信が落ち着いた時点で、一覧部分を右クリックして「HARとしてすべてコピー」します
5. コピーした内容を任意のテキストエディアに張り付けて、HARファイル格納先のフォルダ(今回は ./hars )に任意のファイル名で保存します
※このファイル名は、結果表示時に使用されます。ファイル名にはテスト対象のURLの一部を使うのが手っ取り早いです(例えばベースURLが https://example.com/testapp/ のWebサイトの場合は https://example.com/testapp/user/ のクロールHARファイルは user.har にする等)。基本的にどのOSでもファイル名に半角スラッシュは使えません。そのため、例えば user/create として結果表示したいクロールのHARの場合は、少し強引ですが半角スラッシュを「___(3連続の半角下線)」に置換して user___create.har として保存してください。そうすれば後述するHARファイル読込処理にて半角スラッシュへ戻されます。
試験対象URLの置換と抽出
HARファイルには、対象Webサイトのホスト名が保存されます。そのため、単純にHARファイルからURLを拾ってしまうと、HARを作成した環境でしか試験できません。
実務でよくある構成として、本番環境とは別に、開発環境やステージング環境を独立したネットワーク上に(URLも別で)用意することがありますが、同じ内容なのに環境ごとにHARを作るのは非効率です。
また別な問題として、外部URLからのリソースファイル読みこみ(jQuery, Bootstrap, GoogleMap など, CDN経由のCSSやJavaScriptの利用等)がある場合、それに対して負荷をかけてしまうとDoS攻撃と見なされる恐れがあるため、基本的には負荷試験対象から外す必要があります。
それを踏まえて今回は、HAR作成時のURL(例では harUrl )を、実際の試験対象のURL(例では targetUrl )に置換します。それと同時に、HARにharUrl以外のリクエストが記録されていた場合は、試験対象から外します。さらに、開発環境でスクリプトを作成し、そのまま別環境へテストを投げられるように、targetUrl はファイルへハードコーディングするのではなく、コマンドライン引数 URL から指定できるようにします。
HAR作成は http://localhost で行い、試験対象は http://192.168.56.10/ というローカルIP とする場合は、以下のようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
// 定数定義 // 適宜調整すること // 対象URL(環境変数URLとして渡すこと) const targetUrl = __ENV.URL; // ログインURL const loginUrl = targetUrl+"/login/"; // HARファイル生成時のベースURL。HARファイル中のURLと一致していないと対象URLへの置換時に空振りになるので注意 const harUrl = "http://192.168.56.10"; //(省略) // テストケースのSharedArrayをHarファイルから作成する // それぞれ以下を持つ // name: テスト名 (= Group Name) // method: HTTPリクエストメソッド(GET or POST) // url: HARからベースURLを削除した url // body: リクエストパラメータ等(フォームでの送信データはdecodeURI済) // params: リクエストヘッダ const testCases = new SharedArray('testCases', function () { //(省略) if( !ent.request.url.startsWith(harUrl) ) continue; //(省略) // テストケースごとに下準備した値を詰める arr.push( { name: baseName(filename).replace(/\_\_\_/g, '/'), // HARファイル名の___ は / に置換する method: ent.request.method, url: ent.request.url.slice( harUrl.length ), body: (Object.keys(bodyParam).length === 0)? null:bodyParam, params: { headers: obj } } ); } } if( arr.length === 0 ) fail('testCases is empty. Check har files contain harUrl[' + harUrl +']'); return arr; }); |
1 2 |
# bash でのテスト実行例 k6 run -e URL=http://192.168.56.10 -e FILELIST="./hars/Test.har;" k6-with-chrome-hars.js |
複数HARの一括実行
同じHARを単発で繰り返して実行した場合、キャッシュなどで性能が意図せず底上げされる可能性があります。性能が上がるのは歓迎すべきことですが、実際の運用ではリクエストが複数のURLに分散するために、単発試験時よりもキャッシュが有効に効かず、想定していたよりもパフォーマンスが悪くなる危険性があります。それを避けるため、作成したHARは今回はランダムに複数一括実行させます。
残念ながら、ES2015/ES6 のJavaScriptには、ディレクトリからファイル一覧を取得するうまい方法がなく、k6 にもその手のツールは用意されていません。npm module を使えば glob モジュールを利用することでファイル一覧の取得が可能ですが、k6 はNode.js上のモジュールではなく golang の実行系を使っているため、そのままでは npm のモジュールを使えません。現状では npm で、Webpack や browserify などを使ってモジュール化するしかないため、HARファイルの一括取得のためだけにそれを行うには手間がかかりすぎます。
それを踏まえて、今回は解決方法として、findコマンドを使ってファイル名をセミコロンで連結した文字列を生成し、k6スクリプトに環境変数で渡すことにします。
※公式のリポジトリのissuesに参考例が掲載されています。リンク先には他にも、JSONでファイル一覧を作っておく方法も紹介されています。
HARファイルが ./hars 以下に aaa.har, bbb.har, ccc.har のファイル名で存在するとき、bash にて
1 |
find ./hars -type f -print0 | tr '\0' ';' |
を実行すると、文字列「aaa.har;bbb.har;ccc.har;」を生成することができます。これを利用して k6 を下記のように実行します。
1 |
k6 run -e URL=http://192.168.56.10 -e FILELIST="$(find ./hars -type f -print0 | tr '\0' ';')" k6-with-chrome-hars.js |
なお、今回はファイル名に使えなかった半角スラッシュの代理として___があった場合、元の文字に置換することにします(前掲の testCases の以下の箇所)。
1 |
name: baseName(filename).replace(/\_\_\_/g, '/'), // HARファイル名の___ は / に置換する |
VUSユーザのログイン情報の事前定義
ログイン機能を持つWebサイトの試験の場合、Webサイト側で事前に作成されたユーザを使ってログインして試験したい場合があります。
今回は、Webサイト側のユーザ定義を users.csv というCSVファイルとしてエクスポートして使用しました。このCSVファイルには、以下のようにIDと平文のパスワードが定義してあります。
1 2 3 4 |
"id","password" "id-admin01","adminPasswordExample1" "id-u000001","userPasswordExample1" "id-u000002","userPasswordExample2" |
k6 の 外部ライブラリに用意されている Papa Parse を利用してパースし、SharedArray 型で保持しておきます。
SharedArray は k6 の配列的な使い方のできる省メモリのオブジェクトで、公式ドキュメントでは1000要素以上ある場合にメモリ・CPU効率が上がるとのことです。ただし const のように、初期化は1度しかできず、以降は読取専用なのでご注意ください。
1 2 3 4 5 6 7 8 9 10 11 12 |
import { SharedArray } from 'k6/data'; import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js'; // ユーザリストCSVをパースして、k6 の SharedArray に保存しておく // 現在のk6 では、SharedArray は1度しか初期化できない、読取専用なので注意すること const csvUsers = new SharedArray('csvUsers', function () { // Papa Parse を使ってCSVをパースします。BOMや改行コードは落とします。 // CSVカラムごとにJSONのように設定されるため、 // csvUsers[0].id, csvUsers[0].password のように使用できます return papaparse.parse(open(users_csv).replace(/^\ufeff/,"").replace(/^\r?\n*|\r?\n*$/g, ''), { header: true }).data; }); |
k6 は、VUS(Virtual UserS)と呼ばれる無限ループが独立して iteration (繰り返し)を行います。
今回の試験対象のWebアプリケーションでは、一度ログインしたユーザはログアウトするまで再ログインはしない作りになっているため、より実運用に近づけるために、ログイン情報をVUごとにCookieへ持たせるようにします(Cookieの利用については、後ほど詳しく解説します)。
なお、VU共通の変数を持ちたい(値を変更したい)場合は、グローバルスコープで let, var で定義するのではなく、
上のコード例でも使った globalThis を使うほうが安全です( 理由についてはv0.40.0 リリースノートの Main module/script no longer pollutes the global scope をご覧ください)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
// 以下はk6のVUSごとに共有するためのグローバル変数 globalThis.cookies = []; globalThis.token=[]; globalThis.xsrf_token = []; globalThis.fit_session = []; //(省略) // テスト本体 export default function() { // イテレーションごとに、VU で使うユーザIDをランダムに決める let user = (function(){ let userOption; if(exec.test.options.scenarios.default.stages) userOption = csvUsers[__VU % exec.test.options.scenarios.default.stages[0].target]; else userOption = csvUsers[__VU % exec.test.options.scenarios.default.vus]; return userOption; })(); console.debug(`VU ${__VU} on iteration ${__ITER} has user ID ${user.id}...`); // ユーザごとにログイン状態を保持するため cookie を設定する // 1度読んだらグローバル変数に保持しておき、別イテレーションでは再ログインしない // もしここでエラーになった場合、毎回NGとなり、 // 試験がかなり遅くなるので注意すること if (globalThis.cookies[user.id] === undefined) { const res = http.get(loginUrl); // 下記はxsrf対策のためのtokenを持つようなサイトの場合を想定しています // find path と リクエストパラメータ、メソッドタイプ は適宜調整してください const elem = res.html().find('input[name=_token]'); console.debug('globalThis.cookies[user.id] is undefined: ' + user.id); const formdata = { email: user.id, password: user.password, _token: elem.attr('value') }; const headers = { 'Referer': loginUrl }; const response = http.post(loginUrl, formdata, { headers: headers }); sleep(5); const checkRes = check(response, { 'status was 200': (r) => r.status == 200, }) || fail('status was not 200 ' + res.url ); console.debug('VU ' + __VU + ' response ' + JSON.stringify(response)); // cookie と token を VUごとに保持する // tokenのセレクタや、各 Set-Cookie の項目はケースバイケースなので注意すること globalThis.cookies[user.id] = http.cookieJar().cookiesForURL(targetUrl); globalThis.token[user.id] = response.html().find('input[name=_token]').attr('value'); if( response.headers.hasOwnProperty('Set-Cookie') ){ if( response.headers['Set-Cookie'].match(/XSRF-TOKEN=(.+?);/) ){ globalThis.xsrf_token[user.id] = response.headers['Set-Cookie'].match(/XSRF-TOKEN=(.+?);/)[1]; globalThis.cookies[user.id]['XSRF-TOKEN'] = [ globalThis.xsrf_token[user.id] ]; } if( response.headers['Set-Cookie'].match(/globalThis.fit_session=(.+?);/) ){ globalThis.fit_session[user.id] = response.headers['Set-Cookie'].match(/globalThis.fit_session=(.+?);/)[1]; globalThis.cookies[user.id]['globalThis.fit_session'] = [ globalThis.fit_session[user.id] ]; } console.debug('VU ' + __VU + ' set cookie. ' + JSON.stringify(globalThis.cookies[user.id])); } } else { console.debug('globalThis.cookies[user.id] is defined: ' + user.id); } |
送信するフォームデータは、試験対象のシステムのログイン時のPOSTのパラメーター名にあわせる必要があります。
今回の例は、ユーザー名が email、パスワードが password 、後述のトークンが _token というパラメータキーの場合です。
1 2 3 4 5 |
const formdata = { email: user.id, password: user.password, _token: elem.attr('value') }; |
Cookieと動的データの利用
ログイン時のセッションキーや、クロスサイトスクリプティング対策のためのトークンなどをCookieに持つようなWebサイトを試験する場合、何も考慮しないでリクエストを投げると、Webサイト側の認証・認可の機能でエラーページやログイン画面にリダイレクトされてしまい、意図した試験にならない場合があります。
k6 公式サイトにそれぞれ、Cookie と動的データ利用についての実装例があります。前段の HARを利用する点も踏まえて、少し複雑になりますが、今回は以下のように実装しています。
①ログイン時など、レスポンスヘッダに Set-Cookie があれば VU ごとにその値を保持する
②VU ごとに、保持している Cookie がなれけば、先にログイン画面をリクエストしてログインする。Cookieが既にあればログイン画面をスキップする
③リクエストの際に、HTMLのINPUT要素でname「_token」属性を持つINPUT要素があれば、その値をリクエストヘッダ「x-csrf-token」およびリクエストパラーメーター「token」の値として設定する※
④リクエストの際に、Cookieに XSRF-TOKEN 値が設定されていれば、その値をリクエストヘッダ「x-xsrf-token」の値として設定する※
※ただしリクエストヘッダもリクエストパラメータ―も、HARでの元リクエストで、そのキーが使用されている場合のみ設定する
①は、前掲したログイン処理の51行目以降で行っています。
②も、ログイン処理の28行目のブロックです。
③と④については以下の処理で行います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
// Harの tokenを実際のものに付け替える(リクエストヘッダ中のものも全部変える) batch.forEach( item => { item[1] = item[1].replace(/token%5B%5D=(.+?)&/g,'token%5B%5D=' + globalThis.token[user.id] + '&'); if( item[2] !== null ){ item[2]._token = globalThis.token[user.id]; } console.debug('VU ' + __VU + ' current cookie. ' + JSON.stringify(globalThis.cookies[user.id])); item[3].headers.cookie = JSON.stringify(globalThis.cookies[user.id]).replace(/\[|\||\]|"|\{|\}/g,'').replace(/,/g,'; ').replace(/:/g,'='); item[3].jar = globalThis.cookies[user.id]; item[3].headers.referer = globalThis.url + '/' + gName; if( item[3].headers.hasOwnProperty('x-csrf-token') ){ console.debug('VU ' + __VU + 'x-csrf-token :' + globalThis.token[user.id] ); item[3].headers['x-csrf-token'] = [ globalThis.token[user.id] ]; } if( item[3].headers.hasOwnProperty('x-xsrf-token') ){ console.debug('VU ' + __VU + 'x-xsrf-token :' + globalThis.cookies[user.id]['XSRF-TOKEN'] ); item[3].headers['x-xsrf-token'] = [ decodeURIComponent(globalThis.cookies[user.id]['XSRF-TOKEN']) ] ; } console.debug('VU ' + __VU + ' header cookie. ' + item[3].headers.cookie); // バッチ実行 let res = http.batch([item])[0]; //(省略) // Set-Cookie レスポンスヘッダがあればグローバル変数を更新する if( res.status == 200 && res.headers.hasOwnProperty('Set-Cookie') ){ if( res.headers['Set-Cookie'].match(/XSRF-TOKEN=(.+?);/) ){ console.debug('VU ' + __VU + ' Response has Set-Cookie header . ' + res.headers['Set-Cookie']); console.debug('VU ' + __VU + ' updated current cookie. ' + JSON.stringify(globalThis.cookies[user.id])); globalThis.xsrf_token[user.id] = res.headers['Set-Cookie'].match(/XSRF-TOKEN=(.+?);/)[1]; globalThis.cookies[user.id]['XSRF-TOKEN'] = [ globalThis.xsrf_token[user.id] ]; } if( res.headers['Set-Cookie'].match(/globalThis.fit_session=(.+?);/) ){ console.debug('VU ' + __VU + ' updated current cookie. ' + JSON.stringify(globalThis.cookies[user.id])); globalThis.fit_session[user.id] = res.headers['Set-Cookie'].match(/globalThis.fit_session=(.+?);/)[1]; globalThis.cookies[user.id]['globalThis.fit_session'] = [ globalThis.fit_session[user.id] ]; } } // リダイレクト先に token のINPUT要素があればグローバル変数を更新する if( res.html().find('input[name=_token]') !== undefined && res.html().find('input[name=_token]').attr('value') !== undefined){ console.debug('VU ' + __VU + ' globalThis.token( updated ) : ' + res.html().find('input[name=_token]').attr('value') ); globalThis.token[user.id] = res.html().find('input[name=_token]').attr('value'); } |
Group別の結果表示
標準のメトリクスでは check 項目ごとにグループ単位で成功率が表示されますが、その他のメトリクスはグループ単位では表示されず、全てまとめての結果しか表示できません。ですので今回は、グループ別に主要なメトリクスを表示させるため、カスタムメトリクスを利用します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
import { sleep, check, fail, group } from "k6"; import { Trend } from 'k6/metrics'; //(省略) // グループ名のリストを作っておく const groupNames = [...new Set(testCases.map(item => item.name))]; // グループごとにカスタムメトリクスを定義する (function(){ for( const gName of groupNames ){ globalThis[gName+'_trend_http_req_blocked'] = new Trend(' ' + gName + ' ... http_req_blocked', true); globalThis[gName+'_trend_http_req_connecting'] = new Trend(' ' + gName + ' ... http_req_connecting', true); globalThis[gName+'_trend_http_req_duration'] = new Trend(' ' + gName + ' ... http_req_duration', true); globalThis[gName+'_trend_http_req_receiving'] = new Trend(' ' + gName + ' ... http_req_receiving', true); globalThis[gName+'_trend_http_req_sending'] = new Trend(' ' + gName + ' ... http_req_sending', true); globalThis[gName+'_trend_http_req_waiting'] = new Trend(' ' + gName + ' ... http_req_waiting', true); } })(); //(省略) // テスト本体 export default function() { // 実施するグループ名をランダムに決定する let gName = groupNames[ randomIntBetween(1, groupNames.length) -1 ]; // 以下、グループごとに実行する group(gName, function(){ //(省略) // バッチ実行 let res = http.batch([item])[0]; check(res, { 'status was 200': (r) => { return r.status == 200; } }, //(省略) // グループごとにカスタムメトリクスとして集計する globalThis[gName+'_trend_http_req_blocked'].add(res.timings.blocked); globalThis[gName+'_trend_http_req_connecting'].add(res.timings.connecting); globalThis[gName+'_trend_http_req_duration'].add(res.timings.duration); globalThis[gName+'_trend_http_req_receiving'].add(res.timings.receiving); globalThis[gName+'_trend_http_req_sending'].add(res.timings.sending); globalThis[gName+'_trend_http_req_waiting'].add(res.timings.waiting); |
実行結果
今回は動作確認ということで負荷としては極控えめに、3VUS、5分間で実行しました。上述の通り、グループごと(HARごと)の結果が表示されています。
使用したスクリプトの完全版はこちらです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 |
import http from "k6/http"; import { sleep, check, fail, group } from "k6"; import { Trend } from 'k6/metrics'; import { SharedArray } from 'k6/data'; import exec from 'k6/execution'; import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js'; import { normalDistributionStages, randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; /******************************************************************************************************************************* k6 負荷試験用スクリプト(HARファイル利用 Group別表示版) ========= ログイン状態をcookieで持ち、xsrf対策のためtoken機能を持つようなWebシステムでの、負荷試験スクリプトです。 シナリオ定義は、Chrome 開発者ツールで作成した HARファイルを使う想定です。 ## author ecomott.inc ## example ``` k6 run -e URL=https://xxxx -e FILELIST="$(find ./hars -type f -print0 | tr '\0' ';')" k6-with-chrome-hars.js ``` ## license MIT *******************************************************************************************************************************/ // 定数定義 // 適宜調整すること // 対象URL(環境変数URLとして渡すこと) const targetUrl = __ENV.URL; // ログインURL const loginUrl = targetUrl+"/login/"; // HARファイル生成時のベースURL。HARファイル中のURLと一致していないと対象URLへの置換時に空振りになるので注意 const harUrl = "http://192.168.56.10"; // ユーザ定義ファイル const users_csv = './users.csv'; // ユーザリスト // 以下はk6のVUSごとに共有するためのグローバル変数 globalThis.cookies = []; globalThis.token=[]; globalThis.xsrf_token = []; globalThis.fit_session = []; export const options = { scenarios: { default: { // Note: テストしたい executor ブロックごとに、コメントアウトを切り替えること /////////////////////////////////// // executor: 'ramping-vus', // startVUs: 0, // stages: normalDistributionStages(5, 60, 5), //maxVus, durationSeconds, [numberOfStages] // gracefulRampDown: '30s', /////////////////////////////////// // executor: 'ramping-vus', // startVUs: 10, // stages: [ // { duration: '2m', target: 10 }, // { duration: '5m', target: 50 }, // { duration: '5m', target: 500 }, // { duration: '5m', target: 10 }, // ], // gracefulRampDown: '0s', /////////////////////////////////// executor: 'constant-vus', vus: 3, duration: '5m', gracefulStop: '0s', } }, thresholds: { http_req_duration: ['p(90)<60000'], // 90% of requests should be below 60,000ms(1m) } }; // ユーザリストCSVをパースして、k6 の SharedArray に保存しておく // 現在のk6 では、SharedArray は1度しか初期化できない、読取専用なので注意すること const csvUsers = new SharedArray('csvUsers', function () { // Papa Parse を使ってCSVをパースします。BOMや改行コードは落とします。 // CSVカラムごとにJSONのように設定されるため、 // csvUsers[0].id, csvUsers[0].password のように使用できます return papaparse.parse(open(users_csv).replace(/^\ufeff/,"").replace(/^\r?\n*|\r?\n*$/g, ''), { header: true }).data; }); // テストケースのSharedArrayをHarファイルから作成する // それぞれ以下を持つ // name: テスト名 (= Group Name) // method: HTTPリクエストメソッド(GET or POST) // url: HARからベースURLを削除した url // body: リクエストパラメータ等(フォームでの送信データはdecodeURI済) // params: リクエストヘッダ const testCases = new SharedArray('testCases', function () { let baseName = (function (str) { let base = new String(str).substring(str.lastIndexOf('/') + 1); if(base.lastIndexOf(".") != -1) base = base.substring(0, base.lastIndexOf(".")); return base; }); if( __ENV.FILELIST === undefined ) fail('Must Required FILELIST EnvironmentVar'); let arr = []; const files = __ENV.FILELIST.split(';'); for ( const filename of files ){ if( filename === '' ) continue; let tmpJson = JSON.parse(open(filename)); for( const ent of tmpJson.log.entries ){ if( !ent.request.url.startsWith(harUrl) ) continue; // console.log( 'name: ' + baseName(filename) + ', url : ' + ent.request.url.slice( harUrl.length ) ); let bodyParam = {}; if( ent.request.postData !== undefined && ent.request.postData.params !== undefined ){ ent.request.postData.params.forEach( item => { if( ent.request.postData.mimeType === 'application/x-www-form-urlencoded' ){ // form post データはデコードする bodyParam[item.name] = decodeURIComponent(item.value); } else { // multipart の場合 // Chrome で HAR を保存した場合、バイナリデータのときは日本語ロケールだと固定文字でこの文字列が入る(2022/11/16時点) if( !item.value === '(バイナリ)' ){ bodyParam[item.name] = ''; } else { bodyParam[item.name] = item.value; } } }); } let obj = {}; // k6 のリクエストヘッダは HTTP1しかサポートしていない(2022/11/16時点)ので、 // HTTP2仕様のリクエストヘッダは省いている ent.request.headers.forEach( item => { if( !item.name.startsWith(':') && !item.name.startsWith('content-length') ) obj[item.name] = item.value; }); // テストケースごとに下準備した値を詰める arr.push( { name: baseName(filename).replace(/\_\_\_/g, '/'), // HARファイル名の___ は / に置換する method: ent.request.method, url: ent.request.url.slice( harUrl.length ), body: (Object.keys(bodyParam).length === 0)? null:bodyParam, params: { headers: obj } } ); } } if( arr.length === 0 ) fail('testCases is empty. Check har files contain harCreatedUrl[' + harUrl +']'); return arr; }); // グループ名のリストを作っておく const groupNames = [...new Set(testCases.map(item => item.name))]; // グループごとにカスタムメトリクスを定義する (function(){ for( const gName of groupNames ){ globalThis[gName+'_trend_http_req_blocked'] = new Trend(' ' + gName + ' ... http_req_blocked', true); globalThis[gName+'_trend_http_req_connecting'] = new Trend(' ' + gName + ' ... http_req_connecting', true); globalThis[gName+'_trend_http_req_duration'] = new Trend(' ' + gName + ' ... http_req_duration', true); globalThis[gName+'_trend_http_req_receiving'] = new Trend(' ' + gName + ' ... http_req_receiving', true); globalThis[gName+'_trend_http_req_sending'] = new Trend(' ' + gName + ' ... http_req_sending', true); globalThis[gName+'_trend_http_req_waiting'] = new Trend(' ' + gName + ' ... http_req_waiting', true); } })(); // ここから k6 標準のコールバックメソッド開始 export function setup() { // for (const userPwdPair of csvUsers) { // console.debug(JSON.stringify(userPwdPair)); // } } // テスト本体 export default function() { // イテレーションごとに、VU で使うユーザIDをランダムに決める let user = (function(){ let userOption; if(exec.test.options.scenarios.default.stages) userOption = csvUsers[__VU % exec.test.options.scenarios.default.stages[0].target]; else userOption = csvUsers[__VU % exec.test.options.scenarios.default.vus]; return userOption; })(); console.debug(`VU ${__VU} on iteration ${__ITER} has user ID ${user.id}...`); // ユーザごとにログイン状態を保持するため cookie を設定する // 1度読んだらグローバル変数に保持しておき、別イテレーションでは再ログインしない // もしここでエラーになった場合、毎回NGとなり、 // 試験がかなり遅くなるので注意すること if (globalThis.cookies[user.id] === undefined) { const res = http.get(loginUrl); // 下記はxsrf対策のためのtokenを持つようなサイトの場合を想定しています // find path と リクエストパラメータ、メソッドタイプ は適宜調整してください const elem = res.html().find('input[name=_token]'); console.debug('globalThis.cookies[user.id] is undefined: ' + user.id); const formdata = { email: user.id, password: user.password, _token: elem.attr('value') }; const headers = { 'Referer': loginUrl }; const response = http.post(loginUrl, formdata, { headers: headers }); sleep(5); const checkRes = check(response, { 'status was 200': (r) => r.status == 200, }) || fail('status was not 200 ' + res.url ); console.debug('VU ' + __VU + ' response ' + JSON.stringify(response)); // cookie と token を VUごとに保持する // tokenのセレクタや、各 Set-Cookie の項目はケースバイケースなので注意すること globalThis.cookies[user.id] = http.cookieJar().cookiesForURL(targetUrl); globalThis.token[user.id] = response.html().find('input[name=_token]').attr('value'); if( response.headers.hasOwnProperty('Set-Cookie') ){ if( response.headers['Set-Cookie'].match(/XSRF-TOKEN=(.+?);/) ){ globalThis.xsrf_token[user.id] = response.headers['Set-Cookie'].match(/XSRF-TOKEN=(.+?);/)[1]; globalThis.cookies[user.id]['XSRF-TOKEN'] = [ globalThis.xsrf_token[user.id] ]; } if( response.headers['Set-Cookie'].match(/globalThis.fit_session=(.+?);/) ){ globalThis.fit_session[user.id] = response.headers['Set-Cookie'].match(/globalThis.fit_session=(.+?);/)[1]; globalThis.cookies[user.id]['globalThis.fit_session'] = [ globalThis.fit_session[user.id] ]; } console.debug('VU ' + __VU + ' set cookie. ' + JSON.stringify(globalThis.cookies[user.id])); } } else { console.debug('globalThis.cookies[user.id] is defined: ' + user.id); } ////////////////////////// // main // 実施するグループ名をランダムに決定する let gName = groupNames[ randomIntBetween(1, groupNames.length) -1 ]; // 以下、グループごとに実行する group(gName, function(){ // testCases is readonly, so deepcopy body and headers. let batch = [ ...new Set( testCases.filter(item=>item.name === gName).map(item => [ item.method, `${targetUrl}${item.url}`, JSON.parse(JSON.stringify(item.body)), JSON.parse(JSON.stringify(item.params)) ]) ) ]; // Harの tokenを実際のものに付け替える(リクエストヘッダ中のものも全部変える) batch.forEach( item => { item[1] = item[1].replace(/token%5B%5D=(.+?)&/g,'token%5B%5D=' + globalThis.token[user.id] + '&'); if( item[2] !== null ){ item[2]._token = globalThis.token[user.id]; } console.debug('VU ' + __VU + ' current cookie. ' + JSON.stringify(globalThis.cookies[user.id])); item[3].headers.cookie = JSON.stringify(globalThis.cookies[user.id]).replace(/\[|\||\]|"|\{|\}/g,'').replace(/,/g,'; ').replace(/:/g,'='); item[3].jar = globalThis.cookies[user.id]; item[3].headers.referer = targetUrl + '/' + gName; if( item[3].headers.hasOwnProperty('x-csrf-token') ){ console.debug('VU ' + __VU + 'x-csrf-token :' + globalThis.token[user.id] ); item[3].headers['x-csrf-token'] = [ globalThis.token[user.id] ]; } if( item[3].headers.hasOwnProperty('x-xsrf-token') ){ console.debug('VU ' + __VU + 'x-xsrf-token :' + globalThis.cookies[user.id]['XSRF-TOKEN'] ); item[3].headers['x-xsrf-token'] = [ decodeURIComponent(globalThis.cookies[user.id]['XSRF-TOKEN']) ] ; } console.debug('VU ' + __VU + ' header cookie. ' + item[3].headers.cookie); // バッチ実行 let res = http.batch([item])[0]; // アプリケーション固有のデバッグがしたい場合の例(ajax呼び出し内容の確認) // if( res.url.startsWith( targetUrl+'/' + gName + '?draw' ) ){ // console.debug('\u001b[32m' +'----------------------------------------' + '\u001b[0m' + JSON.stringify(res)); // sleep(30); // } check(res, { 'status was 200': (r) => { return r.status == 200; } }, { status: res.status, name: item[0], failure_reason: res.error, } ) || fail( 'VU ' + __VU + '(' + +')' + ' status was not 200 ' + res.url + res.body + res.header + ' item[0]: ' + JSON.stringify(item[0]) + ' item[1]: ' + JSON.stringify(item[1]) + ' item[2]: ' + JSON.stringify(item[2]) + ' item[3]: ' + JSON.stringify(item[3]) ); // Set-Cookie レスポンスヘッダがあればグローバル変数を更新する if( res.status == 200 && res.headers.hasOwnProperty('Set-Cookie') ){ if( res.headers['Set-Cookie'].match(/XSRF-TOKEN=(.+?);/) ){ console.debug('VU ' + __VU + ' Response has Set-Cookie header . ' + res.headers['Set-Cookie']); console.debug('VU ' + __VU + ' updated current cookie. ' + JSON.stringify(globalThis.cookies[user.id])); globalThis.xsrf_token[user.id] = res.headers['Set-Cookie'].match(/XSRF-TOKEN=(.+?);/)[1]; globalThis.cookies[user.id]['XSRF-TOKEN'] = [ globalThis.xsrf_token[user.id] ]; } if( res.headers['Set-Cookie'].match(/globalThis.fit_session=(.+?);/) ){ console.debug('VU ' + __VU + ' updated current cookie. ' + JSON.stringify(globalThis.cookies[user.id])); globalThis.fit_session[user.id] = res.headers['Set-Cookie'].match(/globalThis.fit_session=(.+?);/)[1]; globalThis.cookies[user.id]['globalThis.fit_session'] = [ globalThis.fit_session[user.id] ]; } } // リダイレクト先に token のINPUT要素があればグローバル変数を更新する if( res.html().find('input[name=_token]') !== undefined && res.html().find('input[name=_token]').attr('value') !== undefined){ console.debug('VU ' + __VU + ' globalThis.token( updated ) : ' + res.html().find('input[name=_token]').attr('value') ); globalThis.token[user.id] = res.html().find('input[name=_token]').attr('value'); } // グループごとにカスタムメトリクスとして集計する globalThis[gName+'_trend_http_req_blocked'].add(res.timings.blocked); globalThis[gName+'_trend_http_req_connecting'].add(res.timings.connecting); globalThis[gName+'_trend_http_req_duration'].add(res.timings.duration); globalThis[gName+'_trend_http_req_receiving'].add(res.timings.receiving); globalThis[gName+'_trend_http_req_sending'].add(res.timings.sending); globalThis[gName+'_trend_http_req_waiting'].add(res.timings.waiting); }); }); } |
次回は可視化ツール Grafana を利用して、リアルタイムにグループ別の実行状態を表示する方法について紹介する予定です。