こんにちは!プラットフォームチームの池田です。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: 開始ボタンを押してレッスンルームに入る
このとき、会員基盤サービスに関して、以下のようなやり取りが行われます。(下記システム構成も実際の弊社システムに対して正確ではありません。)
ログイン処理
スケジュール取得
単一アカウントのみのテストの場合
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
テストシナリオを実環境にできる限り近づけるべく、テスト環境でバラバラな設定を持つユーザーらを事前に準備し負荷試験に利用するという前提です。
ポイントとなるのは、負荷時にログイン処理から返る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フォーマットにしていますが、プロッティングとして可視化できる仕組みもあります。
おわりに
複雑になってしまったリクエスト間のやり取りと終了時の制御は上手く抽象化すればプラグイン的な物が提供できそうだなと感じました。また次の機会に取り組みたいです。
参考