この記事は LIFULLその2 Advent Calendar 2019 の4日目の記事です。
ベジータ「ビッグバンアタック!!!!!!!!!!!!😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡」
はい。以下は真面目な内容です
目的
作成したサービスに対して、自分でシナリオを組んで負荷試験を行いたい。
背景
負荷試験をする際に、システムによってはキャッシュの機構が存在するため、同じリクエストを何度も投げても期待する負荷試験とならないことがあります。
そのため、リクエストのパラメータを変えながら負荷試験を行いたいということは比較的存在するシナリオだと思います。
しかし過去にApache JMeterを用いて負荷試験を行った際は、BeanShellの書き方がよくわからず時間もなかったので、csvにパラメータを大量に用意し、csvファイルを食わせるという方法を取りました。
しかし、csvとしてパラメータを用意するのは地味に面倒だったので、もっといいやりかたを探していました。
そこで vegeta に出会いました。
まず
名前や、githubの画像的にイロモノ感がすごいんですが、ツールとしては使いやすく、またライブラリとして使用できるようになっており、かなりいい感じです。
もともとは自前でhttpに並列アクセスするようなものを作っていたんですが、レポート機能なども作るのが面倒だと思っていたところでこのツールを見つけ、シナリオの部分だけ自分で書けばいいのでは?ということを閃きました。
vegetaができること
READMEにほぼ書いてあるので、僕が活用した部分だけ紹介します。
vegetaは、cliツールとして提供されていて、 vegeta attack
で負荷をかけ、vegeta encode
で結果を出力したり、vegeta plot
で結果をグラフでプロットしたりすることができます。
実際の使い方はこんな感じです。
echo "GET http://localhost:8080/ping" | vegeta attack | vegeta encode --every 1s
vegeta attack
で、独自の形式で出力が行われるのですが、それを他のサブコマンドに渡すことで結果を出力したり、いい感じに活用することができます。
そこで、vegeta attack
と同じ出力をするようにして、レポートについてはvegeta
を使うことにしました
実際のコード
READMEにもある通り、リクエストの内容が一定であれば、ターゲットファイルと呼ばれる、リクエストの内容を書いたテキストファイルを読み込ませたり、NewStaticTargeter
を使えば可能なのですが、今回は動的に内容を変更したかったので、自前で作りました。
動的に変更させる箇所
シナリオを実装しやすくするために、シナリオが満たすべきインターフェースを定義しました。
// TargetGenerator を実装すると、Attackで実行できる
type TargetGenerator interface {
GenerateTarget(*vegeta.Target) error
}
// NewStaticTargeterを参考に、排他制御しておく
func generateTargeter(gen TargetGenerator) vegeta.Targeter {
var mu sync.Mutex
return func(tgt *vegeta.Target) error {
mu.Lock()
defer mu.Unlock()
return gen.GenerateTarget(tgt)
}
}
func attack(targeter vegeta.Targeter, freq, duration int, name string) <-chan *vegeta.Result {
rate := vegeta.Rate{Freq: freq, Per: time.Second}
dur := time.Duration(duration) * time.Second
attacker := vegeta.NewAttacker()
return attacker.Attack(targeter, rate, dur, name)
}
func main() {
url := flag.String("url", "http://localhost:8080", "target")
duration := flag.Int("duration", 10, "duration time(second)")
freq := flag.Int("freq", 1, "frequence per 1 second")
flag.Parse()
result := attack(generateTargeter(/* ここで TargetGenerator を満たした構造体を渡す */))
enc := vegeta.NewEncoder(os.Stdout)
for res := range result {
enc.Encode(res)
}
}
複数シナリオをマージする箇所
とりえあずこのコードでリクエストを送れる状態になりましたが、複数のシナリオがあった際に内容をまとめる必要があります。
まとめずにシナリオの数だけgoroutineを用意して出力すると、出力の内容が壊れてしまうので、一つのチャンネルにまとめることにします。
func merge(cs ...<-chan *vegeta.Result) <-chan *vegeta.Result {
out := make(chan *vegeta.Result)
var wg sync.WaitGroup
wg.Add(len(cs))
// channelの数だけgoroutineを起動して、一つにまとめる
for _, c := range cs {
c := c
go func() {
for v := range c {
out <- v
}
wg.Done()
}()
}
// すべてのchannelがクローズされるのを待つ
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
result := merge(
attack(generateTargeter(/* ここで TargetGenerator を満たした構造体を渡す */, *freq, *duration, "Attacker1"),
attack(generateTargeter(/* ここで TargetGenerator を満たした構造体を渡す */, *freq, *duration, "Attacker2"),
)
enc := vegeta.NewEncoder(os.Stdout)
for res := range result {
enc.Encode(res)
}
}
今はAttack時のオプション等が全部のシナリオで同じですが、そこをシナリオごとに変更したければ、適当にattackerを渡せばいいと思います。
シナリオの書き方
シナリオの書き方は普通のGoのプログラムです
var rs1Letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func RandString(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = rs1Letters[rand.Intn(len(rs1Letters))]
}
return string(b)
}
type randomScenario struct {
url string
}
func (s *randomScenario) GenerateTarget(tgt *vegeta.Target) error {
tgt.Method = http.MethodGet
tgt.URL = s.url
tgt.Body = []byte(RandomString(32) + "\n")
return nil
}
プログラム全体
package main
import (
"flag"
"math/rand"
"net/http"
"os"
"sync"
"time"
vegeta "github.com/tsenart/vegeta/lib"
)
var rs1Letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func RandString(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = rs1Letters[rand.Intn(len(rs1Letters))]
}
return string(b)
}
type randomScenario struct {
url string
method string
}
func (s *randomScenario) GenerateTarget(tgt *vegeta.Target) error {
tgt.Method = s.method
tgt.URL = s.url
tgt.Body = []byte(RandString(32) + "\n")
return nil
}
func merge(cs ...<-chan *vegeta.Result) <-chan *vegeta.Result {
out := make(chan *vegeta.Result)
var wg sync.WaitGroup
wg.Add(len(cs))
for _, c := range cs {
c := c
go func() {
for v := range c {
out <- v
}
wg.Done()
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}
// TargetGenerator を実装すると、Attackで実行できる
type TargetGenerator interface {
GenerateTarget(*vegeta.Target) error
}
func generateTargeter(gen TargetGenerator) vegeta.Targeter {
var mu sync.Mutex
return func(tgt *vegeta.Target) error {
mu.Lock()
defer mu.Unlock()
return gen.GenerateTarget(tgt)
}
}
func attack(targeter vegeta.Targeter, freq, duration int, name string) <-chan *vegeta.Result {
rate := vegeta.Rate{Freq: freq, Per: time.Second}
dur := time.Duration(duration) * time.Second
attacker := vegeta.NewAttacker()
return attacker.Attack(targeter, rate, dur, name)
}
func main() {
url := flag.String("url", "http://localhost:8080", "target")
duration := flag.Int("duration", 10, "duration time(second)")
freq := flag.Int("freq", 1, "frequence per 1 second")
flag.Parse()
result := merge(
attack(generateTargeter(&randomScenario{url: *url}), *freq, *duration, "Attacker1"),
attack(generateTargeter(&randomScenario{url: *url}), *freq, *duration, "Attacker2"),
)
enc := vegeta.NewEncoder(os.Stdout)
for res := range result {
enc.Encode(res)
}
}
まとめ
vegetaを使って並列にシナリオを実行したり、レポートを簡単に取得することができました。
非同期処理は普通ゲロ難しいイメージがありますが、Golangだと、比較的簡単に扱えていいですね。
(goroutineをどう起動するかや、値をどう受け渡しするかなど考えることはありますが)
※ 負荷試験を行う際はリクエスト先やリクエスト数などには十分注意してください。このプログラムを使用して問題が起きても責任は負いかねます。
個人的に、Attackerの名前を、"Attacker1"とか"Attacker2"とかにしてるの、中二病っぽくてすごい好きです。