From c88c8c4fb59319ccc3a09b74c834b4ea951069bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Senart?= Date: Tue, 13 Aug 2013 18:51:00 +0200 Subject: [PATCH] Working Vegeta --- README.md | 67 ++++++++++++++++++++++++++++++++++-- client.go | 73 +++++++++++++++++++++++---------------- main.go | 96 +++++++++++++++++++++++++++++++--------------------- reporters.go | 76 +++++++++++++++++++++++++++++++++++++++++ targets.go | 61 +++++++++++++++++++++++++++++++++ urls.go | 38 --------------------- 6 files changed, 301 insertions(+), 110 deletions(-) create mode 100644 reporters.go create mode 100644 targets.go delete mode 100644 urls.go diff --git a/README.md b/README.md index b4f5790..427f956 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,65 @@ -vegeta -====== +# Vegeta + +Vegeta is a versatile HTTP load testing tool built out of need to drill +HTTP services with a relatively constant request rate. + +## Install +You need go installed and `GOBIN` in your `PATH`. Once that is done, run the +command: +```shell +$ go install github.com/tsenart/vegeta +``` + +## Usage +```shell +$ vegeta -h +Usage of vegeta: + -duration=10s: Duration of the test + -ordering="random": sequential or random + -rate=50: Requests per second + -reporter="text": Reporter to use [text] + -targets="targets.txt": Targets file +``` + +#### -duration +Specifies the amount of time to issue request to the targets. +The internal concurrency structure's setup has this value as a variable. +The actual run time of the test can be longer than specified due to the +responses delay. + +#### -ordering +Specifies the ordering of target attack. The default is `random` and +it will randomly pick one of the targets per request without ever choosing +that target again. +The other option is `sequential` and it does what you would expect it to +do. + +#### -rate +Specifies the requests per second rate to issue requests with against +the targets + +#### -reporter +Specifies the reporting type to display the results with. +The default is a text report printed to stdout. + +#### -targets +Specifies the attack targets in a line sepated file. The format should +be as follows: +``` +GET http://goku:9090/path/to/dragon?item=balls +HEAD http://goku:9090/path/to/success +... +``` + + +## TODO +* Add timeout options to the requests +* Graphical reports +* Test + +## Licence +See the `LICENSE` file. + + + -HTTP load testing tool diff --git a/client.go b/client.go index 5c79c38..942e0aa 100644 --- a/client.go +++ b/client.go @@ -1,49 +1,62 @@ package main import ( + "errors" + "io/ioutil" "net/http" "time" ) -// Client is an http.Client with rate limiting and time series instrumentation. +// Client is an http.Client with rate limiting +// TODO: Add timeouts type Client struct { - cli http.Client - qps uint - codes []uint64 - timings []time.Duration - bytesOut []int64 - bytesIn []int64 + http.Client + rate uint } -func NewClient(qps uint) *Client { - return &Client{ - cli: http.Client{}, - qps: qps, - codes: []uint64{}, - timings: []time.Duration{}, - bytesOut: []int64{}, - bytesIn: []int64{}, - } +// Response represents the metrics we want out of an http.Response +type Response struct { + code uint64 + timestamp time.Time + timing time.Duration + bytesOut uint64 + bytesIn uint64 + err error +} + +// NewClient returns an initialized Client +func NewClient(rate uint) *Client { + return &Client{http.Client{}, rate} } // Drill loops over the passed reqs channel and executes each request. // It is throttled to the qps specified in the initializer -func (c *Client) Drill(reqs chan *http.Request) { - throttle := time.Tick(time.Duration(1e9 / c.qps)) - for req := range reqs { - <-throttle - go c.Do(req) +func (c *Client) Drill(reqs chan *http.Request, res chan *Response) { + for _ = range time.Tick(time.Duration(1e9 / c.rate)) { + if req, ok := <-reqs; !ok { + break + } else { + go c.Do(req, res) + } } } -// Do executes the passed http.Request and saves some metrics -// (timings, bytesIn, bytesOut, codes) -func (c *Client) Do(req *http.Request) (*http.Response, error) { +// Do executes the passed http.Request and puts a generated *Response into res. +func (c *Client) Do(req *http.Request, res chan *Response) { began := time.Now() - resp, err := c.cli.Do(req) - c.timings = append(c.timings, time.Since(began)) - c.bytesOut = append(c.bytesOut, req.ContentLength) - c.bytesIn = append(c.bytesIn, resp.ContentLength) - c.codes[resp.StatusCode]++ - return resp, err + r, err := c.Client.Do(req) + resp := &Response{ + timestamp: began, + timing: time.Since(began), + bytesOut: uint64(req.ContentLength), + err: err, + } + if err == nil { + resp.bytesIn, resp.code = uint64(r.ContentLength), uint64(r.StatusCode) + if body, err := ioutil.ReadAll(r.Body); err != nil && resp.code < 200 || resp.code >= 300 { + resp.err = errors.New(string(body)) + } + } + + res <- resp } diff --git a/main.go b/main.go index 486db6f..c3b335c 100644 --- a/main.go +++ b/main.go @@ -2,72 +2,90 @@ package main import ( "flag" - "fmt" "log" "math" "math/rand" "net/http" + "os" "time" ) -// -qps=10 -urls=xxx.txt -mode={sequential,random} {-requests=N,-duration=T} - func main() { var ( // Flags - qps = flag.Uint("qps", 50, "Queries Per Second") - urlsFile = flag.String("urls", "urls.txt", "URLs file") - mode = flag.String("mode", "random", "sequential or random") - requests = flag.Uint("requests", 5000, "Number of requests to do") - duration = flag.Duration("duration", 10*time.Second, "Maximum duration of execution") + rate = flag.Uint("rate", 50, "Requests per second") + targetsf = flag.String("targets", "targets.txt", "Targets file") + ordering = flag.String("ordering", "random", "Attack ordering [sequential, random]") + duration = flag.Duration("duration", 10*time.Second, "Duration of the test") + reporter = flag.String("reporter", "text", "Reporter to use [text]") ) flag.Parse() - // Validate QPS argument - if *qps == 0 { - log.Fatal("qps can't be zero") - } - // Magic formula that assumes each client can - // sustain 500 QPS under normal circumstances - clients := make([]*Client, int(math.Ceil(float64(*qps)/500.0))) - qpsClient := *qps / uint(len(clients)) - for i := 0; i < len(clients); i++ { - clients[i] = NewClient(qpsClient) + if flag.NFlag() == 0 { + flag.Usage() + return } - // Parse URLs file - urls, err := NewURLsFromFile(*urlsFile) + // Validate rate argument + if *rate == 0 { + log.Fatal("rate can't be zero") + } + // Magic formula that assumes each client can + // sustain 200 RPS under normal circumstances + clients := make([]*Client, int(math.Ceil(float64(*rate)/200.0))) + ratePerClient := *rate / uint(len(clients)) + for i := 0; i < len(clients); i++ { + clients[i] = NewClient(ratePerClient) + } + + // Parse targets file + targets, err := NewTargetsFromFile(*targetsf) if err != nil { log.Fatal(err) } - // Parse mode argument + // Parse ordering argument random := false - if *mode == "random" { + if *ordering == "random" { rand.Seed(time.Now().UnixNano()) random = true - } else if *mode != "sequential" { - log.Fatal("Unknown mode %s", *mode) + } else if *ordering != "sequential" { + log.Fatalf("Unknown ordering %s", *ordering) } - // Parse number of requests and duration - if *requests == 0 && *duration == 0 { - log.Fatal("Neither requests or duration was provided") + // Parse duration + if *duration == 0 { + log.Fatal("Duration provided is invalid") } - fmt.Printf("Hitting %d URLs in %s mode for %s with %d requests and %d clients.", - len(urls), *mode, duration.String(), *requests, len(clients)) - - reqs := make(chan *http.Request, *requests) + hits := make(chan *http.Request, *rate*uint((*duration).Seconds())) + for i, idxs := 0, targets.Iter(random); i < cap(hits); i++ { + hits <- targets[idxs[i%len(idxs)]] + } + // Attack! + responses := make(chan *Response, cap(hits)) for _, client := range clients { - go client.Drill(reqs) + go client.Drill(hits, responses) } - for _, index := range urls.Iter(random) { - url := urls[index] - req, err := http.NewRequest("GET", url.String(), nil) - if err != nil { - log.Fatal("Bad request: %s", err) - } - reqs <- req + log.Printf("Vegeta is attacking ") + log.Printf("%d targets in %s order for %s with %d clients.\n", len(targets), *ordering, duration, len(clients)) + + var rep Reporter + switch *reporter { + case "text": + rep = NewTextReporter(len(responses)) + default: + log.Println("reporter provided is not supported. using text") + rep = NewTextReporter(len(responses)) + } + // Wait for all requests to finish + for i := 0; i < cap(responses); i++ { + rep.Add(<-responses) + } + close(hits) + close(responses) + + if rep.Report(os.Stdout) != nil { + log.Fatal("Failed to report!") } } diff --git a/reporters.go b/reporters.go new file mode 100644 index 0000000..aee59de --- /dev/null +++ b/reporters.go @@ -0,0 +1,76 @@ +package main + +import ( + "fmt" + "io" + "time" +) + +// Reporter represents any reporter of the results of the test +type Reporter interface { + Add(res *Response) + Report(io.Writer) error +} + +type TextReporter struct { + responses []*Response +} + +// NewTextReporter initializes a TextReporter with n responses +func NewTextReporter(n int) *TextReporter { + return &TextReporter{responses: make([]*Response, n)} +} + +// Add adds a response to be used in the report +// Order of arrival is not relevant for this reporter +func (r *TextReporter) Add(res *Response) { + r.responses = append(r.responses, res) +} + +// Report computes and writes the report to out. +// It returns an error in case of failure. +func (r *TextReporter) Report(out io.Writer) error { + totalRequests := len(r.responses) + totalTime := time.Duration(0) + totalBytesOut := uint64(0) + totalBytesIn := uint64(0) + totalSuccess := uint64(0) + histogram := map[uint64]uint64{} + errors := map[string]struct{}{} + + for _, res := range r.responses { + histogram[res.code]++ + totalTime += res.timing + totalBytesOut += res.bytesOut + totalBytesIn += res.bytesIn + if res.code >= 200 && res.code < 300 { + totalSuccess++ + } + if res.err != nil { + errors[res.err.Error()] = struct{}{} + } + } + + avgTime := time.Duration(float64(totalTime) / float64(totalRequests)) + avgBytesOut := float64(totalBytesOut) / float64(totalRequests) + avgBytesIn := float64(totalBytesIn) / float64(totalRequests) + avgSuccess := float64(totalSuccess) / float64(totalRequests) + + buf := "" + buf += fmt.Sprintln("Results: ") + buf += fmt.Sprintf("Time (avg): %s\n", avgTime) + buf += fmt.Sprintf("Bytes out (avg): %f\n", avgBytesOut) + buf += fmt.Sprintf("Bytes in (avg): %f\n", avgBytesIn) + buf += fmt.Sprintf("Success ratio: %f\n", avgSuccess) + buf += fmt.Sprintf("Requests: %d\n", totalRequests) + buf += fmt.Sprintln("\nStatus codes histogram:") + for code, count := range histogram { + buf += fmt.Sprintf("%3d\t%d\n", code, count) + } + buf += fmt.Sprintln("\nError set:") + for err, _ := range errors { + buf += fmt.Sprintln(err) + } + _, err := out.Write([]byte(buf)) + return err +} diff --git a/targets.go b/targets.go new file mode 100644 index 0000000..6fc3e7e --- /dev/null +++ b/targets.go @@ -0,0 +1,61 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "math/rand" + "net/http" + "os" + "strings" +) + +type Targets []*http.Request + +func NewTargetsFromFile(filename string) (Targets, error) { + file, err := os.Open(filename) + if err != nil { + return Targets{}, err + } + defer file.Close() + return NewTargets(file) +} + +func NewTargets(source io.Reader) (Targets, error) { + reader := bufio.NewReader(source) + targets := make([]*http.Request, 0) + + for { + line, err := reader.ReadString('\n') + if err != nil && err == io.EOF { + break + } else if err != nil { + return targets, err + } + if line = strings.TrimSpace(line); line == "" { // Empty line + continue + } + parts := strings.SplitN(line, " ", 2) + if len(parts) != 2 { + return targets, fmt.Errorf("Invalid request format: `%s`", line) + } + // Build request + req, err := http.NewRequest(parts[0], parts[1], nil) + if err != nil { + return targets, fmt.Errorf("Failed to build request: %s", err) + } + targets = append(targets, req) + } + return targets, nil +} + +func (t Targets) Iter(random bool) []int { + if random { + return rand.Perm(len(t)) + } + iter := make([]int, len(t)) + for i := 0; i < len(t); i++ { + iter[i] = i + } + return iter +} diff --git a/urls.go b/urls.go deleted file mode 100644 index f1207b7..0000000 --- a/urls.go +++ /dev/null @@ -1,38 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "io/ioutil" - "math/rand" - "net/url" -) - -type URLs []*url.URL - -func NewURLsFromFile(filename string) (urls URLs, err error) { - lines, err := ioutil.ReadFile(filename) - if err != nil { - return URLs{}, err - } - - for _, line := range bytes.Split(lines, []byte("\n")) { - uri, err := url.Parse(string(line)) - if err != nil { - return URLs{}, fmt.Errorf("Failed to parse URI (%s): %s", line, err) - } - urls = append(urls, uri) - } - return urls, nil -} - -func (urls URLs) Iter(random bool) []int { - if random { - return rand.Perm(len(urls)) - } - iter := make([]int, len(urls)) - for i := 0; i < len(urls); i++ { - iter[i] = i - } - return iter -}