本番環境のワークロードを検証環境にラクラク再現?!GoReplayでEC-CUBEのトラフィックをキャプチャ&リプレイしてみた

AWS Summit OnlineのPayPayさんのセッションで気になっていたGoReplayを触ってみました
2020.09.29

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

CX事業本部@大阪の岩田です。AWS Summit OnlineのPayPayさんのセッションで紹介されていたGoReplayがおもしろそうだったので軽く試してみました。

GoReplayとは

GoReplayはGoで開発されたOSSのネットワークモニタリングツールです。APサーバー上で実行してアプリケーションのトラフィックをキャプチャすることでシステムの利用傾向を分析したり、キャプチャしたトラフィックを別環境に流してボトルネックを分析したり、改修に伴う回帰テストを実行したり...といった用途に利用することが可能です。負荷テストであればJMeterなどのツールが有名ですが、jMeterで実際の利用状況に沿ったシナリオを記述するのはなかなか難しいと思います。例えば画面遷移の間隔だったり画面ごとのアクセス数の分布だったり、その点GoReplayは本番環境でキャプチャしたトラフィックをもとにテストが可能なので、より安心感のあるテストが実行できると言えます。

※本番環境でキャプチャを行うことによるパフォーマンスへの悪影響や、取り扱う対象データの機密性などは一旦無視して考えます。

公式サイトはこちらです。

https://goreplay.org/

基本的な使い方

GoReplayはUnixの思想に影響を受けており、様々なタイプの入力と出力をパイプでつないで動作させます。

例えばgor --input-raw :80 --output-stdoutと実行すると、GoReplayを実行したホストの80ポートへのトラフィックをキャプチャして入力とし、標準出力(stdout)に出力します。試しに手元のMacでGoReplayを実行しつつ適当なWebサーバーを起動、Chromeからアクセスすると以下のように出力されました。

sudo ./gor --input-raw :8080 --output-stdout

2020/09/28 19:23:08 [PPID 12731 and PID 12732] Version:1.2.0
1 8ce7a4aac2a54ef037a6628dce9aa07c1bb1919b 1601288592814373000 2811000
GET /index.php HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8

1 8ce7a4aac2a54ef037a6628dce9aa07c1bb1919b 1601288592814373000 2811000の部分はGoReplayが出力したメタデータです。

メタデータはスペース区切りで以下の4つの要素で構成されています

  • ペイロードのタイプ
    • 1: リクエスト
    • 2: オリジナルのレスポンス
    • 3: リプレイ時のレスポンス
  • リクエストID
    • リクエストを一意に識別可能なGoReplayが採番したID
  • タイムスタンプ
    • リクエスト/レスポンス開始時のUnixタイムスタンプ
  • レイテンシ
    • ペイドードのタイプがレスポンスの場合のみ付与される、リクエスト〜レスポンスまでの待機時間

他にも出力先として--output-httpオプションを指定してキャプチャしたトラフィックを別のサーバーに流したり、--output-fileオプションを指定してキャプチャ結果を保存したり、あるいは--input-fileを指定して以前キャプチャ&保存したトラフィックをリプレイしたり、といった使い方が可能です。

Middleware

このように便利なGoReplayですが、実際のシステムのトラフィックをキャプチャ&リプレイしただけでは正常に動作しないことがほとんどだと思います。CookieだったりCSRF対策のトークンだったり、こういった情報に関してはキャプチャした情報をそのままリプレイしてもアプリ側にエラーで弾かれてしまうでしょう。そこでGoReplayは利用者が自由にリクエスト&レスポンスを加工するためのMiddlewareという機構を提供しています。

Middlewareは以下の図のように動作します。

                   Original request      +--------------+
+-------------+----------STDIN---------->+              |
|  Gor input  |                          |  Middleware  |
+-------------+----------STDIN---------->+              |
                   Original response (1) +------+---+---+
                                                |   ^
+-------------+    Modified request             v   |
| Gor output  +<---------STDOUT-----------------+   |
+-----+-------+                                     |
      |                                             |
      |            Replayed response                |
      +------------------STDIN----------------->----+

https://github.com/buger/goreplay/wiki/Middlewareより引用

GoReplay実行時にMiddlewareとして実行可能ファイルを指定することで

  • リクエスト
  • オリジナルのレスポンス
  • リプレイ時のレスポンス

それぞれの処理前にGoReplayからMiddlewareの標準出力にペイロードが渡されます。Middlewareではペイロードの加工を行い、加工後のペイロードを標準出力に出力することで、加工後のペイロードをGoReplayの出力先に送信できます。

やってみる

それでは実際にGoReplayを使ったトラフィックのキャプチャ&再生に挑戦してみたいと思います。今回はECサイト構築用のOSSとして有名なEC-CUBEを利用してEC2上に簡単なデモサイトを構築し、非会員での購入処理を実行する過程をGoReplayでキャプチャ、その後キャプチャしてトラフィックを再生することで、自動的に購入処理が完了するまでをゴールに進めていきます。

なお、今回の検証には以下の環境を利用しました

  • GoReplay : v1.2.0
  • Node.js: v12.18.2
    • goreplay_middleware: 1.0.0
  • EC-CUBE: 2.17.1 ※(前職でEC-CUBE2系を触っていて挙動を理解しているので2系を選びました)
    • OS: Amazon Linux2
    • PHP: 7.3.21
    • Apache: 2.4.46

トラフィックのキャプチャ

まずはトラフィックをキャプチャします。EC2上で以下のコマンドを実行しキャプチャを開始し、ブラウザから購入フローを進めていきます。ちなみにGoReplayの導入ですがGo製のツールなのでリリースページからバイナリをDLして配置するだけでOKです。

$ gor --input-raw :80 --output-stdout --http-disallow-url "^*\.(jpg|gif|png|ico|css|js|js\.map)$" -http-allow-method GET -http-allow-method POST --output-file ec-cube-output.gor

オプションの意味は以下の通りです

  • --input-raw :80キャプチャ対象のインターフェイスとポート番号を指定します。今回は全インターフェイスの80ポートを指定しました。
  • --output-stdout キャプチャしたリクエスト&レスポンスの内容を標準出力に出力します。デバッグ用に便利です。
  • --http-disallow-url キャプチャ対象外とするURLを正規表現で指定します。画像などの静的コンテンツは無視するように指定します。
  • --http-allow-method キャプチャ対象とするHTTPメソッドを指定します。Apacheのinternal dummy connectionをキャプチャしたくないので、GETとPOSTのみキャプチャ対象とするように指定しました。
  • --output-file ec-cube-output.gor キャプチャ結果をファイルに出力するように指定します。

キャプチャ結果のファイルを覗いてみましょう

1 efb36fda909c5deb611f08823c071f42d4c9e97e 1601107748347661416 0
GET / HTTP/1.1
Host: ec2-xx-xxx-xxx-xxx.ap-northeast-1.compute.amazonaws.com
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: ja,en-US;q=0.9,en;q=0.8


???
1 56923857576e284271f8944f32458135672a9b39 1601107754106704337 0
GET /products/detail.php?product_id=1 HTTP/1.1
Host: ec2-xx-xxx-xxx-xxx.ap-northeast-1.compute.amazonaws.com
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://ec2-18-183-242-213.ap-northeast-1.compute.amazonaws.com/
Accept-Encoding: gzip, deflate
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Cookie: ECSESSID=xxxxxxxxxxxxx


???

終端文字の???がかなり独特ですね。

Middlewareの準備

早速キャプチャしたトラフィックを再生してみたいところですが、単純にトラフィックを再生しても期待通り動作しません。再生した一連トラフィックがセッションを維持できるようにCookieを設定したり、CSRF対策のチェックが通るようにトークンを書き換えたりする必要があります。こういった加工処理を行うためにMiddlewareを用意します。

最終的なMiddlewareの実装は以下のようになりました。Node.jsで利用可能なGoReplayのMiddleware用ライブラリgoreplay_middlewareを利用しています。※本来必要な諸々のチェック処理は省略しているので、もしGoReplayの利用を検討される場合は適宜修正を行って下さい。

#! /usr/bin/env node

const gor = require('goreplay_middleware');
gor.init();
const cookieName = 'ECSESSID';
const trnIdName = 'transactionid';
const uniqIdName = 'uniqid';
const cookiesMap = {};
const replCookies =[];
const trnIdRegex = new RegExp(`<input\\s+?type="hidden"\\s+?name="${trnIdName}"\\s?value="(?<trnId>\\w*)`);
const uniqIdRegex = new RegExp(`<input\\s+?type="hidden"\\s+?name="${uniqIdName}"\\s?value="(?<uniqId>\\w*)`);

gor.on('request', (req) => {
  const cookie = gor.httpHeader(req.http, 'Cookie');
  let orgCookie = null;

  gor.on('replay', req.ID, (repl) => {
    const setCookie = gor.httpHeader(repl.http, 'Set-Cookie');
    if (setCookie) {
      // 諸々のチェックは省略
      const sessCookie = setCookie.value.split(';').filter((val) => val.startsWith(cookieName))[0];
      if (sessCookie) {
        const sessId = sessCookie.split('=')[1];
        replCookies.push(sessId);
      }
    }
    // トランザクションIDをキャプチャ
    const trnId = trnIdRegex.exec(gor.httpBody(repl.http));
    if (trnId && orgCookie) {
      cookiesMap[orgCookie].trnId = trnId.groups.trnId;
    }

    // uniqidをキャプチャ
    const uniqId = uniqIdRegex.exec(gor.httpBody(repl.http));
    if (uniqId && orgCookie) {
      cookiesMap[orgCookie].uniqId = uniqId.groups.uniqId;
    }
    return repl;
  });

  if (!cookie) {
    return req;
  }

  // リクエストヘッダにCookieが含まれている場合はReplay時に発行されたCookieとのマッピングを生成しつつ、Cookieのvalueを上書く
  orgCookie = cookie.value.split(';').filter((val) => val.startsWith(cookieName))[0].split('=')[1];
  if (!cookiesMap[orgCookie]) {
    const replCookie = replCookies.pop();
    cookiesMap[orgCookie] = {
      newCookie: replCookie,
    };
  }
  req.http = gor.setHttpCookie(req.http, cookieName, cookiesMap[orgCookie].newCookie);

  // Replay時に発行されたトランザクションIDでリクエストボディ/クエリストリングを上書く
  if (cookiesMap[orgCookie].trnId) {
    switch (gor.httpMethod(req.http)) {
      case 'GET':
        req.http = gor.setHttpPathParam(req.http, trnIdName, cookiesMap[orgCookie].trnId);
        break;
      case 'POST':
        if (gor.httpBodyParam(req.http, trnIdName)) {
          req.http = gor.setHttpBodyParam(req.http, trnIdName, cookiesMap[orgCookie].trnId);
        }
        if (gor.httpBodyParam(req.http, uniqIdName)) {
          req.http = gor.setHttpBodyParam(req.http, uniqIdName, cookiesMap[orgCookie].uniqId);
        }
        break;
    }
  }

  return req;
});

詳細を解説していきます。

EC-CUBE2.17でリプレイを正常に動作させるには、Cookieに加えてトランザクションID(CSRF対策のトークン)、UniqID(一時受注情報ID)を適切にセットしてやる必要があります。cookiesMapという変数に以下のようなイメージでキャプチャ時のオリジナルのCookieをキーにリプレイ用の情報を保持し、リクエスト時に適宜上書きしてやります。

キャプチャ時のオリジナルのCookie リプレイ時のCookie リプレイ時のトランザクションID リプレイ時のUniqID
xxxxxx yyyy 1234567890 abcdefghij

まずgor.on('replay', req.ID, (repl) => {の部分でリプレイ時のレスポンスをMiddlewareで処理します。

ここでは後続のリクエスト処理で利用できるようにCookieの値やトランザクションID(EC-CUBEのCSRF対策トークン)等の情報をキャプチャし、cookiesMapという変数に保存します。

  gor.on('replay', req.ID, (repl) => {
    const setCookie = gor.httpHeader(repl.http, 'Set-Cookie');
    if (setCookie) {
      // 諸々のチェックは省略
      const sessCookie = setCookie.value.split(';').filter((val) => val.startsWith(cookieName))[0];
      if (sessCookie) {
        const sessId = sessCookie.split('=')[1];
        replCookies.push(sessId);
      }
    }
    // トランザクションIDをキャプチャ
    const trnId = trnIdRegex.exec(gor.httpBody(repl.http));
    if (trnId && orgCookie) {
      cookiesMap[orgCookie].trnId = trnId.groups.trnId;
    }

    // uniqidをキャプチャ
    const uniqId = uniqIdRegex.exec(gor.httpBody(repl.http));
    if (uniqId && orgCookie) {
      cookiesMap[orgCookie].uniqId = uniqId.groups.uniqId;
    }
    return repl;
  });

続いてリクエストの加工部分です。

gor.on('request', (req) => {
  const cookie = gor.httpHeader(req.http, 'Cookie');
  let orgCookie = null;

  gor.on('replay', req.ID, (repl) => {
    ...略
  });

  if (!cookie) {
    return req;
  }

  // リクエストヘッダにCookieが含まれている場合はReplay時に発行されたCookieとのマッピングを生成しつつ、Cookieのvalueを上書く
  orgCookie = cookie.value.split(';').filter((val) => val.startsWith(cookieName))[0].split('=')[1];
  if (!cookiesMap[orgCookie]) {
    const replCookie = replCookies.pop();
    cookiesMap[orgCookie] = {
      newCookie: replCookie,
    };
  }
  req.http = gor.setHttpCookie(req.http, cookieName, cookiesMap[orgCookie].newCookie);

  // Replay時に発行されたトランザクションIDでリクエストボディ/クエリストリングを上書く
  if (cookiesMap[orgCookie].trnId) {
    switch (gor.httpMethod(req.http)) {
      case 'GET':
        req.http = gor.setHttpPathParam(req.http, trnIdName, cookiesMap[orgCookie].trnId);
        break;
      case 'POST':
        if (gor.httpBodyParam(req.http, trnIdName)) {
          req.http = gor.setHttpBodyParam(req.http, trnIdName, cookiesMap[orgCookie].trnId);
        }
        if (gor.httpBodyParam(req.http, uniqIdName)) {
          req.http = gor.setHttpBodyParam(req.http, uniqIdName, cookiesMap[orgCookie].uniqId);
        }
        break;
    }
  }

  return req;
});

リクエストヘッダにCookieが含まれていた場合はcookiesMapに保存した各種の情報を利用してリクエストを上書いています。

※ログイン時のセッションID再生成(Cookieの値が更新される)を考慮すると、もっと色々な処理が必要になると思いますが、今回はそこまでやってません。

一番悩んだのが、CookieのValueを上書く部分です。流れ的に今回キャプチャしたトラフィックは

  • TOPページへアクセス(リクエストヘッダにはCookieなし、レスポンスヘッダでSet-Cookieされる)
  • 商品詳細画面に遷移(こっちはリクエストヘッダにCookieあり)

という流れになります。これらのリクエストは同一ユーザーによる操作なのですが、最初のTOPページへのアクセス時にはCookieがセットされていないため、キャプチャ結果を機械的に処理すると、同一ユーザーによる一連のリクエストであることが認識できません。苦肉の策としてリクエストヘッダにCookieが含まれるリクエストが初めてリプレイされた時点で、replCookiesという変数にプールしておいたCookieの値(リプレイ時にサーバーから発行されたセッションIDの)を割り当てて、以後のリクエストに付与しています。

この実装だとユーザーAとユーザーBの操作を同時にキャプチャ&リプレイした場合、ユーザーAのTOPページへのアクセスをリプレイした際に発行されたCookieがユーザーBの後続操作のリプレイ用の割り当てるといったことも起こりえると思いますが、いいやり方が思いつかず今回は妥協しました。正攻法としてはMiddlewareでオリジナルのレスポンスをキャプチャしつつ、GoReplayのリクエストIDとCookieの値をマッピングして...という実装になりますが、キャプチャ結果のファイルからリプレイする場合はオリジナルのレスポンスが取得できない(キャプチャ結果をファイルに出力した場合、レスポンスは出力されない)ため、今回試したかった「ファイルに保存したキャプチャ結果からワークロードをリプレイする」というシナリオではこういった実装ができませんでした。もしうまいやり方をご存知の方がいれば、是非教えて下さいm(_ _)m

トラフィックをリプレイしてみる

Middlewareの準備ができたらトラフィックをリプレイしてみましょう。npm install goreplay_middlewareでMiddleware用のライブラリを導入後に以下のコマンドでトラフィックをリプレイします。

$ gor --output-http-track-response  --input-file ec-cube-cap.gor  --output-http <EC2のFQDN> --middleware=./middleware.js  --output-stdout

リプレイ実行前は1件だった受注データが...

2件に増えました!リプレイ成功です。

まとめ

GoReplayの基本についてご紹介しました。今回は簡単に触ってみただけですが、実際の業務で利用しようと思うともっと色々な考慮事項がありそうです。

ぱっと思いつく考慮事項としては

  • 本番環境でキャプチャを動かすことによるパフォーマンスの低下が許容できるか?
  • システムが取り扱っているデータはキャプチャしても問題ないデータか?
  • リクエストをキャプチャするという特性からエンドツーエンドでHTTPSを利用するような構成ではキャプチャ&リプレイできない
  • リプレイ時のCookieやセッションIDをどのように管理/加工すればアプリが正常に動作するか

などでしょうか。

色々と考慮事項はありますが、GoReplay自体は非常におもしろいツールだと思いました。今回紹介した機能以外にもキャプチャ結果をElasticSearchに連携したり、Kafkaに連携したり...といった機能もあるようなのでまた時間を見つけて遊んでみたいと思います。

参考