This tutorial walks through the steps to create a DHCPv6 load tester. Due to nature of the DHCP protocol we cannot easily test standard queries made by users, but instead we may simulate a clients sending queries that are forwarded by a Relay Agent. We won't cover setting up a DHCPv6 server and from this point we will assume you have a working server which can properly answer RELAY queries. If you already know how to Go's DHCP libraries, skip to the "Load Testing" section to get started with Bender and DHCPv6.
You will need to install and configure Go by following the instructions on the
Getting Started page. Then follow the instructions on the
How To Write Go Code page, particularly for setting up your
workspace and the GOPATH
environment variable, which we will use throughout this tutorial.
Next we need the DHCP library for Go, which you can fetch using go get
as:
cd $GOPATH
go get -u github.com/insomniacslk/dhcp
Finally you will need the latest version of Bender, which you can get by running:
go get -u github.com/pinterest/bender
The next few sections walk through the various parts of the load tester. If you are in a hurry skip to the section "Final Load Tester Program" and just follow the instructions from there.
The first thing we need is a function to generate intervals (in nanoseconds) between executing requests. The Bender library comes with some predefined intervals, including a uniform distribution (always wait the same amount of time between each request) and an exponential distribution. In this case we will use the exponential distribution, which means our server will experience load as generated by a Poisson process, which is fairly typical of server workloads on the Internet (with the usual caveats that every service is a special snowflake, etc, etc). We get the interval function with this code:
intervals := bender.ExponentialIntervalGenerator(qps)
Where qps
is our desired throughput measured in queries per second. It is also the reciprocal of
the mean value of the exponential distribution used to generate the request arrival times (see the
wikipedia article above). In practice this means you will see an average QPS that fluctuates around
the target QPS (with less fluctuation as you increase the time interval over which you are
averaging).
The second thing we need is a channel of requests to send to the DHCPv6 server. When an interval has been generated and Bender is ready to send the request, it pulls the next request from this channel and spawns a goroutine to send the request to the server. This function creates a simple synthetic request generator. We need to generate a SOLICIT requests and executor will take care of wrapping them into a RELAY packet:
// HardwareAddr should return a random hardware address (we'll keep it simple)
func HardwareAddr() (net.HardwareAddr, error) {
return net.ParseMAC("01:23:45:67:89:ab")
}
// SyntheticDHCPv6Requests generates up to n synthetic DHCPv6 requests
func SyntheticDHCPv6Requests(n int) chan interface{} {
c := make(chan interface{}, n)
go func() {
for i := 0; i < n; i++ {
// Generate "random" hardware address
hw, err := HardwareAddr()
if err != nil {
log.Println("Error generating MAC:", err)
continue
}
// Genereate solicit for the generated MAC address
duid := dhcpv6.Duid{
Type: dhcpv6.DUID_LLT,
HwType: iana.HwTypeEthernet,
Time: dhcpv6.GetTime(),
LinkLayerAddr: hw,
}
msg, err := dhcpv6.NewSolicitWithCID(duid)
if err != nil {
log.Println("Error generating SOLICIT:", err)
continue
}
c <- msg
}
close(c)
}()
return c
}
The next thing we need is a request executor, which takes the requests generated above and sends them to the service. We will use a helper function from Bender's dhcpv6 library to do most of the work (connection management, error handling, etc), so all we have to do is write code to verify the request. The executor will perform 4-way handshake (SOLICIT, ADVERTISE, REQUEST, RESPONSE) and the validator will only be executed for a response after successfully processing all of them.
func validator(req, res dhcpv6.DHCPv6) error {
return nil
}
exec := dhcpv6.CreateExecutor(client, validator)
This validator accepts all requests which succeeded.
The last thing we need is a channel that will output events as the load tester runs. This will let us listen to the load testers progress and record stats. We want this channel to be buffered so that we can run somewhat independently of the load test without slowing it down:
recorder := make(chan interface{}, 128)
The LoadTestThroughput
function returns a channel on which it will send events for things like
the start of the load test, how long it waits between requests, how much overage it is currently
experiencing, and when requests start and end, how long they took and whether or not they had
errors. That raw event stream makes it possible to analyze the results of a load test. Bender has
a couple of simple "recorders" that provide basic functionality for result analysis and all of
which use the Record
function:
NewLoggingRecorder
creates a recorder that takes alog.Logger
and outputs each event to it in a well-defined format.NewHistogramRecorder
creates a recorder that manages a histogram of latencies from requests and error counts.
You can combine recorders using the Record
function, so you can both log events and manage a
histogram using code like this:
l := log.New(os.Stdout, "", log.LstdFlags)
h := hist.NewHistogram(60000, 10000000)
bender.Record(recorder, bender.NewLoggingRecorder(l), bender.NewHistogramRecorder(h))
The histogram takes two arguments: the number of buckets and a scaling factor for times. In this case we are going to record times in milliseconds and allow 60,000 buckets for times up to one minute. The scaling factor is 1,000,000 which converts from nanoseconds (the timer values) to milliseconds.
It is relatively easy to build recorders, or to just process the events from the channel yourself, see the Bender documentation for more details on what events can be sent, and what data they contain.
Create a directory for the load tester:
mkdir -p src/$PKG/hellobender
Then create a file named main.go
in that directory and add these lines to it:
package main
import (
"flag"
"fmt"
"log"
"net"
"os"
"time"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/insomniacslk/dhcp/dhcpv6/async"
"github.com/insomniacslk/dhcp/iana"
"github.com/pinterest/bender"
bdhcpv6 "github.com/pinterest/bender/dhcpv6"
"github.com/pinterest/bender/hist"
)
// HardwareAddr should return a random hardware address (we'll keep it simple)
func HardwareAddr() (net.HardwareAddr, error) {
return net.ParseMAC("01:23:45:67:89:ab")
}
// SyntheticDHCPv6Requests generates up to n synthetic DHCPv6 requests
func SyntheticDHCPv6Requests(n int) chan interface{} {
c := make(chan interface{}, n)
go func() {
for i := 0; i < n; i++ {
// Generate "random" hardware address
hw, err := HardwareAddr()
if err != nil {
log.Println("Error generating MAC:", err)
continue
}
// Genereate solicit for the generated MAC address
duid := dhcpv6.Duid{
Type: dhcpv6.DUID_LLT,
HwType: iana.HwTypeEthernet,
Time: dhcpv6.GetTime(),
LinkLayerAddr: hw,
}
msg, err := dhcpv6.NewSolicitWithCID(duid)
if err != nil {
log.Println("Error generating SOLICIT:", err)
continue
}
c <- msg
}
close(c)
}()
return c
}
func validator(req, res dhcpv6.DHCPv6) error {
return nil
}
var target = flag.String("target", "", "target DHCPv6 server")
func main() {
flag.Parse()
if *target == "" {
log.Fatalln("You must specify target")
}
// Create client
addr, err := net.ResolveUDPAddr("udp6", net.JoinHostPort(*target, "547"))
if err != nil {
log.Fatalln("Error resolving UDP:", err)
}
// We need to manually set the local address since we're using Relay port
ip, err := getGlobalAddr("eth0")
if err != nil {
log.Fatalln("Error getting address:", err)
}
client := async.NewClient()
client.LocalAddr = &net.UDPAddr{IP: *ip, Port: dhcpv6.DefaultServerPort, Zone: ""}
client.RemoteAddr = addr
err = client.Open(100)
if err != nil {
log.Fatalln("Error opening client:", err)
}
defer client.Close()
// Subscribe to client errors channel
go func() {
for err := range client.Errors() {
log.Println("Client error:", err)
}
}()
// Load test
intervals := bender.ExponentialIntervalGenerator(10)
requests := SyntheticDHCPv6Requests(100)
exec := bdhcpv6.CreateExecutor(client, validator)
recorder := make(chan interface{}, 100)
bender.LoadTestThroughput(intervals, requests, exec, recorder)
l := log.New(os.Stdout, "", log.LstdFlags)
h := hist.NewHistogram(60000, int(time.Millisecond))
bender.Record(recorder, bender.NewLoggingRecorder(l), bender.NewHistogramRecorder(h))
fmt.Println(h)
}
// getGlobalAddr finds a global IPv6 address for the given interface
func getGlobalAddr(ifname string) (*net.IP, error) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
for _, iface := range ifaces {
if iface.Name != ifname {
continue
}
ifaddrs, err := iface.Addrs()
if err != nil {
return nil, err
}
for _, ifaddr := range ifaddrs {
if ifaddr, ok := ifaddr.(*net.IPNet); ok {
if ifaddr.IP.To4() == nil && !ifaddr.IP.IsLinkLocalUnicast() {
return &ifaddr.IP, nil
}
}
}
}
return nil, fmt.Errorf("No global address found for interface %v", ifname)
}
Run these commands to compile and install the load tester:
go install $PKG/hellobender
Because both server and load tester must run on port 547 it is not possible to run the test end the server on the same machine. We will assume that your server is running on machine example.com. Client takes ownership of port 547 so we need root access to run it:
sudo ./bin/hellobender -target example.com
You should see a long sequence of outputs and a final print out of the histogram data.