こんにちは!プラットフォームチームの池田です。2回目の投稿になります。
元々ハンバーガ ーが好きで社内でもそれで自分を売り出していたのですが、つい最近とんでもない対抗馬と出会ってしまいました。そう、バインミー です(今更?)。
時代と文化が生んだ奇跡。私はハマってしまいました。バインミータベタイ
閑話休題 、直近で負荷試験 を実施する機会があり、それに関するトピックを紹介します。
はじめに
私が所属するチーム(プラットフォームチーム)ではGo言語をメインに開発していることもあり、負荷試験 においてもGoでカスタマイズできるツールを探していました。特に気になったのがGo言語で開発されているOSS のVegeta のライブラリでの利用方法でして、今回試してみました。
Vegetaライブラリを使った動的なテストシナリオの方法を紹介した記事はあまり見受けられず、ベストな選択肢では無いと承知しながらも、この記事を書いてみようと思いました。
【目次】
安定した性能を発揮する多機能なHTTP負荷テストツールです。
GitHub のREADMEでは名前の由来である"彼"の姿を見ることできます。
Vegetaツール自体は古き良きApache HTTP server benchmarking tool (ab) と同様のCLI ベースのツールです。
abと比較して、HTTP/2対応、分散攻撃機 能、結果プロッティング機能などの付加的な特徴があります。(本記事ではこれらの機能には言及しません。)
Vegetaライブラリを利用するメリット
Vegetaライブラリで負荷試験 シナリオを作るメリット は以下としています。
Goをメインに開発しているチームにとってスムーズに作成と保守ができる
Goの特徴であるシンプルなクロスコン パイルにより、どの環境にもシングルバイナリとして簡単に乗せることができる
逆にデメリット は以下になります。
今回のシナリオの想定
プラットフォームチームが保持するマイクロサービスの1つである会員基盤サービス(REST API ) に対する負荷試験 という前提で進めます。
本記事では、弊社サービスにおいてシンプルだけれどもアクセスピークがクリティカルなシナリオとして、以下のユーザーストーリーを想定します。(実際の弊社サービスに対して正確な表現ではありません。)
Step1: 英会話レッスンが始まる直前の時刻にユーザーがログインする
Step2: レッスン予定を閲覧できるページにアクセスする(レッスン開始ボタンがある)
Step3: 開始ボタンを押してレッスンルームに入る
このとき、会員基盤サービスに関して、以下のようなやり取りが行われます。(下記システム構成も実際の弊社システムに対して正確ではありません。)
ログイン処理
login
スケジュール取得
schedule
単一アカウントのみのテストの場合
1つの会員アカウントを使って負荷をかける場合、VegetaのCLI を利用すれば簡単にテストが可能です。
以下のログインエンドポイント用のJSON ペイロード ファイル(login.json )と対象エンドポイントを記載したファイル(target.txt)を用意します。
予定取得エンドポイント(/schedule
)にてJWTトーク ンは予め用意したものを利用します。
login.json
{
"email ": "test_user@example.com ",
"password ": "Password "
}
target.txt
(※ この記事ではあえてローカル環境をターゲットにしています。)
POST http://localhost:8090/login
Content-Type: application/json
@login.json
GET http://localhost:8090/schedule
Content-Type: application/json
Authorization: Bearer xxxxx.yyyyy.zzzzz
負荷を以下のようなオプションでかけます。このとき、対象アカウントにてログインとスケジュール取得リクエス トを交互に繰り返します。
vegeta attack -targets=target.txt -rate=50/s -duration 600s | \
vegeta report -type=json | \
jq
テストシナリオを実環境にできる限り近づけるべく、テスト環境でバラバラな設定を持つユーザーらを事前に準備し負荷試験 に利用するという前提です。
multi
ポイントとなるのは、負荷時にログイン処理から返るJWTトーク ンを動的に取得し、予定取得エンドポイントのリクエス トにてAuthorizationヘッダーへ取得したトーク ンを入れる処理を実装する必要があるということです。
プログラム
1000アカウント分のクレデンシャル情報を持つCSV ファイルを事前に攻撃サーバに用意します。
test1@example.com,Passw0rdDAZE!
test2@example.com,Passw0rdNANOKA?
.
.
.
test1000@example.com,Passw0rdKAMONE&
Goのソースコード は下記になります。Vegetaライブラリのドキュメントとツール側の実装 を参考にしながら作成しました。ちょっと長めなので折りたたんでいます。
package main
import (
"encoding/json"
"flag"
"os"
"sync"
"time"
vegeta "github.com/tsenart/vegeta/lib"
)
type TargeterType int
const (
Login TargeterType = iota
Schedule
)
type MetricsMutex struct {
sync.Mutex
vegeta.Metrics
}
type ProductTargeter struct {
ttype TargeterType
targeter vegeta.Targeter
}
func main() {
var (
rate = flag.Int("rate" , 10 , "Number of requests per time unit [0 = infinity] (default 10/1s)" )
duration = flag.Int("duration" , 10 , "Duration of the test [0 = forever]" )
)
flag.Parse()
tokenChn := make (chan string )
targeters := []*ProductTargeter{
{
Login,
NewLoginTargeter("login_user.csv" ),
},
{
Schedule,
NewScheduleTargeter(tokenChn),
},
}
rt := vegeta.Rate{Freq: *rate / len (targeters), Per: time.Second}
dur := time.Duration(*duration) * time.Second
var metrics MetricsMutex
var wg sync.WaitGroup
for _, t := range targeters {
wg.Add(1 )
t := t
go func () {
defer wg.Done()
for res := range vegeta.NewAttacker().Attack(t.targeter, rt, dur, string (t.ttype)) {
if res == nil || res.Error == vegeta.ErrNoTargets.Error() {
continue
}
switch t.ttype {
case Login:
go PassToken(res.Body, tokenChn)
}
metrics.Lock()
metrics.Add(res)
metrics.Unlock()
}
}()
}
wg.Wait()
metrics.Close()
b, err := json.Marshal(metrics)
if err != nil {
panic (err)
}
os.Stdout.Write(b)
}
package main
import (
"encoding/csv"
"encoding/json"
"io"
"net/http"
"os"
"sync"
vegeta "github.com/tsenart/vegeta/lib"
)
type LoginReqBody struct {
Email string `json:"email"`
Password string `json:"password"`
}
type LoginTarget struct {
Method string
URL string
Header http.Header
Body interface {}
}
var (
usersNum int
loginCount int
countMutex sync.Mutex
)
func LoadLoginUsers(filePath string ) (targets []*LoginTarget, err error ) {
file, err := os.Open(filePath)
if err != nil {
return
}
defer file.Close()
reader := csv.NewReader(file)
var line []string
for {
line, err = reader.Read()
if err == io.EOF {
break
}
if err != nil {
return nil , err
}
targets = append (targets, &LoginTarget{
Method: http.MethodPost,
URL: "http://localhost:8090/login" ,
Header: map [string ][]string {
"Content-Type" : {"application/json" },
},
Body: LoginReqBody{
Email: line[0 ],
Password: line[1 ],
},
})
}
return targets, nil
}
func NewLoginTargeter(userFilePath string ) vegeta.Targeter {
targets, err := LoadLoginUsers(userFilePath)
if err != nil {
panic (err)
}
usersNum = len (targets)
var (
index int
mu sync.Mutex
)
return func (tgt *vegeta.Target) error {
mu.Lock()
defer mu.Unlock()
defer func () {
index++
}()
if index >= len (targets) {
return vegeta.ErrNoTargets
}
target := targets[index]
tgt.Method = target.Method
tgt.URL = target.URL
tgt.Header = target.Header
if target.Body != nil {
if body, err := json.Marshal(target.Body); err != nil {
return err
} else {
tgt.Body = body
}
}
return nil
}
}
func PassToken(resBody []byte , tokenChn chan <- string ) {
if len (resBody) == 0 {
return
}
type LoginRes struct {
JwtToken string `json:"jwt_token"`
}
var loginRes LoginRes
json.Unmarshal(resBody, &loginRes)
tokenChn <- loginRes.JwtToken
countMutex.Lock()
defer countMutex.Unlock()
loginCount++
if loginCount == usersNum {
close (tokenChn)
}
}
package main
import (
"fmt"
"net/http"
vegeta "github.com/tsenart/vegeta/lib"
)
type ScheduleTargeter struct {
Method string
URL string
Header http.Header
}
func NewScheduleTargeter(tokenChn <-chan string ) vegeta.Targeter {
return func (tgt *vegeta.Target) error {
target := ScheduleTargeter{
Method: http.MethodGet,
URL: "http://localhost:8090/schedule" ,
Header: map [string ][]string {
"Content-Type" : {"application/json" },
},
}
token, ok := <-tokenChn
if !ok {
return vegeta.ErrNoTargets
}
tgt.Method = target.Method
tgt.URL = target.URL
tgt.Header = target.Header
if val := token; ok {
tgt.Header["Authorization" ] = []string {fmt.Sprintf("Bearer %s" , val)}
}
return nil
}
}
主にJWTトーク ンの受け渡し部分と1000アカウント終了時に攻撃を停止する制御の部分でコードが少々複雑になってしまいましたがこれで動きます。
負荷をかける!
【はじめに注意】本記事用にダミーな負荷対象HTTPサーバをローカルに立てて検証していますので、下記の結果は弊社サービスの実際のシステムとは全く関係の無い結果であることにご留意ください。
上記のプログラムを動かし負荷をかけると結果が得られます。今回は2つのエンドポイントを混ぜ合わせた1シナリオとしての結果が得られるようにしています。
$ go build -o attack
$ ./attack -rate=50 -duration=60 | jq
{
"latencies": {
"total": 808174235516,
"mean": 404087117,
"50th": 410460112,
"95th": 457702034,
"99th": 470219198,
"max": 509059811
},
"bytes_in": {
"total": 64000,
"mean": 32
},
"bytes_out": {
"total": 52893,
"mean": 26.4465
},
"earliest": "2020-mm-ddTHH:MM:SS.097961559+09:00",
"latest": "2020-mm-ddTHH:MM:SS.097961559+09:00",
"end": "2020-mm-ddTHH:MM:SS.097961559+09:00",
"duration": 39957268820,
"wait": 458157781,
"requests": 2000,
"rate": 50.053471097076844,
"throughput": 49.4860544154324,
"success": 1,
"status_codes": {
"200": 2000
},
"errors": []
}
攻撃結果を得ることができました。上記の場合は出力をJSON フォーマットにしていますが、プロッティングとして可視化できる仕組みもあります。
おわりに
複雑になってしまったリクエス ト間のやり取りと終了時の制御は上手く抽象化すればプラグイン 的な物が提供できそうだなと感じました。また次の機会に取り組みたいです。
参考