From bd57369e6abc8d0980b8e5a34e86469b221ca42f Mon Sep 17 00:00:00 2001 From: Frank Schroeder Date: Sat, 17 Oct 2015 09:50:25 +0200 Subject: [PATCH] Initial commit --- .gitignore | 6 + .travis.yml | 3 + LICENSE | 23 +++ README.md | 238 +++++++++++++++++++++++++++++++ config.go | 63 +++++++++ consul/consul.go | 39 ++++++ consul/parse.go | 25 ++++ consul/parse_test.go | 39 ++++++ consul/watch_auto_config.go | 92 ++++++++++++ consul/watch_manual_config.go | 44 ++++++ consul/watcher.go | 66 +++++++++ fabio.properties | 252 +++++++++++++++++++++++++++++++++ listen.go | 113 +++++++++++++++ listen_test.go | 75 ++++++++++ main.go | 130 +++++++++++++++++ metrics/metrics.go | 89 ++++++++++++ metrics/metrics_test.go | 39 ++++++ route/matcher.go | 14 ++ route/parse.go | 216 ++++++++++++++++++++++++++++ route/picker.go | 48 +++++++ route/picker_test.go | 50 +++++++ route/proxy.go | 64 +++++++++ route/route.go | 256 ++++++++++++++++++++++++++++++++++ route/route_bench_test.go | 120 ++++++++++++++++ route/route_test.go | 54 +++++++ route/routes.go | 19 +++ route/shutdown.go | 13 ++ route/table.go | 234 +++++++++++++++++++++++++++++++ route/table_lookup_test.go | 47 +++++++ route/table_route_test.go | 151 ++++++++++++++++++++ route/table_weight_test.go | 161 +++++++++++++++++++++ ui/route.go | 67 +++++++++ ui/server.go | 12 ++ 33 files changed, 2862 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.go create mode 100644 consul/consul.go create mode 100644 consul/parse.go create mode 100644 consul/parse_test.go create mode 100644 consul/watch_auto_config.go create mode 100644 consul/watch_manual_config.go create mode 100644 consul/watcher.go create mode 100644 fabio.properties create mode 100644 listen.go create mode 100644 listen_test.go create mode 100644 main.go create mode 100644 metrics/metrics.go create mode 100644 metrics/metrics_test.go create mode 100644 route/matcher.go create mode 100644 route/parse.go create mode 100644 route/picker.go create mode 100644 route/picker_test.go create mode 100644 route/proxy.go create mode 100644 route/route.go create mode 100644 route/route_bench_test.go create mode 100644 route/route_test.go create mode 100644 route/routes.go create mode 100644 route/shutdown.go create mode 100644 route/table.go create mode 100644 route/table_lookup_test.go create mode 100644 route/table_route_test.go create mode 100644 route/table_weight_test.go create mode 100644 ui/route.go create mode 100644 ui/server.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..3205e9e4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +fabio +fabio.sublime-* +*.pprof +*.test +*.pem +.DS_Store diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..39f7c985b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,3 @@ +language: go +go: + - release diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..3bd071cfc --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2015 eBay Software Foundation. All rights reserved. + +Initially written by Frank Schroeder. + +Licensed under the MIT license. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..97f07efa2 --- /dev/null +++ b/README.md @@ -0,0 +1,238 @@ +# fabio + +fabio is a fast, modern, zero-conf load balancing HTTP router for deploying +microservices. Services provide one or more host/path prefixes they serve and +fabio updates the routing table every time a service becomes (un-)available +without restart. + +fabio was developed at the [eBay Classifieds Group](http://www.ebayclassifiedsgroup.com) +in Amsterdam and is currently used to route traffic for +[marktplaats.nl](http://www.makrtplaats.nl) and [kijiji.it](http://www.kijiji.it). +Marktplaats is running all of its traffic through fabio which is +several thousand requests per second distributed over several fabio +instances. + +## Features + +* Single binary in Go. No external dependencies. +* Zero-conf +* Hot-reloading of routing table through backend watchers +* Round robin and random distribution +* [Traffic Shaping](#Traffic Shaping) (send 5% of traffic to new instances) +* Graphite metrics +* Request tracing +* WebUI +* Fast + +fabio listens on a single HTTP port for incoming requests and routes +them to the registered services. + +## Installation + +To install fabio run (you need Go 1.4 or higher) + + go get github.corm/eBay/fabio + +To start fabio run + + ./fabio + +which will run it with the default configuration which is described +in `fabio.properties`. To run it with a config file run it +with + + ./fabio -cfg cfgfile + +## Performance + +fabio has been tested to deliver up to 15.000 req/sec on a single 16 +core host with moderate memory requirements (~ 60 MB). + +To achieve the performance fabio sets the following defaults which +can be overwritten with the environment variables: + +* `GOMAXPROCS` is set to `runtime.NumCPU()` since this is not the + default for Go 1.4 and before +* `GOGC=800` is set to reduce the pressure on the garbage collector + +When fabio is compiled with Go 1.5 and run with default settings it can be up +to 40% slower than the same version compiled with Go 1.4. The `GOGC=100` +default puts more pressure on the Go 1.5 GC which makes the fabio spend 10% of +the time in the GC. With `GOGC=800` this drops back to 1-2%. Higher values +don't provide higher gains. + +As usual, don't rely on these numbers and perform your own benchmarks. You can +check the time fabio spends in the GC with `GODEBUG=gotrace=1`. + +## Service configuration + +Each service can register one or more URL prefixes for which it serves +traffic. A URL prefix is a `host/path` combination without a scheme since SSL +has already been terminated and all traffic is expected to be HTTP. To +register a URL prefix add a tag `urlprefix-host/path` to the service +definition. + +By default, traffic is distributed evenly across all service instances which +register a URL prefix but you can set the amount of traffic a set of instances +will receive ("Canary testing"). See [Traffic Shaping](#Traffic Shaping) +below. + +A background process watches for service definition and health status changes +in consul. When a change is detected a new routing table is constructed using +the commands described in [Config Commands](#Config Commands). + +## Manual overrides + +Since an automatically generated routing table can only be changed with a +service deployment additional routing commands can be stored manually in the +consul KV store which get appended to the automatically generated routing +table. This allows fine-tuning and fixing of problems without a deployment. + +The [Traffic Shaping](#Traffic Shaping) commands are also stored in the KV +store. + +## Routing Table Configuration + +The routing table is configured with the following commands: + +``` +route add service host/path targetURL [weight ] [tags "tag1,tag2,..."] + - Add a new route for host/path to targetURL + +route del service + - Remove all routes for service + +route del service host/path + - Remove all routes for host/path for this service only + +route del service host/path targetURL + - Remove only this route + +route weight service host/path weight n tags "tag1,tag2" + - Route n% of traffic to services matching service, host/path and tags + n is a float > 0 describing a percentage, e.g. 0.5 == 50% + n <= 0: means no fixed weighting. Traffic is evenly distributed + n > 0: route will receive n% of traffic. If sum(n) > 1 then n is normalized. + sum(n) >= 1: only matching services will receive traffic + +``` + +The order of commands matters but routes are always ordered from most to least +specific by prefix length. + +## Routing + +The routing table contains first all routes with a host sorted by prefix +length in descending order and then all routes without a host again sorted by +prefix length in descending order. + +For each incoming request the routing table is searched top to bottom for a +matching route. A route matches if either `host/path` or - if there was no +match - just `/path` matches. + +The matching route determines the target URL depending on the configured +strategy. `rnd` and `rr` are available with `rnd` being the default. + +## Example + +The auto-generated routing table is + +``` +route add service-a www.mp.dev/accounts/ http://host-a:11050/ tags "a,b" +route add service-a www.kjca.dev/accounts/ http://host-a:11050/ tags "a,b" +route add service-a www.dba.dev/accounts/ http://host-a:11050/ tags "a,b" +route add service-b www.mp.dev/auth/ http://host-b:11080/ tags "a,b" +route add service-b www.kjca.dev/auth/ http://host-b:11080/ tags "a,b" +route add service-b www.dba.dev/auth/ http://host-b:11080/ tags "a,b" +``` + +The manual configuration under `/fabio/config` is + +``` +route del service-b www.dba.dev/auth/ +route add service-c www.somedomain.com/ http://host-z:12345/ +``` + +The complete routing table then is + +``` +route add service-a www.mp.dev/accounts/ http://host-a:11050/ tags "a,b" +route add service-a www.kjca.dev/accounts/ http://host-a:11050/ tags "a,b" +route add service-a www.dba.dev/accounts/ http://host-a:11050/ tags "a,b" +route add service-b www.mp.dev/auth/ http://host-b:11080/ tags "a,b" +route add service-b www.kjca.dev/auth/ http://host-b:11080/ tags "a,b" +route add service-c www.somedomain.com/ http://host-z:12345/ tags "a,b" +``` + +## Traffic Shaping + +fabio allows to control the amount of traffic a set of service +instances will receive. You can use this feature to direct a fixed percentage +of traffic to a newer version of an existing service for testing ("Canary +testing"). + +The following command will allocate 5% of traffic to `www.kjca.dev/auth/` to +all instances of `service-b` which match tags `version-15` and `dc-fra`. This +is independent of the number of actual instances running. The remaining 95% +of the traffic will be distributed evenly across the remaining instances +publishing the same prefix. + +``` +route weight service-b www.kjca.dev/auth/ weight 0.05 tags "version-15,dc-fra" +``` + +### Traffic shaping with multiple active fabio instances + +The percentage calculation is currently local to the fabio instance. +That means that each fabio will send N percent of traffic to a +service for which traffic shaping is enabled. Therefore, if you want to +send 10% of traffic to a service and have two fabio instances +running you need to set the percentage to 5%. + +This will change in a later version when fabio registers itself in +consul and can adapt the percentages automatically depending on the number +of active fabio instances. + +## Debugging + +To send a request from the command line via the fabio using `curl` +you should send it as follows: + +``` +curl -v -H 'Host: foo.com' 'http://localhost:9999/path' +``` + +The `-x` or `--proxy` options will most likely not work as you expect as they +send the full URL instead of just the request URI which usually does not match +any route but the default one - if configured. + +### Tracing a request + +To trace how a request is routed you can add a `Trace` header with an non- +empty value which is truncated at 16 characters to keep the log output short. + +``` +$ curl -v -H 'Trace: abc' -H 'Host: foo.com' 'http://localhost:9999/bar/baz' + +2015/09/28 21:56:26 [TRACE] abc Tracing foo.com/bar/baz +2015/09/28 21:56:26 [TRACE] abc No match foo.com/bang +2015/09/28 21:56:26 [TRACE] abc Match foo.com/ +2015/09/28 22:01:34 [TRACE] abc Routing to http://1.2.3.4:8080/ +``` + +## Web UI + +fabio contains a (very) simple web ui to examine the routing +table. By default it is accessible on `http://localhost:9998/` + +## Roadmap + +The following features are planned to be added next. + +* HTTP/2 support +* Correct traffic shaping with multiple fabio instances + +## License + +MIT licensed + diff --git a/config.go b/config.go new file mode 100644 index 000000000..bf089181c --- /dev/null +++ b/config.go @@ -0,0 +1,63 @@ +package main + +import ( + "runtime" + "time" + + "github.com/eBay/fabio/_third_party/github.com/magiconair/properties" +) + +var ( + proxyAddr = ":9999" + proxyMaxConn = 10000 + proxyRoutes = "" + proxyStrategy = "rnd" + proxyShutdownWait = time.Duration(0) + proxyDialTimeout = 30 * time.Second + proxyTimeout = time.Duration(0) + proxyHeaderClientIP = "" + proxyHeaderTLS = "" + proxyHeaderTLSValue = "" + consulAddr = "localhost:8500" + consulKVPath = "/fabio/config" + consulTagPrefix = "urlprefix-" + consulURL = "http://" + consulAddr + "/" + metricsTarget = "" + metricsInterval = 30 * time.Second + metricsPrefix = "default" + metricsGraphiteAddr = "" + gogc = 800 + gomaxprocs = runtime.NumCPU() + uiAddr = ":9998" +) + +func loadConfig(filename string) error { + p, err := properties.LoadFile(filename, properties.UTF8) + if err != nil { + return err + } + + proxyAddr = p.GetString("proxy.addr", proxyAddr) + proxyMaxConn = p.GetInt("proxy.maxconn", proxyMaxConn) + proxyRoutes = p.GetString("proxy.routes", proxyRoutes) + proxyStrategy = p.GetString("proxy.strategy", proxyStrategy) + proxyShutdownWait = p.GetParsedDuration("proxy.shutdownWait", proxyShutdownWait) + proxyDialTimeout = p.GetParsedDuration("proxy.dialtimeout", proxyDialTimeout) + proxyTimeout = p.GetParsedDuration("proxy.timeout", proxyTimeout) + proxyHeaderClientIP = p.GetString("proxy.header.clientip", proxyHeaderClientIP) + proxyHeaderTLS = p.GetString("proxy.header.tls", proxyHeaderTLS) + proxyHeaderTLSValue = p.GetString("proxy.header.tls.value", proxyHeaderTLSValue) + consulAddr = p.GetString("consul.addr", consulAddr) + consulKVPath = p.GetString("consul.kvpath", consulKVPath) + consulTagPrefix = p.GetString("consul.tagprefix", consulTagPrefix) + consulURL = p.GetString("consul.url", "http://"+consulAddr+"/") + metricsTarget = p.GetString("metrics.target", metricsTarget) + metricsInterval = p.GetParsedDuration("metrics.interval", metricsInterval) + metricsPrefix = p.GetString("metrics.prefix", metricsPrefix) + metricsGraphiteAddr = p.GetString("metrics.graphite.addr", metricsGraphiteAddr) + gogc = p.GetInt("runtime.gogc", gogc) + gomaxprocs = p.GetInt("runtime.gomaxprocs", gomaxprocs) + uiAddr = p.GetString("ui.addr", uiAddr) + + return nil +} diff --git a/consul/consul.go b/consul/consul.go new file mode 100644 index 000000000..7e10f69e6 --- /dev/null +++ b/consul/consul.go @@ -0,0 +1,39 @@ +package consul + +import ( + "errors" + + "github.com/eBay/fabio/_third_party/github.com/hashicorp/consul/api" +) + +// Addr contains the host:port of the consul server +var Addr string + +// Scheme contains the protocol used to connect to the consul server +var Scheme = "http" + +// URL contains the base URL of the consul server +var URL string + +// Datacenter returns the datacenter of the local agent +func Datacenter() (string, error) { + client, err := api.NewClient(&api.Config{Address: Addr, Scheme: Scheme}) + if err != nil { + return "", nil + } + + self, err := client.Agent().Self() + if err != nil { + return "", err + } + + cfg, ok := self["Config"] + if !ok { + return "", errors.New("consul: self.Config not found") + } + dc, ok := cfg["Datacenter"].(string) + if !ok { + return "", errors.New("consul: self.Datacenter not found") + } + return dc, nil +} diff --git a/consul/parse.go b/consul/parse.go new file mode 100644 index 000000000..6467d14e8 --- /dev/null +++ b/consul/parse.go @@ -0,0 +1,25 @@ +package consul + +import ( + "log" + "strings" +) + +// parseURLPrefixTag expects an input in the form of 'tag-host/path' +// and returns the lower cased host plus the path unaltered if the +// prefix matches the tag. +func parseURLPrefixTag(s, prefix string) (host, path string, ok bool) { + if !strings.HasPrefix(s, prefix) { + return "", "", false + } + + // split host/path + p := strings.SplitN(s[len(prefix):], "/", 2) + if len(p) != 2 { + log.Printf("[WARN] Invalid %s tag %q", prefix, s) + return "", "", false + } + + host, path = strings.ToLower(strings.TrimSpace(p[0])), "/"+strings.TrimSpace(p[1]) + return host, path, true +} diff --git a/consul/parse_test.go b/consul/parse_test.go new file mode 100644 index 000000000..846d8e7fe --- /dev/null +++ b/consul/parse_test.go @@ -0,0 +1,39 @@ +package consul + +import "testing" + +func TestParseTag(t *testing.T) { + prefix := "p-" + tests := []struct { + tag, host, path string + ok bool + }{ + {"p", "", "", false}, + {"p-", "", "", false}, + {"p- ", "", "", false}, + {"p-/", "", "/", true}, + {"p-/ ", "", "/", true}, + {"p- / ", "", "/", true}, + {"p-/foo", "", "/foo", true}, + {"p-bar/foo", "bar", "/foo", true}, + {"p-bar/foo/foo", "bar", "/foo/foo", true}, + {"p-www.bar.com/foo/foo", "www.bar.com", "/foo/foo", true}, + {"p-WWW.BAR.COM/foo/foo", "www.bar.com", "/foo/foo", true}, + } + + for i, tt := range tests { + host, path, ok := parseURLPrefixTag(tt.tag, prefix) + if got, want := ok, tt.ok; got != want { + t.Errorf("%d: got %v want %v", i, got, want) + } + if !ok { + continue + } + if got, want := host, tt.host; got != want { + t.Errorf("%d: got %s want %s", i, got, want) + } + if got, want := path, tt.path; got != want { + t.Errorf("%d: got %s want %s", i, got, want) + } + } +} diff --git a/consul/watch_auto_config.go b/consul/watch_auto_config.go new file mode 100644 index 000000000..740205b9f --- /dev/null +++ b/consul/watch_auto_config.go @@ -0,0 +1,92 @@ +package consul + +import ( + "fmt" + "log" + "runtime" + "sort" + "strings" + "time" + + "github.com/eBay/fabio/_third_party/github.com/hashicorp/consul/api" +) + +// watchAutoConfig monitors the consul health checks and creates a new configuration +// on every change. +func watchAutoConfig(client *api.Client, tagPrefix string, config chan []string) { + var lastIndex uint64 + + for { + q := &api.QueryOptions{RequireConsistent: true, WaitIndex: lastIndex} + checks, meta, err := client.Health().State("passing", q) + if err != nil { + log.Printf("[WARN] Error fetching health state. %v", err) + time.Sleep(time.Second) + continue + } + + log.Printf("[INFO] Health changed to #%d", meta.LastIndex) + config <- servicesConfig(client, checks, tagPrefix) + lastIndex = meta.LastIndex + } +} + +// servicesConfig determines which service instances have passing health checks +// and then finds the ones which have tags with the right prefix to build the config from. +func servicesConfig(client *api.Client, checks []*api.HealthCheck, tagPrefix string) []string { + // map service name to list of service passing for which the health check is ok + m := map[string]map[string]bool{} + for _, check := range checks { + name, id := check.ServiceName, check.ServiceID + + if _, ok := m[name]; !ok { + m[name] = map[string]bool{id: true} + } else { + m[name][id] = true + } + } + + var config []string + for name, passing := range m { + cfg := serviceConfig(client, name, passing, tagPrefix) + config = append(config, cfg...) + } + + // sort config in reverse order to sort most specific config to the top + sort.Sort(sort.Reverse(sort.StringSlice(config))) + + return config +} + +// serviceConfig constructs the config for all good instances of a single service. +func serviceConfig(client *api.Client, name string, passing map[string]bool, tagPrefix string) (config []string) { + if name == "" || len(passing) == 0 { + return nil + } + + q := &api.QueryOptions{RequireConsistent: true} + svcs, _, err := client.Catalog().Service(name, "", q) + if err != nil { + log.Printf("[WARN] [%s] Error getting catalog service %s. %v", name, err) + return nil + } + + for _, svc := range svcs { + // check if the instance is in the list of instances + // which passed the health check + if _, ok := passing[svc.ServiceID]; !ok { + continue + } + + for _, tag := range svc.ServiceTags { + if host, path, ok := parseURLPrefixTag(tag, tagPrefix); ok { + name, addr, port := svc.ServiceName, svc.ServiceAddress, svc.ServicePort + if runtime.GOOS == "darwin" && !strings.Contains(addr, ".") { + addr += ".local" + } + config = append(config, fmt.Sprintf("route add %s %s%s http://%s:%d/ tags %q", name, host, path, addr, port, strings.Join(svc.ServiceTags, ","))) + } + } + } + return config +} diff --git a/consul/watch_manual_config.go b/consul/watch_manual_config.go new file mode 100644 index 000000000..cdcc41609 --- /dev/null +++ b/consul/watch_manual_config.go @@ -0,0 +1,44 @@ +package consul + +import ( + "log" + "strings" + "time" + + "github.com/eBay/fabio/_third_party/github.com/hashicorp/consul/api" +) + +// watchManualConfig monitors a key in the KV store for changes and passes +// its content unaltered on. The intended use case is to add addtional +// route commands to the routing table. +func watchManualConfig(client *api.Client, path string, config chan []string) { + var lastIndex uint64 + var lastValue string + + for { + value, index := nextValue(client, path, lastIndex) + if value != lastValue || index != lastIndex { + log.Printf("[INFO] Manual config changed to #%d", index) + config <- strings.Split(value, "\n") + lastValue, lastIndex = value, index + } + } +} + +func nextValue(client *api.Client, path string, lastIndex uint64) (string, uint64) { + for { + q := &api.QueryOptions{RequireConsistent: true, WaitIndex: lastIndex} + kvpair, meta, err := client.KV().Get(path, q) + if err != nil { + log.Printf("[WARN] Error fetching config from %s. %v", path, err) + time.Sleep(time.Second) + continue + } + + if kvpair == nil { + return "", meta.LastIndex + } + + return strings.TrimSpace(string(kvpair.Value)), meta.LastIndex + } +} diff --git a/consul/watcher.go b/consul/watcher.go new file mode 100644 index 000000000..1b9e7dd7e --- /dev/null +++ b/consul/watcher.go @@ -0,0 +1,66 @@ +package consul + +import ( + "log" + "strings" + + "github.com/eBay/fabio/route" + + "github.com/eBay/fabio/_third_party/github.com/hashicorp/consul/api" +) + +type Watcher struct { + client *api.Client + tagPrefix string + configPath string +} + +func NewWatcher(tagPrefix, configPath string) (*Watcher, error) { + client, err := api.NewClient(&api.Config{Address: Addr, Scheme: "http"}) + if err != nil { + return nil, err + } + + w := &Watcher{ + client: client, + tagPrefix: tagPrefix, + configPath: configPath, + } + return w, nil +} + +func (w *Watcher) Watch() { + var ( + auto []string + manual []string + t route.Table + err error + + autoConfig = make(chan []string) + manualConfig = make(chan []string) + ) + + go watchAutoConfig(w.client, w.tagPrefix, autoConfig) + go watchManualConfig(w.client, w.configPath, manualConfig) + + for { + select { + case auto = <-autoConfig: + case manual = <-manualConfig: + } + + if len(auto) == 0 && len(manual) == 0 { + continue + } + + input := strings.Join(append(auto, manual...), "\n") + log.Printf("[DEBUG] Received config\n%q", input) + + t, err = route.ParseString(input) + if err != nil { + log.Printf("[WARN] %s", err) + continue + } + route.SetTable(t) + } +} diff --git a/fabio.properties b/fabio.properties new file mode 100644 index 000000000..a88470419 --- /dev/null +++ b/fabio.properties @@ -0,0 +1,252 @@ +# proxy.addr configures the HTTP and HTTPS listeners as a comma separated list. +# +# To configure an HTTP listener provide [host]:port. +# To configure an HTTPS listener provide [host]:port;certFile;keyFile. +# certFile and keyFile contain the public/private key pair for that listener +# in PEM format. If certFile contains both the public and private key then +# keyFile can be omittted. +# +# Configure a single HTTP listener on port 9999: +# +# proxy.addr = :9999 +# +# Configure both an HTTP and HTTPS listener: +# +# proxy.addr = :9999,:443;path/to/cert.pem;path/to/key.pem +# +# Configure multiple HTTP and HTTPS listeners on IPv4 and IPv6: +# +# proxy.addr = \ +# 1.2.3.4:9999, \ +# 5.6.7.8:9999, \ +# [2001:DB8::A/32]:9999, \ +# [2001:DB8::B/32]:9999, \ +# 1.2.3.4:443;path/to/certA.pem;path/to/keyA.pem, \ +# 5.6.7.8:443;path/to/certB.pem;path/to/keyB.pem, \ +# [2001:DB8::A/32]:443;path/to/certA.pem;path/to/keyA.pem, \ +# [2001:DB8::B/32]:443;path/to/certB.pem;path/to/keyB.pem +# +# The default is +# +# proxy.addr = :9999 + + +# proxy.strategy configures the load balancing strategy. +# +# rnd: pseudo-random distribution +# rr: round-robin distribution +# +# "rnd" configures a pseudo-random distribution by using the microsecond +# fraction of the time of the request. +# +# "rr" configures a round-robin distribution. +# +# The default is +# +# proxy.strategy = rnd + + +# proxy.shutdownwait configures the time for a graceful shutdown. +# +# After a signal is caught the proxy will immediately suspend +# routing traffic and respond with a 503 Service Unavailable +# for the duration of the given period. +# +# The default is +# +# proxy.gracefulwait = 0s + + +# proxy.timeout configures the response header and keep-alive timeout. +# +# This configures the ResponseHeaderTimeout of the http.Transport +# and the KeepAliveTimeout of the network dialer. +# +# The default is +# +# proxy.timeout = 0s + + +# proxy.dialtimeout configures the connection timeout. +# +# This configures the DialTimeout of the network dialer. +# +# The default is +# +# proxy.dialtimeout = 30s + + +# proxy.maxconn configures the maximum number of cached connections. +# +# This configures the MaxConnsPerHost of the http.Transport. +# +# The default is +# +# proxy.maxconn = 10000 + + +# proxy.routes configures a static routing table. +# +# Setting this to a non-empty value will disable the automatic route +# generation from consul and use only this static routing table. +# If the entry starts with '@' it is considered to be a path to +# a file. +# +# Example: +# +# proxy.routes = \ +# route add svc / http://1.2.3.4:5000/ +# +# or +# +# proxy.routes = @routes.txt +# +# The default is +# +# proxy.routes = + +# proxy.header.clientip configures the header for the request ip. +# +# When set to a non-empty value the proxy will set this header on every +# request with the value of http.Request.RemoteAddr +# +# The default is +# +# proxy.header.clientip = + + +# proxy.header.tls configures the header to set for TLS connections. +# +# When set to a non-empty value the proxy will set this header on every +# TLS request to the value of ${proxy.header.tls.value} +# +# The default is +# +# proxy.header.tls = +# proxy.header.tls.value = + + +# consul.addr configures the address of the consul agent to connect to. +# +# The default is +# +# consul.addr = localhost:8500 + + +# consul.url configures the URL to connect to the consul UI. +# +# This is the base URL for links to consul in the UI. +# +# The default is +# +# consul.url = http://${consul.addr}/ + + +# consul.kvpath configures the KV path for manual routes. +# +# The consul KV path is watched for changes which get appended to +# the routing table. This allows for manual overrides and weighted +# round-robin routes. +# +# The default is +# +# consul.kvpath = /fabio/config + + +# consul.tagprefix configures the prefix for tags which define routes. +# +# Services which define routes publish one or more tags with host/path +# routes which they serve. These tags must have this prefix to be +# recognized as routes. +# +# The default is +# +# consul.tagprefix = urlprefix- + + +# metrics.target configures the backend the metrics values are +# sent to. +# +# Possible values are: +# : do not report metrics +# stdout: report metrics to stdout +# graphite: report metrics to Graphite on ${metrics.graphite.addr} +# +# The default is +# +# metrics.target = + + +# metrics.prefix configures the prefix of all reported metrics. +# +# Each metric has a unique name which is hard-coded to +# +# prefix.service.host.path.target-addr +# +# When set to "default" the prefix is . +# +# The default is +# +# metrics.prefix = default + + +# metrics.interval configures the interval in which metrics are +# reported. +# +# The default is +# +# metrics.interval = 30s + + +# metrics.graphite.addr configures the host:port of the Graphite +# server. This is required when ${metrics.target} is set to "graphite". +# +# The default is +# +# metrics.graphite.addr = + + +# runtime.gogc configures GOGC (the GC target percentage). +# +# Setting runtime.gogc is equivalent to setting the GOGC +# environment variable which also takes precendence over +# the value from the config file. +# +# Increasing this value means fewer but longer GC cycles +# since there is more garbage to collect. +# +# The default of GOGC=100 works for Go 1.4 but shows +# a significant performance drop for Go 1.5 since the +# concurrent GC kicks in more often. +# +# During benchmarking I have found the following values +# to work for my setup and for now I consider them sane +# defaults for both Go 1.4 and Go 1.5. +# +# GOGC=100: Go 1.5 40% slower than Go 1.4 +# GOGC=200: Go 1.5 == Go 1.4 with GOGC=100 (default) +# GOGC=800: both Go 1.4 and 1.5 significanlty faster (40%/go1.4, 100%/go1.5) +# +# The default is +# +# runtime.gogc = 800 + + +# runtime.gomaxprocs configures GOMAXPROCS. +# +# Setting runtime.gomaxprocs is equivalent to setting the GOMAXPROCS +# environment variable which also takes precendence over +# the value from the config file. +# +# If runtime.gomaxprocs < 0 then all CPU cores are used. +# +# The default is +# +# runtime.gomaxprocs = -1 + + +# ui.addr configures the address the UI is listening on +# +# The default is +# +# ui.addr = :9998 diff --git a/listen.go b/listen.go new file mode 100644 index 000000000..314ad22e1 --- /dev/null +++ b/listen.go @@ -0,0 +1,113 @@ +package main + +import ( + "crypto/tls" + "log" + "net" + "net/http" + "os" + "os/signal" + "regexp" + "syscall" + "time" + + "github.com/eBay/fabio/route" +) + +var quit = make(chan bool) +var commas = regexp.MustCompile(`\s*,\s*`) +var semicolons = regexp.MustCompile(`\s*;\s*`) + +func init() { + go func() { + // we use buffered to mitigate losing the signal + sigchan := make(chan os.Signal, 1) + signal.Notify(sigchan, os.Interrupt, os.Kill, syscall.SIGTERM) + <-sigchan + close(quit) + }() +} + +// listen starts one or more listeners for the handler. The list +// of addresses are +func listen(addrs string, wait time.Duration, h http.Handler) { + for _, addr := range commas.Split(addrs, -1) { + if addr == "" { + continue + } + + p := semicolons.Split(addr, 4) + switch len(p) { + case 1: + go listenAndServe(p[0], h) + case 2: + go listenAndServeTLS(p[0], p[1], p[1], h) + case 3: + go listenAndServeTLS(p[0], p[1], p[2], h) + default: + log.Fatal("[FATAL] Invalid address format ", addr) + } + } + + // wait for shutdown signal + <-quit + + // disable routing for all requests + route.Shutdown() + + // trigger graceful shutdown + log.Printf("[INFO] Graceful shutdown over %s", wait) + time.Sleep(wait) + log.Print("[INFO] Down") +} + +func listenAndServe(addr string, h http.Handler) { + log.Printf("[INFO] HTTP proxy listening on %s", addr) + if err := http.ListenAndServe(addr, h); err != nil { + log.Fatal("[FATAL] ", err) + } +} + +// listenAndServeTLS starts an HTTPS server with the given certificate. +func listenAndServeTLS(addr, certFile, keyFile string, h http.Handler) { + log.Printf("[INFO] HTTPS proxy listening on %s with certificate %s", addr, certFile) + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + log.Fatal("[FATAL] ", err) + } + + config := &tls.Config{ + NextProtos: []string{"http/1.1"}, + Certificates: []tls.Certificate{cert}, + } + srv := &http.Server{Addr: addr, TLSConfig: config, Handler: h} + + ln, err := net.Listen("tcp", addr) + if err != nil { + log.Fatal("[FATAL] ", err) + } + + tlsListener := tls.NewListener(tcpKeepAliveListener{ln.(*net.TCPListener)}, config) + if err := srv.Serve(tlsListener); err != nil { + log.Fatal("[FATAL] ", err) + } +} + +// copied from http://golang.org/src/net/http/server.go?s=54604:54695#L1967 +// tcpKeepAliveListener sets TCP keep-alive timeouts on accepted +// connections. It's used by ListenAndServe and ListenAndServeTLS so +// dead TCP connections (e.g. closing laptop mid-download) eventually +// go away. +type tcpKeepAliveListener struct { + *net.TCPListener +} + +func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) { + tc, err := ln.AcceptTCP() + if err != nil { + return + } + tc.SetKeepAlive(true) + tc.SetKeepAlivePeriod(3 * time.Minute) + return tc, nil +} diff --git a/listen_test.go b/listen_test.go new file mode 100644 index 000000000..7879402eb --- /dev/null +++ b/listen_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/eBay/fabio/route" +) + +// TestGracefulShutdown tests +func TestGracefulShutdown(t *testing.T) { + + req := func(url string) int { + resp, err := http.Get(url) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + return resp.StatusCode + } + + // start a server which responds after the shutdown has been triggered. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-quit // wait for shutdown signal + return + })) + defer srv.Close() + + // load the routing table + tbl, err := route.ParseString("route add svc / " + srv.URL) + if err != nil { + t.Fatal(err) + } + route.SetTable(tbl) + + // start proxy with graceful shutdown period long enough + // to complete one more request. + var wg sync.WaitGroup + wg.Add(1) + laddr := "127.0.0.1:57777" + go func() { + defer wg.Done() + listen(laddr, 250*time.Millisecond, route.NewProxy(http.DefaultTransport, "", "", "")) + }() + + // trigger shutdown after some time + shutdownDelay := 100 * time.Millisecond + go func() { + time.Sleep(shutdownDelay) + close(quit) + }() + + // give proxy some time to start up + // needs to be done before shutdown is triggered + time.Sleep(shutdownDelay / 2) + + // make 200 OK request + // start before and complete after shutdown was triggered + if got, want := req("http://"+laddr+"/"), 200; got != want { + t.Fatalf("request 1: got %v want %v", got, want) + } + + // make 503 request + // start and complete after shutdown was triggered + if got, want := req("http://"+laddr+"/"), 503; got != want { + t.Fatalf("got %v want %v", got, want) + } + + // wait for listen() to return + // note that the actual listeners have not returned yet + wg.Wait() +} diff --git a/main.go b/main.go new file mode 100644 index 000000000..f4e8f2f3e --- /dev/null +++ b/main.go @@ -0,0 +1,130 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "runtime" + "runtime/debug" + "strings" + + "github.com/eBay/fabio/consul" + "github.com/eBay/fabio/metrics" + "github.com/eBay/fabio/route" + "github.com/eBay/fabio/ui" +) + +var version = "1.0.0" + +func main() { + var cfg string + var v bool + flag.StringVar(&cfg, "cfg", "", "path to config file") + flag.BoolVar(&v, "v", false, "show version") + flag.Parse() + + if v { + fmt.Println(version) + return + } + + log.Printf("[INFO] Version %s starting", version) + + if cfg != "" { + if err := loadConfig(cfg); err != nil { + log.Fatal("[FATAL] ", err) + } + } + + if err := metrics.Init(metricsTarget, metricsPrefix, metricsInterval, metricsGraphiteAddr); err != nil { + log.Fatal("[FATAL] ", err) + } + + if os.Getenv("GOMAXPROCS") == "" { + log.Print("[INFO] Setting GOMAXPROCS=", gomaxprocs) + runtime.GOMAXPROCS(gomaxprocs) + } else { + log.Print("[INFO] Using GOMAXPROCS=", os.Getenv("GOMAXPROCS"), " from env") + } + + if os.Getenv("GOGC") == "" { + log.Print("[INFO] Setting GOGC=", gogc) + debug.SetGCPercent(gogc) + } else { + log.Print("[INFO] Using GOGC=", os.Getenv("GOGC"), " from env") + } + + if proxyRoutes == "" { + useDynamicRoutes() + } else { + useStaticRoutes() + } + + if err := route.SetPickerStrategy(proxyStrategy); err != nil { + log.Fatal("[FATAL] ", err) + } + + dc, err := consul.Datacenter() + if err != nil { + log.Fatal("[FATAL] ", err) + } + + log.Printf("[INFO] Using routing strategy %q", proxyStrategy) + log.Printf("[INFO] Connecting to consul on %q in datacenter %q", consulAddr, dc) + log.Printf("[INFO] Consul can be reached via %q", consulURL) + + log.Printf("[INFO] UI listening on %q", uiAddr) + go func() { + if err := ui.Start(uiAddr, consulKVPath); err != nil { + log.Fatal("[FATAL] ui: ", err) + } + }() + + tr := &http.Transport{ + ResponseHeaderTimeout: proxyTimeout, + MaxIdleConnsPerHost: proxyMaxConn, + Dial: (&net.Dialer{ + Timeout: proxyDialTimeout, + KeepAlive: proxyTimeout, + }).Dial, + } + + proxy := route.NewProxy(tr, proxyHeaderClientIP, proxyHeaderTLS, proxyHeaderTLSValue) + listen(proxyAddr, proxyShutdownWait, proxy) +} + +func useDynamicRoutes() { + log.Printf("[INFO] Using dynamic routes from consul on %s", consulAddr) + log.Printf("[INFO] Using tag prefix %q", consulTagPrefix) + log.Printf("[INFO] Watching KV path %q", consulKVPath) + go func() { + w, err := consul.NewWatcher(consulTagPrefix, consulKVPath) + if err != nil { + log.Fatal("[FATAL] ", err) + } + w.Watch() + }() +} + +func useStaticRoutes() { + var err error + var t route.Table + + if strings.HasPrefix(proxyRoutes, "@") { + proxyRoutes = proxyRoutes[1:] + log.Print("[INFO] Using static routes from ", proxyRoutes) + t, err = route.ParseFile(proxyRoutes) + } else { + log.Print("[INFO] Using static routes from config file") + t, err = route.ParseString(proxyRoutes) + } + + if err != nil { + log.Fatal("[FATAL] ", err) + } + + route.SetTable(t) +} diff --git a/metrics/metrics.go b/metrics/metrics.go new file mode 100644 index 000000000..eb97c1f28 --- /dev/null +++ b/metrics/metrics.go @@ -0,0 +1,89 @@ +package metrics + +import ( + "errors" + "fmt" + "log" + "net" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/eBay/fabio/_third_party/github.com/cyberdelia/go-metrics-graphite" + gometrics "github.com/eBay/fabio/_third_party/github.com/rcrowley/go-metrics" +) + +var pfx string + +func Init(target, prefix string, interval time.Duration, graphteAddr string) error { + pfx = prefix + if pfx == "default" { + pfx = defaultPrefix() + } + + switch target { + case "stdout": + log.Printf("[INFO] Sending metrics to stdout") + return initStdout(interval) + case "graphite": + if graphteAddr == "" { + return errors.New("metrics: graphite addr missing") + } + + log.Printf("[INFO] Sending metrics to Graphite on %s as %q", graphteAddr, pfx) + return initGraphite(graphteAddr, interval) + case "": + log.Printf("[INFO] Metrics disabled") + default: + log.Fatal("[FATAL] Invalid metrics target ", target) + } + return nil +} + +func TargetName(service, host, path string, targetURL *url.URL) string { + return strings.Join([]string{ + clean(service), + clean(host), + clean(path), + clean(targetURL.Host), + }, ".") +} + +func clean(s string) string { + if s == "" { + return "_" + } + s = strings.Replace(s, ".", "_", -1) + s = strings.Replace(s, ":", "_", -1) + return strings.ToLower(s) +} + +// stubbed out for testing +var hostname = os.Hostname + +func defaultPrefix() string { + host, err := hostname() + if err != nil { + log.Fatal("[FATAL] ", err) + } + exe := filepath.Base(os.Args[0]) + return clean(host) + "." + clean(exe) +} + +func initStdout(interval time.Duration) error { + logger := log.New(os.Stderr, "localhost: ", log.Lmicroseconds) + go gometrics.Log(gometrics.DefaultRegistry, interval, logger) + return nil +} + +func initGraphite(addr string, interval time.Duration) error { + a, err := net.ResolveTCPAddr("tcp", addr) + if err != nil { + return fmt.Errorf("metrics: cannot connect to Graphite: %s", err) + } + + go graphite.Graphite(gometrics.DefaultRegistry, interval, pfx, a) + return nil +} diff --git a/metrics/metrics_test.go b/metrics/metrics_test.go new file mode 100644 index 000000000..5783ed610 --- /dev/null +++ b/metrics/metrics_test.go @@ -0,0 +1,39 @@ +package metrics + +import ( + "net/url" + "os" + "testing" +) + +func TestDefaultPrefix(t *testing.T) { + hostname = func() (string, error) { return "myhost", nil } + os.Args = []string{"./myapp"} + if got, want := defaultPrefix(), "myhost.myapp"; got != want { + t.Errorf("got %v want %v", got, want) + } +} + +func TestTargetName(t *testing.T) { + tests := []struct { + service, host, path, target string + name string + }{ + {"s", "h", "p", "http://foo.com/bar", "s.h.p.foo_com"}, + {"s", "", "p", "http://foo.com/bar", "s._.p.foo_com"}, + {"s", "", "", "http://foo.com/bar", "s._._.foo_com"}, + {"", "", "", "http://foo.com/bar", "_._._.foo_com"}, + {"", "", "", "http://foo.com:1234/bar", "_._._.foo_com_1234"}, + {"", "", "", "http://1.2.3.4:1234/bar", "_._._.1_2_3_4_1234"}, + } + + for i, tt := range tests { + u, err := url.Parse(tt.target) + if err != nil { + t.Fatalf("%d: %v", i, err) + } + if got, want := TargetName(tt.service, tt.host, tt.path, u), tt.name; got != want { + t.Errorf("%d: got %q want %q", i, got, want) + } + } +} diff --git a/route/matcher.go b/route/matcher.go new file mode 100644 index 000000000..1b40731cc --- /dev/null +++ b/route/matcher.go @@ -0,0 +1,14 @@ +package route + +import "strings" + +// match contains the matcher function +var match matcher = prefixMatcher + +// matcher determines whether a host/path matches a route +type matcher func(path string, r *route) bool + +// prefixMatcher matches path to the routes' path. +func prefixMatcher(path string, r *route) bool { + return strings.HasPrefix(path, r.path) +} diff --git a/route/parse.go b/route/parse.go new file mode 100644 index 000000000..953a3402c --- /dev/null +++ b/route/parse.go @@ -0,0 +1,216 @@ +package route + +import ( + "bufio" + "fmt" + "io" + "os" + "strconv" + "strings" +) + +// Parse loads a routing table from a set of route commands. +// +// The commands are parsed in order and order matters. +// Deleting a route that has not been created yet yields +// a different result than the other way around. +// +// Route commands can have the following form: +// +// route add service host/path targetURL +// - Add a new route for host/path to targetURL +// +// route del service +// - Remove all routes for service +// +// route del service host/path +// - Remove all routes for host/path for this service only +// +// route del service host/path targetURL +// - Remove only this route +// +func Parse(r io.Reader) (Table, error) { + p := &parser{t: make(Table)} + if err := p.parse(r); err != nil { + return nil, err + } + return p.t, nil +} + +// ParseFile loads a routing table from a file. +func ParseFile(path string) (Table, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return Parse(f) +} + +// ParseString loads a routing table from a string. +func ParseString(s string) (Table, error) { + return Parse(strings.NewReader(s)) +} + +type parser struct { + t Table + lineNumber int + line string +} + +type cmdMap map[string]func(args []string) error + +func (p *parser) parse(r io.Reader) error { + cmds := cmdMap{"route": p.route} + + sc := bufio.NewScanner(r) + for sc.Scan() { + p.lineNumber++ + p.line = strings.TrimSpace(sc.Text()) + if p.line == "" || strings.HasPrefix(p.line, "#") { + continue + } + if err := p.call(cmds, strings.Split(p.line, " ")); err != nil { + return err + } + } + return nil +} + +func (p *parser) call(cmds cmdMap, args []string) error { + cmd, args := args[0], args[1:] + fn, ok := cmds[cmd] + if !ok { + return p.syntaxError() + } + if err := fn(args); err != nil { + return err + } + return nil +} + +func (p *parser) syntaxError() error { + return fmt.Errorf("route: line %d: syntax error in %s", p.lineNumber, p.line) +} + +func (p *parser) errorf(msg string, args ...string) error { + return fmt.Errorf("route: line %d: %s", p.lineNumber, fmt.Sprintf(msg, args)) +} + +// route implements the 'route' command. +func (p *parser) route(args []string) error { + cmds := cmdMap{ + "add": p.routeAdd, + "del": p.routeDel, + "weight": p.routeWeight, + } + return p.call(cmds, args) +} + +// routeAdd implements 'route add [weight ] [tags "tag1,tag2,..."]' +func (p *parser) routeAdd(args []string) error { + var service, prefix, target string + var weight float64 + var tags []string + var err error + + switch len(args) { + case 3: + service, prefix, target = args[0], args[1], args[2] + + case 5: + service, prefix, target = args[0], args[1], args[2] + switch args[3] { + case "weight": + if weight, err = p.parseWeight(args[3:]); err != nil { + return err + } + case "tags": + if tags, err = p.parseTags(args[3:]); err != nil { + return err + } + default: + return p.syntaxError() + } + + case 7: + service, prefix, target = args[0], args[1], args[2] + if weight, err = p.parseWeight(args[3:]); err != nil { + return err + } + if tags, err = p.parseTags(args[5:]); err != nil { + return err + } + + default: + return p.syntaxError() + } + + p.t.AddRoute(service, prefix, target, weight, tags) + return nil +} + +// routeDel implements 'route del service [prefix [target]]'' +func (p *parser) routeDel(args []string) error { + var service, prefix, target string + switch len(args) { + case 1: + service = args[0] + case 2: + service, prefix = args[0], args[1] + case 3: + service, prefix, target = args[0], args[1], args[2] + default: + return p.syntaxError() + } + + p.t.DelRoute(service, prefix, target) + return nil +} + +// routeWeight implements 'route weight weight tags "tag1,tag2,..."' +func (p *parser) routeWeight(args []string) error { + var service, prefix string + var weight float64 + var tags []string + var err error + + switch len(args) { + case 6: + service, prefix = args[0], args[1] + if weight, err = p.parseWeight(args[2:]); err != nil { + return err + } + if tags, err = p.parseTags(args[4:]); err != nil { + return err + } + + default: + p.syntaxError() + } + + p.t.AddRouteWeight(service, prefix, weight, tags) + return nil +} + +func (p *parser) parseWeight(args []string) (float64, error) { + if args[0] != "weight" || len(args) < 2 { + return 0, p.syntaxError() + } + n, err := strconv.ParseFloat(args[1], 64) + if err != nil { + return 0, p.errorf("invalid weight: %s", args[1]) + } + return n, nil +} + +func (p *parser) parseTags(args []string) ([]string, error) { + if args[0] != "tags" || len(args) < 2 { + return nil, p.syntaxError() + } + tags := args[1] + if !strings.HasPrefix(tags, `"`) || !strings.HasPrefix(tags, `"`) { + return nil, p.syntaxError() + } + return strings.Split(tags[1:len(tags)-1], ","), nil +} diff --git a/route/picker.go b/route/picker.go new file mode 100644 index 000000000..1c0a2d55c --- /dev/null +++ b/route/picker.go @@ -0,0 +1,48 @@ +package route + +import ( + "fmt" + "sync/atomic" + "time" +) + +// pick contains the picker function. +var pick picker = rndPicker + +// Picker selects a target from a list of targets +type picker func(r *route) *target + +// SetPickerStrategy sets the picker function for the proxy. +func SetPickerStrategy(s string) error { + switch s { + case "rnd": + pick = rndPicker + case "rr": + pick = rrPicker + default: + return fmt.Errorf("route: invalid strategy: %s", s) + } + return nil +} + +// rndPicker picks a random target from the list of targets. +func rndPicker(r *route) *target { + return r.wTargets[randIntn(len(r.wTargets))] +} + +// rrPicker picks the next target from a list of targets using round-robin. +func rrPicker(r *route) *target { + u := r.wTargets[r.total%uint64(len(r.wTargets))] + atomic.AddUint64(&r.total, 1) + return u +} + +// stubbed out for testing +// we implement the randIntN function using the nanosecond time counter +// since it is 15x faster than using the pseudo random number generator +// (12 ns vs 190 ns) Most HW does not seem to provide clocks with ns +// resolution but seem to be good enough for µs resolution. Since +// requests are usually handled within several ms we should have enough +// variation. Within 1 ms we have 1000 µs to distribute among a smaller +// set of entities (<< 100) +var randIntn = func(n int) int { return int(time.Now().UnixNano()/int64(time.Microsecond)) % n } diff --git a/route/picker_test.go b/route/picker_test.go new file mode 100644 index 000000000..bce3c6acb --- /dev/null +++ b/route/picker_test.go @@ -0,0 +1,50 @@ +package route + +import ( + "net/url" + "reflect" + "testing" +) + +var ( + fooDotCom = mustParse("http://foo.com/") + barDotCom = mustParse("http://bar.com/") +) + +func TestRndPicker(t *testing.T) { + r := newRoute("www.bar.com", "/foo") + r.addTarget("svc", fooDotCom, 0, nil) + r.addTarget("svc", barDotCom, 0, nil) + + tests := []struct { + rnd int + targetURL *url.URL + }{ + {0, fooDotCom}, + {1, barDotCom}, + } + + prev := randIntn + defer func() { randIntn = prev }() + + for i, tt := range tests { + randIntn = func(int) int { return i } + if got, want := rndPicker(r).URL, tt.targetURL; !reflect.DeepEqual(got, want) { + t.Errorf("%d: got %v want %v", i, got, want) + } + } +} + +func TestRRPicker(t *testing.T) { + r := newRoute("www.bar.com", "/foo") + r.addTarget("svc", fooDotCom, 0, nil) + r.addTarget("svc", barDotCom, 0, nil) + + tests := []*url.URL{fooDotCom, barDotCom, fooDotCom, barDotCom, fooDotCom, barDotCom} + + for i, tt := range tests { + if got, want := rrPicker(r).URL, tt; !reflect.DeepEqual(got, want) { + t.Errorf("%d: got %v want %v", i, got, want) + } + } +} diff --git a/route/proxy.go b/route/proxy.go new file mode 100644 index 000000000..a4bdf8036 --- /dev/null +++ b/route/proxy.go @@ -0,0 +1,64 @@ +package route + +import ( + "log" + "net" + "net/http" + "net/http/httputil" + "time" + + gometrics "github.com/eBay/fabio/_third_party/github.com/rcrowley/go-metrics" +) + +// Proxy is a dynamic reverse proxy. +type Proxy struct { + tr http.RoundTripper + clientIPHeader string + tlsHeader string + tlsHeaderValue string + requests gometrics.Timer +} + +func NewProxy(tr http.RoundTripper, clientIPHeader, tlsHeader, tlsHeaderValue string) *Proxy { + return &Proxy{ + tr: tr, + clientIPHeader: clientIPHeader, + tlsHeader: tlsHeader, + tlsHeaderValue: tlsHeaderValue, + requests: gometrics.GetOrRegisterTimer("requests", gometrics.DefaultRegistry), + } +} + +func (p *Proxy) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if ShuttingDown() { + http.Error(w, "shutting down", http.StatusServiceUnavailable) + return + } + + target := GetTable().lookup(req, req.Header.Get("trace")) + if target == nil { + log.Print("[WARN] No route for ", req.URL) + w.WriteHeader(404) + return + } + + if p.clientIPHeader != "" { + ip, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + http.Error(w, "cannot parse "+req.RemoteAddr, http.StatusInternalServerError) + return + } + req.Header.Set(p.clientIPHeader, ip) + } + + if p.tlsHeader != "" && req.TLS != nil { + req.Header.Set(p.tlsHeader, p.tlsHeaderValue) + } + + start := time.Now() + rp := httputil.NewSingleHostReverseProxy(target.URL) + rp.Transport = p.tr + rp.ServeHTTP(w, req) + target.timer.UpdateSince(start) + p.requests.UpdateSince(start) +} diff --git a/route/route.go b/route/route.go new file mode 100644 index 000000000..f2ca3c9a8 --- /dev/null +++ b/route/route.go @@ -0,0 +1,256 @@ +package route + +import ( + "fmt" + "net/url" + "sort" + "strings" + + "github.com/eBay/fabio/metrics" + + gometrics "github.com/eBay/fabio/_third_party/github.com/rcrowley/go-metrics" +) + +// route maps a path prefix to one or more target URLs. +// routes can have a share value which describes the +// amount of traffic this route should get. You can specify +// that a route should get a fixed percentage of the traffic +// independent of how many instances are running. +type route struct { + // host contains the host of the route. + // not used for routing but for config generation + // Table has a map with the host as key + // for faster lookup and smaller search space. + host string + + // path is the path prefix from a request uri + path string + + // targets contains the list of URLs + targets []*target + + // wTargets contains 100 targets distributed + // according to their weight and ordered RR in the + // same order as targets + wTargets []*target + + // total contains the total number of requests for this route. + // Used by the RRPicker + total uint64 +} + +type target struct { + // service is the name of the service the targetURL points to + service string + + // tags are the list of tags for this target + tags []string + + // URL is the endpoint the service instance listens on + URL *url.URL + + // fixedWeight is the weight assigned to this target. + // If the value is 0 the targets weight is dynamic. + fixedWeight float64 + + // weight is the actual weight for this service in percent. + weight float64 + + // timer measures throughput and latency of this target + timer gometrics.Timer +} + +func newRoute(host, path string) *route { + return &route{host: host, path: path} +} + +func (r *route) addTarget(service string, targetURL *url.URL, fixedWeight float64, tags []string) { + if fixedWeight < 0 { + fixedWeight = 0 + } + + name := metrics.TargetName(service, r.host, r.path, targetURL) + timer := gometrics.GetOrRegisterTimer(name, gometrics.DefaultRegistry) + + t := &target{service: service, tags: tags, URL: targetURL, fixedWeight: fixedWeight, timer: timer} + r.targets = append(r.targets, t) + r.weighTargets() +} + +func (r *route) delService(service string) { + var clone []*target + for _, t := range r.targets { + if t.service == service { + continue + } + clone = append(clone, t) + } + r.targets = clone + r.weighTargets() +} + +func (r *route) delTarget(service string, targetURL *url.URL) { + var clone []*target + for _, t := range r.targets { + if t.service == service && t.URL.String() == targetURL.String() { + continue + } + clone = append(clone, t) + } + r.targets = clone + r.weighTargets() +} + +func (r *route) setWeight(weight float64, tags []string) int { + updated := 0 + for _, t := range r.targets { + if contains(t.tags, tags) { + t.fixedWeight = weight + updated++ + } + } + if updated > 0 { + r.weighTargets() + } + return updated +} + +func contains(src, dst []string) bool { + for _, d := range dst { + found := false + for _, s := range src { + if s == d { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// targetWeight returns how often target is in wTargets. +func (r *route) targetWeight(targetURL string) (n int) { + for _, t := range r.wTargets { + if t.URL.String() == targetURL { + n++ + } + } + return n +} + +// config returns the route configuration in the config language. +// with the weights specified by the user. +func (r *route) config(addWeight bool) []string { + var cfg []string + for _, t := range r.targets { + if t.weight <= 0 { + continue + } + + s := fmt.Sprintf("route add %s %s %s", t.service, r.host+r.path, t.URL) + if addWeight { + s += fmt.Sprintf(" weight %2.2f", t.weight) + } else if t.fixedWeight > 0 { + s += fmt.Sprintf(" weight %.2f", t.fixedWeight) + } + if len(t.tags) > 0 { + s += fmt.Sprintf(" tags %q", strings.Join(t.tags, ",")) + } + cfg = append(cfg, s) + } + return cfg +} + +// weighTargets computes the share of traffic each target receives based +// on its weight and the weight of the other targets. +// +// Traffic is first distributed to targets with a fixed weight. If the sum of +// all fixed weights exceeds 100% then they are normalized to 100%. +// +// Targets with a dynamic weight will receive an equal share of the remaining +// traffic if there is any left. +func (r *route) weighTargets() { + // how big is the fixed weighted traffic? + var nFixed int + var sumFixed float64 + for _, t := range r.targets { + if t.fixedWeight > 0 { + nFixed++ + sumFixed += t.fixedWeight + } + } + + // normalize fixed weights up (sumFixed < 1) or down (sumFixed > 1) + scale := 1.0 + if sumFixed > 1 || (nFixed == len(r.targets) && sumFixed < 1) { + scale = 1 / sumFixed + } + + // compute the weight for the targets with dynamic weights + dynamic := (1 - sumFixed) / float64(len(r.targets)-nFixed) + if dynamic < 0 { + dynamic = 0 + } + + // assign the actual weight to each target + for _, t := range r.targets { + if t.fixedWeight > 0 { + t.weight = t.fixedWeight * scale + } else { + t.weight = dynamic + } + } + + // Distribute the targets on a ring with N slots. The distance + // between two entries for the same target should be N/count slots + // apart to achieve even distribution. count is the number of slots the + // target should get based on its weight. + // To achieve this we first determine count per target and then sort that + // from smallest to largest to distribute the targets with lesser weight + // more evenly. For that we pick a random starting point on the ring and + // move clockwise until we find a free spot. The the next slot is N/count + // slots away. If it is occupied we again move clockwise until we find + // a free slot. + + // number of slots we want to use and number of slots we will actually use + // because of rounding errors + gotSlots, wantSlots := 0, 100 + + slotCount := make(byN, len(r.targets)) + for i, t := range r.targets { + slotCount[i].i = i + slotCount[i].n = int(float64(wantSlots)*t.weight + 0.5) + gotSlots += slotCount[i].n + } + sort.Sort(slotCount) + + slots := make([]*target, gotSlots) + for _, c := range slotCount { + if c.n <= 0 { + continue + } + + next, step := 0, gotSlots/c.n + for k := 0; k < c.n; k++ { + // find the next empty slot + for slots[next] != nil { + next = (next + 1) % gotSlots + } + + // use slot and move to next one + slots[next] = r.targets[c.i] + next = (next + step) % gotSlots + } + } + + r.wTargets = slots +} + +type byN []struct{ i, n int } + +func (r byN) Len() int { return len(r) } +func (r byN) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +func (r byN) Less(i, j int) bool { return r[i].n < r[j].n } diff --git a/route/route_bench_test.go b/route/route_bench_test.go new file mode 100644 index 000000000..9d199e0f1 --- /dev/null +++ b/route/route_bench_test.go @@ -0,0 +1,120 @@ +package route + +import ( + "fmt" + "net/http" + "sync" + "testing" +) + +var ( + b5Routes Table + b10Routes Table + b100Routes Table + b500Routes Table + + once sync.Once +) + +// initRoutes is used for lazy one time initialization of the test data for +// the parallel benchmarks via once +func initRoutes() { + b5Routes = makeRoutes(1, 5, 1, 6) + b10Routes = makeRoutes(1, 5, 2, 6) + b100Routes = makeRoutes(10, 5, 2, 24) + b500Routes = makeRoutes(10, 10, 5, 24) +} + +func BenchmarkPrefixMatcherRndPicker5Routes(b *testing.B) { + once.Do(initRoutes) + b.RunParallel(func(b *testing.PB) { benchmarkGet(b5Routes, prefixMatcher, rndPicker, b) }) +} + +func BenchmarkPrefixMatcherRRPicker5Routes(b *testing.B) { + once.Do(initRoutes) + b.RunParallel(func(b *testing.PB) { benchmarkGet(b5Routes, prefixMatcher, rrPicker, b) }) +} + +func BenchmarkPrefixMatcherRndPicker10Routes(b *testing.B) { + once.Do(initRoutes) + b.SetParallelism(3) + b.RunParallel(func(b *testing.PB) { benchmarkGet(b10Routes, prefixMatcher, rndPicker, b) }) +} + +func BenchmarkPrefixMatcherRRPicker10Routes(b *testing.B) { + once.Do(initRoutes) + b.SetParallelism(3) + b.RunParallel(func(b *testing.PB) { benchmarkGet(b10Routes, prefixMatcher, rrPicker, b) }) +} + +func BenchmarkPrefixMatcherRndPicker100Routes(b *testing.B) { + once.Do(initRoutes) + b.SetParallelism(3) + b.RunParallel(func(b *testing.PB) { benchmarkGet(b100Routes, prefixMatcher, rndPicker, b) }) +} + +func BenchmarkPrefixMatcherRRPicker100Routes(b *testing.B) { + once.Do(initRoutes) + b.SetParallelism(3) + b.RunParallel(func(b *testing.PB) { benchmarkGet(b100Routes, prefixMatcher, rrPicker, b) }) +} + +func BenchmarkPrefixMatcherRndPicker500Routes(b *testing.B) { + once.Do(initRoutes) + b.SetParallelism(3) + b.RunParallel(func(b *testing.PB) { benchmarkGet(b500Routes, prefixMatcher, rndPicker, b) }) +} + +func BenchmarkPrefixMatcherRRPicker500Routes(b *testing.B) { + once.Do(initRoutes) + b.SetParallelism(3) + b.RunParallel(func(b *testing.PB) { benchmarkGet(b500Routes, prefixMatcher, rrPicker, b) }) +} + +// makeRoutes builds a set of routes for a set of domains +// and target urls. For each domain all paths up to depth +// are constructed and all host/path combinations have the +// same target URLs. The number of generated routes is +// domains * paths * depth. +func makeRoutes(domains, paths, depth, urls int) Table { + t := Table{} + for i := 0; i < domains; i++ { + prefix := fmt.Sprintf("www.host-%d.com/", i) + for j := 0; j < paths; j++ { + for k := 0; k < depth; k++ { + prefix += fmt.Sprintf("path-%d/", k) + for l := 0; l < urls; l++ { + if err := t.AddRoute("svc", prefix, "http://host:12345/", 0, nil); err != nil { + panic(err) + } + } + } + } + } + return t +} + +// makeRequests builds a list of http.Request objects with an +// additional path for benchmarking. +func makeRequests(t Table) []*http.Request { + reqs := []*http.Request{} + for host, hr := range t { + for _, r := range hr { + req := &http.Request{Host: host, RequestURI: r.path + "/some/additional/path"} + reqs = append(reqs, req) + } + } + return reqs +} + +// benchmarkGet runs the benchmark on the Table.Lookup() function with the +// given matcher and picker functions. +func benchmarkGet(t Table, m matcher, p picker, pb *testing.PB) { + reqs := makeRequests(t) + match, pick = m, p + k, n := len(reqs), 0 + for pb.Next() { + t.lookup(reqs[n%k], "") + n++ + } +} diff --git a/route/route_test.go b/route/route_test.go new file mode 100644 index 000000000..1916ae3dd --- /dev/null +++ b/route/route_test.go @@ -0,0 +1,54 @@ +package route + +import ( + "net/url" + "reflect" + "testing" +) + +func mustParse(rawurl string) *url.URL { + u, err := url.Parse(rawurl) + if err != nil { + panic(err) + } + return u +} + +func TestNewRoute(t *testing.T) { + r := newRoute("www.bar.com", "/foo") + if got, want := r.path, "/foo"; got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestAddTarget(t *testing.T) { + u := mustParse("http://foo.com/") + + r := newRoute("www.bar.com", "/foo") + r.addTarget("service", u, 0, nil) + + if got, want := len(r.targets), 1; got != want { + t.Errorf("target length: got %d want %d", got, want) + } + if got, want := r.targets[0].URL, u; got != want { + t.Errorf("target url: got %s want %s", got, want) + } + config := []string{"route add service www.bar.com/foo http://foo.com/"} + if got, want := r.config(false), config; !reflect.DeepEqual(got, want) { + t.Errorf("config: got %q want %q", got, want) + } +} + +func TestDelService(t *testing.T) { + u1, u2 := mustParse("http://foo.com/"), mustParse("http://bar.com/") + + r := newRoute("www.bar.com", "/foo") + r.addTarget("serviceA", u1, 0, nil) + r.addTarget("serviceB", u2, 0, nil) + r.delService("serviceA") + + config := []string{"route add serviceB www.bar.com/foo http://bar.com/"} + if got, want := r.config(false), config; !reflect.DeepEqual(got, want) { + t.Errorf("config: got %q want %q", got, want) + } +} diff --git a/route/routes.go b/route/routes.go new file mode 100644 index 000000000..8461ace01 --- /dev/null +++ b/route/routes.go @@ -0,0 +1,19 @@ +package route + +// routes stores a list of routes usually for a single host. +type routes []*route + +// find returns the route with the given path and returns nil if none was found. +func (rt routes) find(path string) *route { + for _, r := range rt { + if r.path == path { + return r + } + } + return nil +} + +// sort by path in reverse order (most to least specific) +func (rt routes) Len() int { return len(rt) } +func (rt routes) Swap(i, j int) { rt[i], rt[j] = rt[j], rt[i] } +func (rt routes) Less(i, j int) bool { return rt[j].path < rt[i].path } diff --git a/route/shutdown.go b/route/shutdown.go new file mode 100644 index 000000000..bae59b5c4 --- /dev/null +++ b/route/shutdown.go @@ -0,0 +1,13 @@ +package route + +import "sync/atomic" + +var shutdown int32 + +func Shutdown() { + atomic.StoreInt32(&shutdown, 1) +} + +func ShuttingDown() bool { + return atomic.LoadInt32(&shutdown) != 0 +} diff --git a/route/table.go b/route/table.go new file mode 100644 index 000000000..1734a549f --- /dev/null +++ b/route/table.go @@ -0,0 +1,234 @@ +package route + +import ( + "errors" + "fmt" + "log" + "net/http" + "net/url" + "sort" + "strings" + "sync/atomic" +) + +// active stores the current routing table. Should never be nil. +var active atomic.Value + +var errInvalidPrefix = errors.New("route: prefix must not be empty") +var errInvalidTarget = errors.New("route: target must not be empty") +var errNoMatch = errors.New("route: no target match") + +func init() { + active.Store(make(Table)) +} + +func GetTable() Table { + return active.Load().(Table) +} + +func SetTable(t Table) { + if t == nil { + log.Print("[WARN] Ignoring nil routing table") + return + } + active.Store(t) + log.Printf("[INFO] Updated config to\n%s", t) +} + +// Table contains a set of routes grouped by host. +// The host routes are sorted from most to least specific +// by sorting the routes in reverse order by path. +type Table map[string]routes + +// hostpath splits a host/path prefix into a host and a path. +// The path always starts with a slash +func hostpath(prefix string) (host string, path string) { + p := strings.SplitN(prefix, "/", 2) + host, path = p[0], "" + if len(p) == 1 { + return p[0], "/" + } + return p[0], "/" + p[1] +} + +// AddRoute adds a new route prefix -> target for the given service. +func (t Table) AddRoute(service, prefix, target string, weight float64, tags []string) error { + host, path := hostpath(prefix) + + if prefix == "" { + return errInvalidPrefix + } + + if target == "" { + return errInvalidTarget + } + + targetURL, err := url.Parse(target) + if err != nil { + return fmt.Errorf("route: invalid target. %s", err) + } + + r := newRoute(host, path) + r.addTarget(service, targetURL, weight, tags) + + // add new host + if t[host] == nil { + t[host] = routes{r} + return nil + } + + // add new route to existing host + if t[host].find(path) == nil { + t[host] = append(t[host], r) + sort.Sort(t[host]) + return nil + } + + // add new target to existing route + t[host].find(path).addTarget(service, targetURL, weight, tags) + + return nil +} + +func (t Table) AddRouteWeight(service, prefix string, weight float64, tags []string) error { + host, path := hostpath(prefix) + + if prefix == "" { + return errInvalidPrefix + } + + if t[host] == nil || t[host].find(path) == nil { + return errNoMatch + } + + if n := t[host].find(path).setWeight(weight, tags); n == 0 { + return errNoMatch + } + return nil +} + +// DelRoute removes one or more routes depending on the arguments. +// If service, prefix and target are provided then only this route +// is removed. Are only service and prefix provided then all routes +// for this service and prefix are removed. This removes all active +// instances of the service from the route. If only the service is +// provided then all routes for this service are removed. The service +// will no longer receive traffic. +func (t Table) DelRoute(service, prefix, target string) error { + switch { + case prefix == "" && target == "": + for _, hr := range t { + for _, r := range hr { + r.delService(service) + } + } + + case target == "": + r := t.route(hostpath(prefix)) + if r == nil { + return nil + } + r.delService(service) + + default: + targetURL, err := url.Parse(target) + if err != nil { + return fmt.Errorf("route: invalid target. %s", err) + } + + r := t.route(hostpath(prefix)) + if r == nil { + return nil + } + r.delTarget(service, targetURL) + } + + return nil +} + +// route finds the route for host/path or returns nil if none exists. +func (t Table) route(host, path string) *route { + hr := t[host] + if hr == nil { + return nil + } + return hr.find(path) +} + +// lookup finds a target url based on the current matcher and picker +// or nil if there is none. It first checks the routes for the host +// and if none matches then it falls back to generic routes without +// a host. This is useful for a catch-all '/' rule. +func (t Table) lookup(req *http.Request, trace string) *target { + if trace != "" { + if len(trace) > 16 { + trace = trace[:15] + } + log.Printf("[TRACE] %s Tracing %s%s", trace, req.Host, req.RequestURI) + } + + u := t.doLookup(strings.ToLower(req.Host), req.RequestURI, trace) + if u == nil { + u = t.doLookup("", req.RequestURI, trace) + } + + if trace != "" { + log.Printf("[TRACE] %s Routing to %s", trace, u.URL) + } + + return u +} + +func (t Table) doLookup(host, path, trace string) *target { + hr := t[host] + if hr == nil { + return nil + } + + for _, r := range hr { + if match(path, r) { + n := len(r.targets) + if n == 0 { + return nil + } + if n == 1 { + return r.targets[0] + } + if trace != "" { + log.Printf("[TRACE] %s Match %s%s", trace, r.host, r.path) + } + return pick(r) + } + if trace != "" { + log.Printf("[TRACE] %s No match %s%s", trace, r.host, r.path) + } + } + return nil +} + +func (t Table) Config(addWeight bool) []string { + var hosts []string + for h := range t { + if h != "" { + hosts = append(hosts, h) + } + } + sort.Strings(hosts) + + // entries without host come last + hosts = append(hosts, "") + + var routes []string + for _, h := range hosts { + for _, r := range t[h] { + routes = append(routes, r.config(addWeight)...) + } + } + return routes +} + +// String returns the routing table as config file which can +// be read by Parse() again. +func (t Table) String() string { + return strings.Join(t.Config(false), "\n") +} diff --git a/route/table_lookup_test.go b/route/table_lookup_test.go new file mode 100644 index 000000000..c9ed4fa4b --- /dev/null +++ b/route/table_lookup_test.go @@ -0,0 +1,47 @@ +package route + +import ( + "net/http" + "testing" +) + +func TestTableLookup(t *testing.T) { + s := ` + route add svc / http://foo.com:800 + route add svc /foo http://foo.com:900 + route add svc abc.com/ http://foo.com:1000 + route add svc abc.com/foo http://foo.com:1500 + route add svc abc.com/foo/ http://foo.com:2000 + route add svc abc.com/foo/bar http://foo.com:2500 + route add svc abc.com/foo/bar/ http://foo.com:3000 + ` + + tbl, err := ParseString(s) + if err != nil { + t.Fatal(err) + } + + var tests = []struct { + req *http.Request + dst string + }{ + {&http.Request{Host: "abc.com", RequestURI: "/"}, "http://foo.com:1000"}, + {&http.Request{Host: "abc.com", RequestURI: "/bar"}, "http://foo.com:1000"}, + {&http.Request{Host: "abc.com", RequestURI: "/foo"}, "http://foo.com:1500"}, + {&http.Request{Host: "abc.com", RequestURI: "/foo/"}, "http://foo.com:2000"}, + {&http.Request{Host: "abc.com", RequestURI: "/foo/bar"}, "http://foo.com:2500"}, + {&http.Request{Host: "abc.com", RequestURI: "/foo/bar/"}, "http://foo.com:3000"}, + + {&http.Request{Host: "def.com", RequestURI: "/"}, "http://foo.com:800"}, + {&http.Request{Host: "def.com", RequestURI: "/bar"}, "http://foo.com:800"}, + {&http.Request{Host: "def.com", RequestURI: "/baz"}, "http://foo.com:800"}, + + {&http.Request{Host: "def.com", RequestURI: "/foo"}, "http://foo.com:900"}, + } + + for i, tt := range tests { + if got, want := tbl.lookup(tt.req, "").URL.String(), tt.dst; got != want { + t.Errorf("%d: got %v want %v", i, got, want) + } + } +} diff --git a/route/table_route_test.go b/route/table_route_test.go new file mode 100644 index 000000000..78a45bf09 --- /dev/null +++ b/route/table_route_test.go @@ -0,0 +1,151 @@ +package route + +import ( + "reflect" + "strings" + "testing" +) + +func TestTableRoute(t *testing.T) { + mustAdd := func(tbl Table, service, prefix, target string) { + if err := tbl.AddRoute(service, prefix, target, 0, nil); err != nil { + t.Fatalf("got %v want nil for %s, %s, %s", err, service, prefix, target) + } + } + + mustDel := func(tbl Table, service, prefix, target string) { + if err := tbl.DelRoute(service, prefix, target); err != nil { + t.Fatalf("got %v want nil for %s, %s, %s", err, service, prefix, target) + } + } + + tests := []struct { + setup func(tbl Table) error + cfg []string + err string + }{ + { // invalid prefix + setup: func(tbl Table) error { return tbl.AddRoute("svc", "", "http://bbb.com/", 0, nil) }, + err: errInvalidPrefix.Error(), + }, + + { // invalid target + setup: func(tbl Table) error { return tbl.AddRoute("svc", "www.foo.com/", "", 0, nil) }, + err: errInvalidTarget.Error(), + }, + + { // invalid target url + setup: func(tbl Table) error { return tbl.AddRoute("svc", "www.foo.com/", "://aaa.com/", 0, nil) }, + err: "route: invalid target", + }, + + { // new prefix + setup: func(tbl Table) error { + mustAdd(tbl, "svc-a", "www.foo.com/", "http://aaa.com/") + return nil + }, + cfg: []string{ + "route add svc-a www.foo.com/ http://aaa.com/", + }, + }, + + { // add host to prefix + setup: func(tbl Table) error { + mustAdd(tbl, "svc-a", "www.foo.com/", "http://aaa.com/") + mustAdd(tbl, "svc-b", "www.foo.com/", "http://bbb.com/") + return nil + }, + cfg: []string{ + "route add svc-a www.foo.com/ http://aaa.com/", + "route add svc-b www.foo.com/ http://bbb.com/", + }, + }, + + { // add more specific prefix + setup: func(tbl Table) error { + mustAdd(tbl, "svc-a", "www.foo.com/", "http://aaa.com/") + mustAdd(tbl, "svc-b", "www.foo.com/", "http://bbb.com/") + mustAdd(tbl, "svc-c", "www.foo.com/ccc", "http://ccc.com/") + return nil + }, + cfg: []string{ + "route add svc-c www.foo.com/ccc http://ccc.com/", + "route add svc-a www.foo.com/ http://aaa.com/", + "route add svc-b www.foo.com/ http://bbb.com/", + }, + }, + + { // add more specific prefix to existing host + setup: func(tbl Table) error { + mustAdd(tbl, "svc-a", "www.foo.com/", "http://aaa.com/") + mustAdd(tbl, "svc-b", "www.foo.com/", "http://bbb.com/") + mustAdd(tbl, "svc-b", "www.foo.com/dddddd", "http://bbb.com/") + mustAdd(tbl, "svc-c", "www.foo.com/ccc", "http://ccc.com/") + return nil + }, + cfg: []string{ + "route add svc-b www.foo.com/dddddd http://bbb.com/", + "route add svc-c www.foo.com/ccc http://ccc.com/", + "route add svc-a www.foo.com/ http://aaa.com/", + "route add svc-b www.foo.com/ http://bbb.com/", + }, + }, + + { // add route without host + setup: func(tbl Table) error { + mustAdd(tbl, "svc-a", "www.foo.com/", "http://aaa.com/") + mustAdd(tbl, "svc-b", "www.foo.com/", "http://bbb.com/") + mustAdd(tbl, "svc-d", "/", "http://ddd.com/") + mustAdd(tbl, "svc-b", "www.foo.com/dddddd", "http://bbb.com/") + mustAdd(tbl, "svc-c", "/ccc", "http://ccc.com/") + mustAdd(tbl, "svc-c", "www.foo.com/ccc", "http://ccc.com/") + return nil + }, + cfg: []string{ + "route add svc-b www.foo.com/dddddd http://bbb.com/", + "route add svc-c www.foo.com/ccc http://ccc.com/", + "route add svc-a www.foo.com/ http://aaa.com/", + "route add svc-b www.foo.com/ http://bbb.com/", + "route add svc-c /ccc http://ccc.com/", + "route add svc-d / http://ddd.com/", + }, + }, + + { // delete route + setup: func(tbl Table) error { + mustAdd(tbl, "svc-a", "www.foo.com/", "http://aaa.com/") + mustAdd(tbl, "svc-b", "www.foo.com/", "http://bbb.com/") + mustAdd(tbl, "svc-b", "www.foo.com/dddddd", "http://bbb.com/") + mustAdd(tbl, "svc-c", "www.foo.com/ccc", "http://ccc.com/") + mustDel(tbl, "svc-a", "www.foo.com/", "http://aaa.com/") + return nil + }, + cfg: []string{ + "route add svc-b www.foo.com/dddddd http://bbb.com/", + "route add svc-c www.foo.com/ccc http://ccc.com/", + "route add svc-b www.foo.com/ http://bbb.com/", + }, + }, + } + + for i, tt := range tests { + tbl := make(Table) + err := tt.setup(tbl) + if got, want := err, tt.err; got == nil && tt.err != "" { + t.Errorf("%d: got %v want %v", i, got, want) + } + if got, want := err, tt.err; got != nil && tt.err == "" { + t.Errorf("%d: got %v want %v", i, got, want) + } + if got, want := err, tt.err; got != nil && !strings.HasPrefix(got.Error(), tt.err) { + t.Errorf("%d: got %v want %v", i, got, want) + } + if err != nil { + continue + } + if got, want := tbl.Config(false), tt.cfg; !reflect.DeepEqual(got, want) { + t.Errorf("%d: got\n%s\nwant\n%s\n", i, strings.Join(got, "\n"), strings.Join(want, "\n")) + } + } + +} diff --git a/route/table_weight_test.go b/route/table_weight_test.go new file mode 100644 index 000000000..c3732d7ce --- /dev/null +++ b/route/table_weight_test.go @@ -0,0 +1,161 @@ +package route + +import ( + "reflect" + "strings" + "testing" +) + +func TestWeight(t *testing.T) { + tests := []struct { + in, out []string + counts []int + }{ + { // no fixed weight -> auto distribution + []string{ + `route add svc /foo http://bar:111/`, + }, + []string{ + `route add svc /foo http://bar:111/ weight 1.00`, + }, + []int{100}, + }, + + { // fixed weight 0 -> auto distribution + []string{ + `route add svc /foo http://bar:111/ weight 0`, + }, + []string{ + `route add svc /foo http://bar:111/ weight 1.00`, + }, + []int{100}, + }, + + { // only fixed weights and sum(fixedWeight) < 1 -> normalize to 100% + []string{ + `route add svc /foo http://bar:111/ weight 0.2`, + `route add svc /foo http://bar:222/ weight 0.3`, + }, + []string{ + `route add svc /foo http://bar:111/ weight 0.40`, + `route add svc /foo http://bar:222/ weight 0.60`, + }, + []int{40, 60}, + }, + + { // only fixed weights and sum(fixedWeight) > 1 -> normalize to 100% + []string{ + `route add svc /foo http://bar:111/ weight 2`, + `route add svc /foo http://bar:222/ weight 3`, + }, + []string{ + `route add svc /foo http://bar:111/ weight 0.40`, + `route add svc /foo http://bar:222/ weight 0.60`, + }, + []int{40, 60}, + }, + + // TODO(fs): should Table de-duplicate? + { // multiple entries with no fixed weight -> even distribution (same service) + []string{ + `route add svc /foo http://bar:111/`, + `route add svc /foo http://bar:111/`, + }, + []string{ + `route add svc /foo http://bar:111/ weight 0.50`, + `route add svc /foo http://bar:111/ weight 0.50`, + }, + []int{100, 100}, + }, + + { // multiple entries with no fixed weight -> even distribution + []string{ + `route add svc /foo http://bar:111/`, + `route add svc /foo http://bar:222/`, + }, + []string{ + `route add svc /foo http://bar:111/ weight 0.50`, + `route add svc /foo http://bar:222/ weight 0.50`, + }, + []int{50, 50}, + }, + + { // mixed fixed and auto weights -> even distribution of remaining weight across non-fixed weighted targets + []string{ + `route add svc /foo http://bar:111/`, + `route add svc /foo http://bar:222/`, + `route add svc /foo http://bar:333/ weight 0.5`, + }, + []string{ + `route add svc /foo http://bar:111/ weight 0.25`, + `route add svc /foo http://bar:222/ weight 0.25`, + `route add svc /foo http://bar:333/ weight 0.50`, + }, + []int{25, 25, 50}, + }, + + { // fixed weight == 100% -> route only to fixed weighted targets + []string{ + `route add svc /foo http://bar:111/`, + `route add svc /foo http://bar:222/ weight 0.25`, + `route add svc /foo http://bar:333/ weight 0.75`, + }, + []string{ + `route add svc /foo http://bar:222/ weight 0.25`, + `route add svc /foo http://bar:333/ weight 0.75`, + }, + []int{0, 25, 75}, + }, + + { // fixed weight > 100% -> route only to fixed weighted targets and normalize weight + []string{ + `route add svc /foo http://bar:111/`, + `route add svc /foo http://bar:222/ weight 1`, + `route add svc /foo http://bar:333/ weight 3`, + }, + []string{ + `route add svc /foo http://bar:222/ weight 0.25`, + `route add svc /foo http://bar:333/ weight 0.75`, + }, + []int{0, 25, 75}, + }, + + { // fixed weight > 100% -> route only to fixed weighted targets and normalize weight + []string{ + `route add svc /foo http://bar:111/ tags "a"`, + `route add svc /foo http://bar:222/ tags "b"`, + `route weight svc /foo weight 0.1 tags "b"`, + }, + []string{ + `route add svc /foo http://bar:111/ weight 0.90 tags "a"`, + `route add svc /foo http://bar:222/ weight 0.10 tags "b"`, + }, + []int{90, 10}, + }, + } + + for i, tt := range tests { + tbl, err := ParseString(strings.Join(tt.in, "\n")) + if err != nil { + t.Fatalf("%d: got %v want nil", i, err) + } + if got, want := tbl.Config(true), tt.out; !reflect.DeepEqual(got, want) { + t.Errorf("%d: got\n%s\nwant\n%s", i, strings.Join(got, "\n"), strings.Join(want, "\n")) + } + + // count url occurrences + r := tbl.route("", "/foo") + if r == nil { + t.Fatalf("%d: got nil want route /foo", i) + } + for j, s := range tt.in { + if !strings.HasPrefix(s, "route add") { + continue + } + p := strings.Split(s, " ") + if got, want := r.targetWeight(p[4]), tt.counts[j]; got != want { + t.Errorf("%d: %s: got %d want %d", i, p[4], got, want) + } + } + } +} diff --git a/ui/route.go b/ui/route.go new file mode 100644 index 000000000..d566d5250 --- /dev/null +++ b/ui/route.go @@ -0,0 +1,67 @@ +package ui + +import ( + "fmt" + "html/template" + "net/http" + + "github.com/eBay/fabio/consul" + "github.com/eBay/fabio/route" +) + +func handleRoute(w http.ResponseWriter, r *http.Request) { + dc, err := consul.Datacenter() + if err != nil { + http.Error(w, "cannot get datacenter: "+err.Error(), http.StatusInternalServerError) + return + } + + data := struct { + Config []string + ConfigURL string + }{ + route.GetTable().Config(true), + fmt.Sprintf("%sui/#/%s/kv%s/edit", consul.URL, dc, configPath), + } + tmplTable.ExecuteTemplate(w, "table", data) +} + +var tmplTable = template.Must(template.New("table").Parse(htmlTable)) + +var htmlTable = ` + + + + + fabio routing table + + + + +

Routing Table

+ +

Filter routes:

+ +{{range $i, $v := .Config}} +
{{$v}}
+{{end}} + +

Edit config

+ + + + + +` diff --git a/ui/server.go b/ui/server.go new file mode 100644 index 000000000..9e1146bef --- /dev/null +++ b/ui/server.go @@ -0,0 +1,12 @@ +package ui + +import "net/http" + +// Addr contains the host:port of the UI endpoint +var configPath string + +func Start(addr, cfgpath string) error { + configPath = cfgpath + http.HandleFunc("/", handleRoute) + return http.ListenAndServe(addr, nil) +}