├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── blacklist.go ├── blacklist_test.go ├── buffer.go ├── cache.go ├── cache_test.go ├── circle.yml ├── consul.go ├── consul_test.go ├── http.go ├── main.go ├── prefer.go ├── prefer_test.go ├── resolv.go ├── shuffle.go ├── shuffle_test.go ├── tests ├── docker-compose.yml ├── files │ ├── hello-1.html │ ├── hello-2.html │ └── hello-3.html └── nginx.conf └── vendor └── vendor.json /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | consul-router 3 | circle.yml 4 | README.md 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | /consul-router 27 | /vendor/*/ 28 | 29 | # Emacs 30 | *~ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine 2 | 3 | COPY . /go/src/github.com/segmentio/consul-router 4 | 5 | ENV CGO_ENABLED=0 6 | RUN apk add --no-cache git && \ 7 | cd /go/src/github.com/segmentio/consul-router && \ 8 | go get -v github.com/kardianos/govendor && \ 9 | govendor sync && \ 10 | go build -v -o /consul-router && \ 11 | apk del git && \ 12 | rm -rf /go/* 13 | 14 | ENTRYPOINT ["/consul-router"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Segment 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # consul-router [![CircleCI](https://circleci.com/gh/segmentio/consul-router.svg?style=shield)](https://circleci.com/gh/segmentio/consul-router) 2 | 3 | ## THIS IS A PROTOTYPE, DO NOT USE IT IN PRODUCTION 4 | 5 | HTTP proxy with service discovery capabilities based on consul 6 | -------------------------------------------------------------------------------- /blacklist.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "runtime" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // The blacklist type is a data structure that is similar to a cache, it keeps 10 | // values around for a configurable amount of time. It is used to implement 11 | // blacklisting of hosts that have had connection failures. 12 | type blacklist struct { 13 | // Immutable fields of the blacklist data structure. 14 | timeout time.Duration 15 | rslv resolver 16 | done chan struct{} 17 | 18 | // Mutable fields of the blacklist data structure, the mutex must be locked 19 | // to access them concurrently. 20 | mutex sync.RWMutex 21 | addr map[string]time.Time 22 | } 23 | 24 | func blacklisted(timeout time.Duration, rslv resolver) *blacklist { 25 | b := &blacklist{ 26 | timeout: timeout, 27 | rslv: rslv, 28 | done: make(chan struct{}), 29 | addr: make(map[string]time.Time), 30 | } 31 | runtime.SetFinalizer(b, func(b *blacklist) { close(b.done) }) 32 | go blacklistVacuum(&b.mutex, b.addr, b.done) 33 | return b 34 | } 35 | 36 | func (b *blacklist) add(addr string) { 37 | now := time.Now() 38 | lim := now.Add(b.timeout) 39 | 40 | b.mutex.Lock() 41 | 42 | // Checking for existence so the address expiration time doesn't get 43 | // updated after it was set. 44 | if exp, exist := b.addr[addr]; !exist || now.After(exp) { 45 | b.addr[addr] = lim 46 | } 47 | 48 | b.mutex.Unlock() 49 | } 50 | 51 | func (b *blacklist) resolve(name string) (srv []service, err error) { 52 | if srv, err = b.rslv.resolve(name); err != nil { 53 | return 54 | } 55 | 56 | i := 0 57 | now := time.Now() 58 | b.mutex.RLock() 59 | 60 | for _, s := range srv { // filter out black-listed hosts 61 | if exp, bad := b.addr[s.host]; !bad || now.After(exp) { 62 | srv[i] = s 63 | i++ 64 | } 65 | } 66 | 67 | b.mutex.RUnlock() 68 | srv = srv[:i] 69 | return 70 | } 71 | 72 | func blacklistVacuum(mutex *sync.RWMutex, blacklist map[string]time.Time, done <-chan struct{}) { 73 | const max = 100 74 | 75 | ticker := time.NewTicker(1 * time.Minute) 76 | defer ticker.Stop() 77 | 78 | for { 79 | select { 80 | case <-done: 81 | return 82 | case now := <-ticker.C: 83 | blacklistVacuumPass(mutex, blacklist, now, max) 84 | } 85 | } 86 | } 87 | 88 | func blacklistVacuumPass(mutex *sync.RWMutex, blacklist map[string]time.Time, now time.Time, max int) { 89 | i := 0 90 | mutex.Lock() 91 | 92 | for addr, exp := range blacklist { 93 | if i++; i > max { 94 | break 95 | } 96 | if now.After(exp) { 97 | delete(blacklist, addr) 98 | } 99 | } 100 | 101 | mutex.Unlock() 102 | } 103 | -------------------------------------------------------------------------------- /blacklist_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | var blacklistTests = []struct { 10 | exc []string 11 | srv []service 12 | res []service 13 | }{ 14 | { 15 | exc: nil, 16 | srv: []service{{"host-1", 1000, nil}, {"host-2", 2000, nil}, {"host-3", 3000, nil}}, 17 | res: []service{{"host-1", 1000, nil}, {"host-2", 2000, nil}, {"host-3", 3000, nil}}, 18 | }, 19 | { 20 | exc: []string{"?"}, 21 | srv: []service{{"host-1", 1000, nil}, {"host-2", 2000, nil}, {"host-3", 3000, nil}}, 22 | res: []service{{"host-1", 1000, nil}, {"host-2", 2000, nil}, {"host-3", 3000, nil}}, 23 | }, 24 | { 25 | exc: []string{"host-1"}, 26 | srv: []service{{"host-1", 1000, nil}, {"host-2", 2000, nil}, {"host-3", 3000, nil}}, 27 | res: []service{{"host-2", 2000, nil}, {"host-3", 3000, nil}}, 28 | }, 29 | { 30 | exc: []string{"host-2"}, 31 | srv: []service{{"host-1", 1000, nil}, {"host-2", 2000, nil}, {"host-3", 3000, nil}}, 32 | res: []service{{"host-1", 1000, nil}, {"host-3", 3000, nil}}, 33 | }, 34 | { 35 | exc: []string{"host-3"}, 36 | srv: []service{{"host-1", 1000, nil}, {"host-2", 2000, nil}, {"host-3", 3000, nil}}, 37 | res: []service{{"host-1", 1000, nil}, {"host-2", 2000, nil}}, 38 | }, 39 | { 40 | exc: []string{"host-1", "host-2"}, 41 | srv: []service{{"host-1", 1000, nil}, {"host-2", 2000, nil}, {"host-3", 3000, nil}}, 42 | res: []service{{"host-3", 3000, nil}}, 43 | }, 44 | { 45 | exc: []string{"host-1", "host-2", "host-3"}, 46 | srv: []service{{"host-1", 1000, nil}, {"host-2", 2000, nil}, {"host-3", 3000, nil}}, 47 | res: []service{}, 48 | }, 49 | } 50 | 51 | func TestBlacklist(t *testing.T) { 52 | for _, test := range blacklistTests { 53 | t.Run("", func(t *testing.T) { 54 | blacklist := blacklisted(1*time.Second, serviceList(test.srv)) 55 | 56 | for _, addr := range test.exc { 57 | blacklist.add(addr) 58 | } 59 | 60 | srv, err := blacklist.resolve("anything") 61 | 62 | if err != nil { 63 | t.Error(err) 64 | } 65 | 66 | if !reflect.DeepEqual(srv, test.res) { 67 | t.Errorf("\n%#v\n%#v", srv, test.res) 68 | } 69 | }) 70 | } 71 | } 72 | 73 | func BenchmarkBlacklist(b *testing.B) { 74 | for _, test := range blacklistTests { 75 | blacklist := blacklisted(1*time.Second, serviceList(test.srv)) 76 | 77 | for _, addr := range test.exc { 78 | blacklist.add(addr) 79 | } 80 | 81 | b.Run("", func(b *testing.B) { 82 | for i := 0; i != b.N; i++ { 83 | blacklist.resolve("anything") 84 | } 85 | }) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /buffer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "sync" 6 | ) 7 | 8 | // bufferPool is a simple wrapper around a sync.Pool that stores byte slices. 9 | type bufferPool struct { 10 | pool sync.Pool 11 | } 12 | 13 | func makeBufferPool(size int) bufferPool { 14 | return bufferPool{ 15 | pool: sync.Pool{ 16 | New: func() interface{} { 17 | return make([]byte, size) 18 | }, 19 | }, 20 | } 21 | } 22 | 23 | func (p *bufferPool) get() []byte { 24 | return p.pool.Get().([]byte) 25 | } 26 | 27 | func (p *bufferPool) put(b []byte) { 28 | p.pool.Put(b) 29 | } 30 | 31 | // Copy bytes from w to r using a temporary buffer allocated from the global 32 | // buffer pool. 33 | func copyBytes(w io.Writer, r io.Reader) { 34 | b := buffers.get() 35 | io.CopyBuffer(w, r, b) 36 | buffers.put(b) 37 | } 38 | 39 | var ( 40 | // A global buffer pool to be used for acquiring temporary buffers anywhere 41 | // in the program. 42 | buffers = makeBufferPool(16384) 43 | ) 44 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "runtime" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // The cache type is an implementation of a resolver decorator that caches 10 | // service endpoints returned by a base resolver using a LRU cache. 11 | type cache struct { 12 | // Immutable fields of the cache. 13 | timeout time.Duration 14 | rslv resolver 15 | done chan struct{} 16 | 17 | // Mutable fields of the cache, the mutex must be locked to access them 18 | // concurrently. 19 | mutex sync.RWMutex 20 | cache map[string]*cacheEntry 21 | } 22 | 23 | type cacheEntry struct { 24 | sync.RWMutex 25 | srv []service 26 | err error 27 | exp time.Time 28 | } 29 | 30 | func cached(timeout time.Duration, rslv resolver) *cache { 31 | c := &cache{ 32 | timeout: timeout, 33 | rslv: rslv, 34 | done: make(chan struct{}), 35 | cache: make(map[string]*cacheEntry), 36 | } 37 | 38 | // The use of a finalizer on the cache object gives us the ability to clear 39 | // the internal goroutine without requiring an explictly API to do so. 40 | runtime.SetFinalizer(c, func(c *cache) { close(c.done) }) 41 | 42 | // It's important that this goroutine doesn't reference the cache object 43 | // itself, otherwise it would never get garbage collected. 44 | go cacheVacuum(&c.mutex, c.cache, c.done) 45 | return c 46 | } 47 | 48 | func (c *cache) resolve(name string) (srv []service, err error) { 49 | now := time.Now() 50 | 51 | for { 52 | if e := c.lookup(name, now); e != nil { 53 | if now.After(e.exp) { 54 | c.remove(name, e) 55 | } else { 56 | e.RLock() 57 | srv = e.srv 58 | err = e.err 59 | e.RUnlock() 60 | break 61 | } 62 | } 63 | 64 | e := &cacheEntry{exp: now.Add(c.timeout)} 65 | e.Lock() 66 | 67 | if !c.add(name, e) { 68 | continue 69 | } 70 | 71 | srv, err = c.rslv.resolve(name) 72 | e.srv = srv 73 | e.err = err 74 | e.Unlock() 75 | break 76 | } 77 | 78 | // Making a copy is important, the caller becomes the owner of the returned 79 | // service list and may modify its content. We don't want this to affect the 80 | // content of the cache. 81 | srv = copyServices(srv) 82 | return 83 | } 84 | 85 | func (c *cache) lookup(name string, now time.Time) *cacheEntry { 86 | c.mutex.RLock() 87 | entry := c.cache[name] 88 | c.mutex.RUnlock() 89 | return entry 90 | } 91 | 92 | func (c *cache) add(name string, entry *cacheEntry) (ok bool) { 93 | c.mutex.Lock() 94 | 95 | if c.cache[name] == nil { 96 | ok = true 97 | c.cache[name] = entry 98 | } 99 | 100 | c.mutex.Unlock() 101 | return 102 | } 103 | 104 | func (c *cache) remove(name string, entry *cacheEntry) { 105 | c.mutex.Lock() 106 | 107 | // Ensure the entry wasn't changed since the last time it was pulled out of 108 | // the map. 109 | if c.cache[name] == entry { 110 | delete(c.cache, name) 111 | } 112 | 113 | c.mutex.Unlock() 114 | } 115 | 116 | func cacheVacuum(mutex *sync.RWMutex, cache map[string]*cacheEntry, done <-chan struct{}) { 117 | // This constant is used to limit the maximum number of cache entries 118 | // visited during one vaccum pass to avoid locking the mutex for too 119 | // long when the cache is large. 120 | // Because iterating over maps is randomized this should still give 121 | // eventual consistency and evict stale entries from the cache. 122 | const max = 100 123 | 124 | ticker := time.NewTicker(10 * time.Second) 125 | defer ticker.Stop() 126 | 127 | for { 128 | select { 129 | case <-done: 130 | return 131 | case now := <-ticker.C: 132 | cacheVacuumPass(mutex, cache, now, max) 133 | } 134 | } 135 | } 136 | 137 | func cacheVacuumPass(mutex *sync.RWMutex, cache map[string]*cacheEntry, now time.Time, max int) { 138 | i := 0 139 | mutex.Lock() 140 | 141 | for name, entry := range cache { 142 | if i++; i > max { 143 | break 144 | } 145 | if now.After(entry.exp) { 146 | delete(cache, name) 147 | } 148 | } 149 | 150 | mutex.Unlock() 151 | } 152 | -------------------------------------------------------------------------------- /cache_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strconv" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestCache(t *testing.T) { 12 | services := serviceMap{ 13 | "host-1": []service{{"host-1", 1000, nil}}, 14 | "host-2": []service{{"host-2", 2000, nil}}, 15 | "host-3": []service{{"host-3", 3000, []string{"A", "B", "C"}}}, 16 | } 17 | 18 | cache := cached(time.Second, services) 19 | 20 | for _, name := range []string{"host-1", "host-2", "host-3"} { 21 | t.Run(name, func(t *testing.T) { 22 | srv, err := cache.resolve(name) 23 | if err != nil { 24 | t.Error(err) 25 | } else if !reflect.DeepEqual(srv, services[name]) { 26 | t.Errorf("%#v != %#v", srv, services[name]) 27 | } 28 | }) 29 | } 30 | 31 | t.Run("missing", func(t *testing.T) { 32 | srv, err := cache.resolve("") 33 | if err != nil { 34 | t.Error(err) 35 | } else if len(srv) != 0 { 36 | t.Errorf("%#v", srv) 37 | } 38 | }) 39 | } 40 | 41 | func BenchmarkCache(b *testing.B) { 42 | for _, size := range [...]int{1, 10, 100, 1000} { 43 | names, services := make([]string, size), make(serviceMap, size) 44 | 45 | for i := 0; i != size; i++ { 46 | name := fmt.Sprintf("host-%d", i+1) 47 | names[i] = name 48 | services[name] = []service{{name, 4242, nil}} 49 | } 50 | 51 | cache := cached(1*time.Minute, services) 52 | 53 | b.Run(strconv.Itoa(size), func(b *testing.B) { 54 | for i := 0; i != b.N; i++ { 55 | cache.resolve(names[i%len(names)]) 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | pre: 3 | - curl -sSL https://s3.amazonaws.com/circle-downloads/install-circleci-docker.sh | bash -s -- 1.10.0 4 | services: 5 | - docker 6 | environment: 7 | ECR_ENABLED: True 8 | 9 | dependencies: 10 | pre: 11 | - docker login -e ${DOCKER_EMAIL} -u ${DOCKER_USER} -p ${DOCKER_PASS} 12 | # get ECR creds 13 | - pip install awscli==1.11.76 14 | - $(aws ecr get-login --region $AWS_REGION) 15 | override: 16 | - docker pull segment/golang:latest 17 | 18 | test: 19 | override: 20 | - > 21 | docker run 22 | $(env | grep -E '^CIRCLE_|^DOCKER_|^AWS_|^GH_|^NPM_|^PRODUCTION_|^STAGE_|^CIRCLECI=|^CI=' | sed 's/^/--env /g' | tr "\\n" " ") 23 | --rm 24 | --tty 25 | --interactive 26 | --name go 27 | --net host 28 | --volume /var/run/docker.sock:/run/docker.sock 29 | --volume ${GOPATH%%:*}/src:/go/src 30 | --volume ${PWD}:/go/src/github.com/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME} 31 | --workdir /go/src/github.com/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME} 32 | --env CGO_ENABLED=0 33 | segment/golang:latest go.test='govendor test -v -cover +local' 34 | 35 | deployment: 36 | release: 37 | tag: /.*/ 38 | commands: 39 | - 'true' 40 | -------------------------------------------------------------------------------- /consul.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/apex/log" 10 | ) 11 | 12 | // The consulResolver is a resolver implementation that uses a consul agent to 13 | // lookup registered services. 14 | type consulResolver struct { 15 | address string 16 | } 17 | 18 | func (r consulResolver) resolve(name string) (srv []service, err error) { 19 | var res *http.Response 20 | var url = r.address + "/v1/catalog/service/" + name 21 | 22 | switch { 23 | case strings.HasPrefix(url, "http://"): 24 | case strings.HasPrefix(url, "https://"): 25 | default: 26 | url = "http://" + url 27 | } 28 | 29 | if res, err = http.Get(url); err != nil { 30 | return 31 | } 32 | 33 | defer res.Body.Close() 34 | 35 | if res.StatusCode != http.StatusOK { 36 | err = errors.New(url + ": " + res.Status) 37 | return 38 | } 39 | 40 | var list []struct { 41 | Address string `json:"Address"` 42 | ServicePort int `json:"ServicePort"` 43 | ServiceTags []string `json:"ServiceTags"` 44 | } 45 | 46 | if err = json.NewDecoder(res.Body).Decode(&list); err != nil { 47 | return 48 | } 49 | 50 | srv = make([]service, 0, len(list)) 51 | 52 | for _, s := range list { 53 | srv = append(srv, service{ 54 | host: s.Address, 55 | port: s.ServicePort, 56 | tags: s.ServiceTags, 57 | }) 58 | } 59 | 60 | log.WithFields(log.Fields{ 61 | "name": name, 62 | "url": url, 63 | "status": res.StatusCode, 64 | "services": len(srv), 65 | }).Info("consul service discovery") 66 | return 67 | } 68 | -------------------------------------------------------------------------------- /consul_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "path" 8 | "reflect" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func TestConsul(t *testing.T) { 14 | services := serviceMap{ 15 | "host-1": []service{{"host-1", 1000, nil}}, 16 | "host-2": []service{{"host-2", 2000, nil}}, 17 | "host-3": []service{{"host-3", 3000, []string{"A", "B", "C"}}}, 18 | } 19 | 20 | server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 21 | if !strings.HasPrefix(req.URL.Path, "/v1/catalog/service/") { 22 | t.Error("invalid path:", req.URL.Path) 23 | res.WriteHeader(http.StatusInternalServerError) 24 | return 25 | } 26 | 27 | name := path.Base(req.URL.Path) 28 | srv, _ := services[name] 29 | ret := []map[string]interface{}{} 30 | 31 | for _, s := range srv { 32 | ret = append(ret, map[string]interface{}{ 33 | "Address": s.host, 34 | "ServicePort": s.port, 35 | "ServiceTags": s.tags, 36 | }) 37 | } 38 | 39 | json.NewEncoder(res).Encode(ret) 40 | })) 41 | defer server.Close() 42 | 43 | rslv := consulResolver{ 44 | address: server.URL, 45 | } 46 | 47 | for _, name := range []string{"host-1", "host-2", "host-3"} { 48 | t.Run(name, func(t *testing.T) { 49 | srv, err := rslv.resolve(name) 50 | if err != nil { 51 | t.Error(err) 52 | } else if !reflect.DeepEqual(srv, services[name]) { 53 | t.Errorf("%#v != %#v", srv, services[name]) 54 | } 55 | }) 56 | } 57 | 58 | t.Run("missing", func(t *testing.T) { 59 | srv, err := rslv.resolve("") 60 | if err != nil { 61 | t.Error(err) 62 | } else if len(srv) != 0 { 63 | t.Errorf("%#v", srv) 64 | } 65 | }) 66 | 67 | } 68 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | "sync" 10 | "sync/atomic" 11 | "time" 12 | 13 | "github.com/apex/log" 14 | ) 15 | 16 | // The httpServer type is a http handler that proxies requests and uses a 17 | // resolver to lookup the address to which it should send the requests. 18 | type httpServer struct { 19 | domain string 20 | blacklist *blacklist 21 | cache *cache 22 | rslv resolver 23 | join sync.WaitGroup 24 | stop uint32 // atomic flag 25 | } 26 | 27 | type httpServerConfig struct { 28 | stop <-chan struct{} 29 | done chan<- struct{} 30 | rslv resolver 31 | domain string 32 | prefer string 33 | cacheTimeout time.Duration 34 | } 35 | 36 | func newHttpServer(config httpServerConfig) *httpServer { 37 | c := cached(config.cacheTimeout, config.rslv) 38 | b := blacklisted(config.cacheTimeout, c) 39 | s := &httpServer{ 40 | domain: config.domain, 41 | blacklist: b, 42 | cache: c, 43 | rslv: preferred(config.prefer, b), 44 | } 45 | 46 | go func(s *httpServer, stop <-chan struct{}, done chan<- struct{}) { 47 | // Wait for a stop signal, when it arrives the server is marked for 48 | // graceful shutdown and waits for in-flight requests to complete. 49 | // Note that this is not a perfect graceful shutdown and there may still 50 | // be some race conditions where requests are dropped but it's the best 51 | // we can do considering the current net/http API. 52 | <-stop 53 | s.setStopped() 54 | s.join.Wait() 55 | close(done) 56 | }(s, config.stop, config.done) 57 | 58 | return s 59 | } 60 | 61 | func (s *httpServer) ServeHTTP(w http.ResponseWriter, req *http.Request) { 62 | s.join.Add(1) 63 | defer s.join.Done() 64 | 65 | // When the server is stopped we break here returning a 503. 66 | if s.stopped() { 67 | w.Header().Add("Connection", "close") 68 | w.WriteHeader(http.StatusServiceUnavailable) 69 | return 70 | } 71 | 72 | // If this is a request for a protocol upgrade we open a new tcp connection 73 | // to the service. 74 | if len(req.Header.Get("Upgrade")) != 0 { 75 | // TODO: support protocol upgrades 76 | w.WriteHeader(http.StatusNotImplemented) 77 | return 78 | } 79 | 80 | if !strings.HasSuffix(req.Host, s.domain) { 81 | w.WriteHeader(http.StatusServiceUnavailable) 82 | log.WithFields(log.Fields{ 83 | "status": http.StatusServiceUnavailable, 84 | "reason": http.StatusText(http.StatusServiceUnavailable), 85 | "host": req.Host, 86 | "domain": s.domain, 87 | }).Error("the requested host doesn't belong to the domain served by the router") 88 | return 89 | } 90 | 91 | host := req.Host 92 | name := host[:len(host)-len(s.domain)] 93 | clearConnectionFields(req.Header) 94 | clearHopByHopFields(req.Header) 95 | clearRequestMetadata(req) 96 | 97 | // Forward the request to the resolved hostname, connection errors are 98 | // retried on idempotent methods, only if no bytes of the body have been 99 | // transfered yet. 100 | const maxAttempts = 10 101 | var res *http.Response 102 | 103 | body := &httpBodyReader{Reader: req.Body} 104 | req.Body = body 105 | 106 | for attempt := 0; true; attempt++ { 107 | srv, err := s.rslv.resolve(name) 108 | 109 | if err != nil { 110 | w.WriteHeader(http.StatusInternalServerError) 111 | log.WithFields(log.Fields{ 112 | "status": http.StatusInternalServerError, 113 | "reason": http.StatusText(http.StatusInternalServerError), 114 | "host": host, 115 | "error": err, 116 | }).Error("an error was returned by the resolver") 117 | return 118 | } 119 | 120 | if len(srv) == 0 { 121 | w.WriteHeader(http.StatusBadGateway) 122 | log.WithFields(log.Fields{ 123 | "status": http.StatusBadGateway, 124 | "reason": http.StatusText(http.StatusBadGateway), 125 | "host": host, 126 | }).Error("no service returned by the resolver") 127 | return 128 | } 129 | 130 | // Prepare the request to be forwarded to the service. 131 | address := net.JoinHostPort(srv[0].host, strconv.Itoa(srv[0].port)) 132 | req.URL.Scheme = "http" 133 | req.URL.Host = address 134 | req.Header.Set("Forwarded", forwarded(req)) 135 | 136 | if res, err = http.DefaultTransport.RoundTrip(req); err == nil { 137 | break // success 138 | } 139 | 140 | if attempt < maxAttempts && body.n == 0 && idempotent(req.Method) { 141 | // Adding the host to the list of black-listed addresses so it 142 | // doesn't get picked up again for the next retries. 143 | s.blacklist.add(address) 144 | log.WithFields(log.Fields{ 145 | "host": host, 146 | "address": address, 147 | "error": err, 148 | }).Warn("black-listing failing service") 149 | 150 | // Backoff: 0ms, 10ms, 40ms, 90ms ... 1000ms 151 | time.Sleep(time.Duration(attempt*attempt) * 10 * time.Millisecond) 152 | continue 153 | } 154 | 155 | w.WriteHeader(http.StatusBadGateway) 156 | log.WithFields(log.Fields{ 157 | "status": http.StatusBadGateway, 158 | "reason": http.StatusText(http.StatusBadGateway), 159 | "host": host, 160 | "error": err, 161 | }).Error("forwarding the request to the service returned an error") 162 | return 163 | } 164 | 165 | // Configure the response header, remove headers that were not directed at 166 | // the client, add 'Connection: close' if the server is terminating. 167 | hdr := w.Header() 168 | copyHeader(hdr, res.Header) 169 | clearConnectionFields(hdr) 170 | clearHopByHopFields(hdr) 171 | 172 | if s.stopped() { 173 | hdr.Add("Connection", "close") 174 | } 175 | 176 | // Send the response. 177 | w.WriteHeader(res.StatusCode) 178 | copyBytes(w, res.Body) 179 | res.Body.Close() 180 | } 181 | 182 | func (s *httpServer) setStopped() { 183 | atomic.StoreUint32(&s.stop, 1) 184 | } 185 | 186 | func (s *httpServer) stopped() bool { 187 | return atomic.LoadUint32(&s.stop) != 0 188 | } 189 | 190 | type httpBodyReader struct { 191 | io.Reader 192 | n int 193 | } 194 | 195 | func (r *httpBodyReader) Read(b []byte) (n int, err error) { 196 | if n, err = r.Reader.Read(b); n > 0 { 197 | r.n += n 198 | } 199 | return 200 | } 201 | 202 | func (r *httpBodyReader) Close() error { 203 | return nil // don't close request bodies so we can do retries 204 | } 205 | 206 | func idempotent(method string) bool { 207 | switch method { 208 | case "GET", "HEAD", "PUT", "DELETE", "OPTIONS": 209 | return true 210 | } 211 | return false 212 | } 213 | 214 | func copyHeader(to http.Header, from http.Header) { 215 | for field, value := range from { 216 | to[field] = value 217 | } 218 | } 219 | 220 | func clearConnectionFields(hdr http.Header) { 221 | for _, field := range hdr["Connection"] { 222 | hdr.Del(field) 223 | } 224 | } 225 | 226 | func clearHopByHopFields(hdr http.Header) { 227 | for _, field := range [...]string{ 228 | "Connection", 229 | "TE", 230 | "Transfer-Encoding", 231 | "Keep-Alive", 232 | "Proxy-Authorization", 233 | "Proxy-Authentication", 234 | "Upgrade", 235 | } { 236 | hdr.Del(field) 237 | } 238 | } 239 | 240 | func clearRequestMetadata(req *http.Request) { 241 | // These fields are populated by the standard http server implementation but 242 | // don't make sense or are invalid to set on client requests. 243 | req.TransferEncoding = nil 244 | req.Close = false 245 | req.RequestURI = "" 246 | } 247 | 248 | func forwarded(req *http.Request) string { 249 | // TODO: combine with previous Forwarded or X-Forwarded-For header. 250 | return "for=" + quote(req.RemoteAddr) + ";host=" + quote(req.Host) + ";proto=http" 251 | } 252 | 253 | func quote(s string) string { 254 | // TODO: https://tools.ietf.org/html/rfc7230#section-3.2.6 255 | return strconv.QuoteToASCII(s) 256 | } 257 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | _ "net/http/pprof" 8 | "os" 9 | "os/signal" 10 | "strings" 11 | "sync/atomic" 12 | "syscall" 13 | "time" 14 | 15 | "golang.org/x/crypto/ssh/terminal" 16 | 17 | "github.com/apex/log" 18 | "github.com/apex/log/handlers/text" 19 | 20 | "github.com/segmentio/conf" 21 | "github.com/segmentio/ecs-logs-go/apex" 22 | "github.com/segmentio/stats" 23 | "github.com/segmentio/stats/datadog" 24 | "github.com/segmentio/stats/httpstats" 25 | "github.com/segmentio/stats/netstats" 26 | "github.com/segmentio/stats/procstats" 27 | ) 28 | 29 | func init() { 30 | if terminal.IsTerminal(1) { 31 | log.Log = &log.Logger{ 32 | Handler: text.New(os.Stderr), 33 | Level: log.DebugLevel, 34 | } 35 | } else { 36 | log.Log = &log.Logger{ 37 | Handler: apex_ecslogs.NewHandler(os.Stderr), 38 | Level: log.InfoLevel, 39 | } 40 | } 41 | } 42 | 43 | func main() { 44 | config := struct { 45 | BindHTTP string `conf:"bind-http" help:"The network address on which the router will listen for incoming connections"` 46 | BindHealthCheck string `conf:"bind-health-check" help:"The network address on which the router listens for health checks"` 47 | BindPProf string `conf:"bind-pprof" help:"The network address on which router listens for profiling requests"` 48 | Consul string `conf:"consul" help:"The address at which the router can access a consul agent"` 49 | Datadog string `conf:"datadog" help:"The address at which the router will send datadog metrics"` 50 | Domain string `conf:"domain" help:"The domain for which the router will accept requests"` 51 | Prefer string `conf:"prefer" help:"The services with a tag matching the preferred value will be favored by the router"` 52 | 53 | CacheTimeout time.Duration `conf:"cache-timeout" help:"The timeout for cached hostnames"` 54 | DialTimeout time.Duration `conf:"dial-timeout" help:"The timeout for dialing tcp connections"` 55 | ReadTimeout time.Duration `conf:"read-timeout" help:"The timeout for reading http requests"` 56 | WriteTimeout time.Duration `conf:"write-timeout" help:"The timeout for writing http requests"` 57 | IdleTimeout time.Duration `conf:"idle-timeout" help:"The timeout for idle connections"` 58 | ShutdownTimeout time.Duration `conf:"shutdown-timeout" help:"The timeout for shutting down the router"` 59 | 60 | MaxIdleConns int `conf:"max-idle-conns" help:"The maximum number of idle connections kept"` 61 | MaxIdleConnsPerHost int `conf:"max-idle-conns-per-host" help:"The maximum number of idle connections kept per host"` 62 | MaxHeaderBytes int `conf:"max-header-bytes" help:"The maximum number of bytes allowed in http headers"` 63 | EnableCompression bool `conf:"enable-compression" help:"When set the router will ask for compressed payloads"` 64 | }{ 65 | CacheTimeout: 10 * time.Second, 66 | DialTimeout: 10 * time.Second, 67 | ReadTimeout: 30 * time.Second, 68 | WriteTimeout: 30 * time.Second, 69 | IdleTimeout: 90 * time.Second, 70 | ShutdownTimeout: 10 * time.Second, 71 | MaxIdleConns: 10000, 72 | MaxIdleConnsPerHost: 100, 73 | MaxHeaderBytes: 65536, 74 | } 75 | 76 | conf.Load(&config) 77 | 78 | // The datadog client that reports metrics generated by the router. 79 | if len(config.Datadog) != 0 { 80 | dd := datadog.NewClient(datadog.ClientConfig{ 81 | Address: config.Datadog, 82 | }) 83 | defer dd.Close() 84 | log.WithField("address", config.Datadog).Info("using datadog agent for metrics collection") 85 | } 86 | defer procstats.StartCollector(procstats.NewGoMetrics(nil)).Close() 87 | defer procstats.StartCollector(procstats.NewProcMetrics(nil)).Close() 88 | 89 | // Configure the base resolver used by the router to forward requests. 90 | var rslv resolver 91 | switch { 92 | case len(config.Consul) != 0: 93 | rslv = consulResolver{address: config.Consul} 94 | log.WithField("address", config.Consul).Info("using consul agent for service discovery") 95 | default: 96 | rslv = serviceList(nil) 97 | log.Warn("no service discovery backend was configured") 98 | } 99 | 100 | // The domain name served by the router, prefix with '.' so it doesn't have 101 | // to be done over and over in each http request. 102 | domain := config.Domain 103 | if len(domain) != 0 && !strings.HasPrefix(domain, ".") { 104 | domain = "." + domain 105 | } 106 | 107 | // Start the health check server, the status variable is used to report when 108 | // the program is shutting down. 109 | healthStatus := uint32(http.StatusOK) 110 | if len(config.BindHealthCheck) != 0 { 111 | go http.ListenAndServe(config.BindHealthCheck, http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 112 | res.WriteHeader(int(atomic.LoadUint32(&healthStatus))) 113 | })) 114 | log.WithField("address", config.BindHealthCheck).Info("started health check server") 115 | } 116 | 117 | // Start hte profiler server. 118 | if len(config.BindPProf) != 0 { 119 | go http.ListenAndServe(config.BindPProf, nil) 120 | log.WithField("address", config.BindPProf).Info("started profiling server") 121 | } 122 | 123 | // Configure the default http transport which is used for forwarding the requests. 124 | http.DefaultTransport = httpstats.NewTransport(nil, &http.Transport{ 125 | DialContext: dialer(config.DialTimeout), 126 | IdleConnTimeout: config.IdleTimeout, 127 | MaxIdleConns: config.MaxIdleConns, 128 | MaxIdleConnsPerHost: config.MaxIdleConnsPerHost, 129 | ResponseHeaderTimeout: config.ReadTimeout, 130 | ExpectContinueTimeout: config.ReadTimeout, 131 | MaxResponseHeaderBytes: int64(config.MaxHeaderBytes), 132 | DisableCompression: !config.EnableCompression, 133 | }) 134 | 135 | // Configure and run the http server. 136 | var httpLstn net.Listener 137 | var httpStop chan struct{} 138 | var httpDone chan struct{} 139 | var err error 140 | 141 | if len(config.BindHTTP) != 0 { 142 | if httpLstn, err = net.Listen("tcp", config.BindHTTP); err != nil { 143 | log.WithFields(log.Fields{ 144 | "address": config.BindHTTP, 145 | "error": err, 146 | }).Fatal("failed to bind tcp address for http server") 147 | } 148 | 149 | httpLstn = netstats.NewListener(nil, httpLstn, stats.Tag{"side", "frontend"}) 150 | httpStop = make(chan struct{}) 151 | httpDone = make(chan struct{}) 152 | 153 | go func() { 154 | if err := (&http.Server{ 155 | ReadTimeout: config.ReadTimeout, 156 | WriteTimeout: config.WriteTimeout, 157 | MaxHeaderBytes: config.MaxHeaderBytes, 158 | Handler: httpstats.NewHandler(nil, newHttpServer(httpServerConfig{ 159 | stop: httpStop, 160 | done: httpDone, 161 | rslv: rslv, 162 | domain: domain, 163 | prefer: config.Prefer, 164 | cacheTimeout: config.CacheTimeout, 165 | })), 166 | }).Serve(httpLstn); err != nil && atomic.LoadUint32(&healthStatus) == http.StatusOK { 167 | log.WithError(err).Fatal("failed to serve http requests") 168 | } 169 | }() 170 | 171 | log.WithField("address", config.BindHTTP).Info("started http server") 172 | } 173 | 174 | // Gracefully shutdown when receiving a signal: 175 | // - set the health check status to 503 176 | // - close tcp connections 177 | // - wait for in-flight requests to complete 178 | sigchan := make(chan os.Signal) 179 | signal.Notify(sigchan, syscall.SIGINT, syscall.SIGTERM) 180 | 181 | sig := <-sigchan 182 | log.WithField("signal", sig).Info("shutting down") 183 | atomic.StoreUint32(&healthStatus, http.StatusServiceUnavailable) 184 | 185 | if httpLstn != nil { 186 | httpLstn.Close() 187 | close(httpStop) 188 | } 189 | 190 | for httpDone != nil { 191 | select { 192 | case <-time.After(config.ShutdownTimeout): 193 | return 194 | case <-sigchan: 195 | return 196 | case <-httpDone: 197 | httpDone = nil 198 | } 199 | } 200 | } 201 | 202 | func dialer(timeout time.Duration) func(context.Context, string, string) (net.Conn, error) { 203 | dialer := &net.Dialer{ 204 | Timeout: timeout, 205 | } 206 | return func(ctx context.Context, network string, address string) (net.Conn, error) { 207 | conn, err := dialer.DialContext(ctx, network, address) 208 | if conn != nil { 209 | conn = netstats.NewConn(nil, conn, stats.Tag{"side", "backend"}) 210 | } 211 | return conn, err 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /prefer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "sort" 4 | 5 | // The preferred function is a resolver decorator that orders the service list 6 | // where service with a matching tag will come first. 7 | // 8 | // An empty tag is interpreted as not filtering at all. 9 | func preferred(tag string, rslv resolver) resolver { 10 | if len(tag) == 0 { 11 | return rslv 12 | } 13 | return resolverFunc(func(name string) (srv []service, err error) { 14 | if srv, err = rslv.resolve(name); err != nil { 15 | return 16 | } 17 | // Using a stable sort is important to preserve the previous service 18 | // list order among preferred and non-preferred entries. 19 | sort.Stable(preferredServices{tag, srv}) 20 | return 21 | }) 22 | } 23 | 24 | type preferredServices struct { 25 | tag string 26 | srv []service 27 | } 28 | 29 | func (s preferredServices) Len() int { 30 | return len(s.srv) 31 | } 32 | 33 | func (s preferredServices) Swap(i int, j int) { 34 | s.srv[i], s.srv[j] = s.srv[j], s.srv[i] 35 | } 36 | 37 | func (s preferredServices) Less(i int, j int) bool { 38 | m1 := matchPreferred(s.tag, s.srv[i]) 39 | m2 := matchPreferred(s.tag, s.srv[j]) 40 | return m1 != m2 && m1 41 | } 42 | 43 | func matchPreferred(tag string, srv service) bool { 44 | for _, t := range srv.tags { 45 | if t == tag { 46 | return true 47 | } 48 | } 49 | return false 50 | } 51 | -------------------------------------------------------------------------------- /prefer_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | var preferredTests = []struct { 9 | tag string 10 | srv []service 11 | res []service 12 | }{ 13 | { 14 | tag: "", 15 | srv: []service{{"host-1", 1000, nil}, {"host-2", 2000, []string{"C"}}, {"host-3", 3000, []string{"A", "C"}}}, 16 | res: []service{{"host-1", 1000, nil}, {"host-2", 2000, []string{"C"}}, {"host-3", 3000, []string{"A", "C"}}}, 17 | }, 18 | { 19 | tag: "A", 20 | srv: []service{{"host-1", 1000, nil}, {"host-2", 2000, []string{"C"}}, {"host-3", 3000, []string{"A", "C"}}}, 21 | res: []service{{"host-3", 3000, []string{"A", "C"}}, {"host-1", 1000, nil}, {"host-2", 2000, []string{"C"}}}, 22 | }, 23 | { 24 | tag: "B", 25 | srv: []service{{"host-1", 1000, nil}, {"host-2", 2000, []string{"C"}}, {"host-3", 3000, []string{"A", "C"}}}, 26 | res: []service{{"host-1", 1000, nil}, {"host-2", 2000, []string{"C"}}, {"host-3", 3000, []string{"A", "C"}}}, 27 | }, 28 | { 29 | tag: "C", 30 | srv: []service{{"host-1", 1000, nil}, {"host-2", 2000, []string{"C"}}, {"host-3", 3000, []string{"A", "C"}}}, 31 | res: []service{{"host-2", 2000, []string{"C"}}, {"host-3", 3000, []string{"A", "C"}}, {"host-1", 1000, nil}}, 32 | }, 33 | } 34 | 35 | func TestPrefer(t *testing.T) { 36 | for _, test := range preferredTests { 37 | t.Run(test.tag, func(t *testing.T) { 38 | prefer := preferred(test.tag, serviceList(test.srv)) 39 | 40 | srv, err := prefer.resolve("anything") 41 | 42 | if err != nil { 43 | t.Error(err) 44 | } 45 | 46 | if !reflect.DeepEqual(srv, test.res) { 47 | t.Errorf("\n%#v\n%#v", srv, test.res) 48 | } 49 | }) 50 | } 51 | } 52 | 53 | func BenchmarkPrefer(b *testing.B) { 54 | for _, test := range preferredTests { 55 | prefer := preferred(test.tag, serviceList(test.srv)) 56 | 57 | b.Run(test.tag, func(b *testing.B) { 58 | for i := 0; i != b.N; i++ { 59 | prefer.resolve("anything") 60 | } 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /resolv.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // The resolver interface is implemented by diverse components involved in 4 | // service name resolution. 5 | type resolver interface { 6 | // The resolve method translates a service name into a list of service 7 | // endpoints that the program can use to forward requests to. 8 | // 9 | // The method should return service endpoints sorted with the best candidate 10 | // coming first, the program is expected to chose the first service endpoint 11 | // of the result list. 12 | // 13 | // If the name cannot be resolved because it could not be found the method 14 | // should return an empty service list, errors should be kept for runtime 15 | // issues that prevented the resolver from completing the request. 16 | resolve(name string) (srv []service, err error) 17 | } 18 | 19 | // The resolverFunc type implements the resolver interface and makes it possible 20 | // for simple functions to be used as resolvers. 21 | type resolverFunc func(string) ([]service, error) 22 | 23 | func (f resolverFunc) resolve(name string) ([]service, error) { 24 | return f(name) 25 | } 26 | 27 | // The service structure represent an endpoint that the program uses to forward 28 | // requests to. 29 | type service struct { 30 | host string 31 | port int 32 | tags []string 33 | } 34 | 35 | // copyServices returns a slice backed by a new array which is a copy of srv, 36 | // note that only a shallow copy is performed. 37 | func copyServices(srv []service) []service { 38 | cpy := make([]service, len(srv)) 39 | copy(cpy, srv) 40 | return cpy 41 | } 42 | 43 | // The serviceList type implements the resolver interface but always returns the 44 | // same set of services, it's mostly intended to be used for tests. 45 | type serviceList []service 46 | 47 | func (s serviceList) resolve(name string) ([]service, error) { 48 | return copyServices(s), nil 49 | } 50 | 51 | // The serviceMap type implements the resolver interface and provides a simple 52 | // associative mapping between service names and service endpoints, it's mostly 53 | // intended to be used for tests. 54 | type serviceMap map[string][]service 55 | 56 | func (s serviceMap) resolve(name string) ([]service, error) { 57 | return copyServices(s[name]), nil 58 | } 59 | -------------------------------------------------------------------------------- /shuffle.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "math/rand" 4 | 5 | // The shuffled function is a resolver decorator that randomizes the list of 6 | // services to provide a basic for of load balancing between the hosts. 7 | func shuffled(rslv resolver) resolver { 8 | return resolverFunc(func(name string) (srv []service, err error) { 9 | if srv, err = rslv.resolve(name); err != nil { 10 | return 11 | } 12 | 13 | for i := range srv { 14 | j := rand.Intn(i + 1) 15 | srv[i], srv[j] = srv[j], srv[i] 16 | } 17 | 18 | return 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /shuffle_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "testing" 7 | ) 8 | 9 | func BenchmarkShuffle(b *testing.B) { 10 | for _, size := range [...]int{1, 10, 100, 1000} { 11 | names, services := make([]string, size), make(serviceMap, size) 12 | 13 | for i := 0; i != size; i++ { 14 | name := fmt.Sprintf("host-%d", i+1) 15 | names[i] = name 16 | services[name] = []service{{name, 4242, nil}} 17 | } 18 | 19 | shuffle := shuffled(services) 20 | 21 | b.Run(strconv.Itoa(size), func(b *testing.B) { 22 | for i := 0; i != b.N; i++ { 23 | shuffle.resolve(names[i%len(names)]) 24 | } 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | consul: 5 | image: consul:latest 6 | command: agent -server -dev -ui -bind 127.0.0.1 -log-level info 7 | network_mode: host 8 | 9 | registrator: 10 | image: gliderlabs/registrator:latest 11 | command: -ip 127.0.0.1 -cleanup -resync 10 -retry-attempts 20 -retry-interval 1000 consul://127.0.0.1:8500 12 | volumes: 13 | - /var/run/docker.sock:/tmp/docker.sock 14 | depends_on: 15 | - consul 16 | network_mode: host 17 | 18 | nginx-1: 19 | image: nginx:alpine 20 | command: nginx -c /etc/nginx/nginx.conf 21 | ports: 22 | - 127.0.0.1:0:80 23 | volumes: 24 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 25 | - ./files/hello-1.html:/etc/nginx/files/hello.html:ro 26 | depends_on: 27 | - registrator 28 | environment: 29 | SERVICE_NAME: nginx 30 | SERVICE_CHECK_HTTP: /health 31 | SERVICE_CHECK_INTERVAL: 10s 32 | SERVICE_CHECK_TIMEOUT: 1s 33 | SERVICE_TAGS: us-west-2a 34 | 35 | nginx-2: 36 | image: nginx:alpine 37 | command: nginx -c /etc/nginx/nginx.conf 38 | ports: 39 | - 127.0.0.1:0:80 40 | volumes: 41 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 42 | - ./files/hello-2.html:/etc/nginx/files/hello.html:ro 43 | depends_on: 44 | - registrator 45 | environment: 46 | SERVICE_NAME: nginx 47 | SERVICE_CHECK_HTTP: /health 48 | SERVICE_CHECK_INTERVAL: 10s 49 | SERVICE_CHECK_TIMEOUT: 1s 50 | SERVICE_TAGS: us-west-2a 51 | 52 | nginx-3: 53 | image: nginx:alpine 54 | command: nginx -c /etc/nginx/nginx.conf 55 | ports: 56 | - 127.0.0.1:0:80 57 | volumes: 58 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 59 | - ./files/hello-3.html:/etc/nginx/files/hello.html:ro 60 | depends_on: 61 | - registrator 62 | environment: 63 | SERVICE_NAME: nginx 64 | SERVICE_CHECK_HTTP: /health 65 | SERVICE_CHECK_INTERVAL: 10s 66 | SERVICE_CHECK_TIMEOUT: 1s 67 | SERVICE_TAGS: us-west-2b 68 | 69 | consul-router-1: 70 | image: segment/consul-router:latest 71 | command: -bind-http :4000 -bind-pprof :6000 -consul 127.0.0.1:8500 -domain segment.local 72 | depends_on: 73 | - nginx-1 74 | - nginx-2 75 | - nginx-3 76 | network_mode: host 77 | 78 | consul-router-2: 79 | image: segment/consul-router:latest 80 | command: -bind-http :4001 -bind-pprof :6001 -consul 127.0.0.1:8500 -domain segment.local -prefer us-west-2a 81 | depends_on: 82 | - nginx-1 83 | - nginx-2 84 | - nginx-3 85 | network_mode: host 86 | 87 | consul-router-3: 88 | image: segment/consul-router:latest 89 | command: -bind-http :4002 -bind-pprof :6002 -consul 127.0.0.1:8500 -domain segment.local -prefer us-west-2b 90 | depends_on: 91 | - nginx-1 92 | - nginx-2 93 | - nginx-3 94 | network_mode: host 95 | -------------------------------------------------------------------------------- /tests/files/hello-1.html: -------------------------------------------------------------------------------- 1 | hello-1 2 | -------------------------------------------------------------------------------- /tests/files/hello-2.html: -------------------------------------------------------------------------------- 1 | hello-2 2 | -------------------------------------------------------------------------------- /tests/files/hello-3.html: -------------------------------------------------------------------------------- 1 | hello-3 2 | -------------------------------------------------------------------------------- /tests/nginx.conf: -------------------------------------------------------------------------------- 1 | daemon off; 2 | error_log stderr debug; 3 | 4 | worker_processes 1; 5 | worker_rlimit_nofile 8192; 6 | 7 | events { 8 | worker_connections 4096; 9 | } 10 | 11 | http { 12 | sendfile on; 13 | tcp_nopush on; 14 | 15 | proxy_redirect off; 16 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 17 | client_max_body_size 1m; 18 | client_body_buffer_size 128k; 19 | proxy_connect_timeout 5; 20 | proxy_send_timeout 5; 21 | proxy_read_timeout 5; 22 | proxy_buffers 32 4k; 23 | 24 | server { 25 | listen 80; 26 | root /etc/nginx/files; 27 | 28 | location / { 29 | } 30 | 31 | location /health { 32 | return 200; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /vendor/vendor.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": "", 3 | "ignore": "test", 4 | "package": [ 5 | { 6 | "checksumSHA1": "Ur88QI//9Ue82g83qvBSakGlzVg=", 7 | "path": "github.com/apex/log", 8 | "revision": "4ea85e918cc8389903d5f12d7ccac5c23ab7d89b", 9 | "revisionTime": "2016-09-05T15:13:04Z" 10 | }, 11 | { 12 | "checksumSHA1": "o5a5xWoaGDKEnNy0W7TikB66lMc=", 13 | "path": "github.com/apex/log/handlers/text", 14 | "revision": "4ea85e918cc8389903d5f12d7ccac5c23ab7d89b", 15 | "revisionTime": "2016-09-05T15:13:04Z" 16 | }, 17 | { 18 | "checksumSHA1": "vMT+piher66IruU9TZNbL/6dYJ8=", 19 | "path": "github.com/ghodss/yaml", 20 | "revision": "a54de18a07046d8c4b26e9327698a2ebb9285b36", 21 | "revisionTime": "2016-11-23T02:24:14Z" 22 | }, 23 | { 24 | "checksumSHA1": "jC9wl7o3INJq1Oaz7sZ1ujJ0GAI=", 25 | "path": "github.com/segmentio/conf", 26 | "revision": "4aeb4f87d968f6d5d7a4c01ce9e3c9b7f977c932", 27 | "revisionTime": "2016-12-07T00:22:14Z" 28 | }, 29 | { 30 | "checksumSHA1": "exBQJLaBXogorrNlmjdaVifVdpc=", 31 | "path": "github.com/segmentio/ecs-logs-go", 32 | "revision": "49d047bbc69ccb12dfc6ad91e763d8f817550012", 33 | "revisionTime": "2016-07-26T19:29:10Z" 34 | }, 35 | { 36 | "checksumSHA1": "bdRkX6PW5jL8fPYFPKRIkxctTRU=", 37 | "path": "github.com/segmentio/ecs-logs-go/apex", 38 | "revision": "49d047bbc69ccb12dfc6ad91e763d8f817550012", 39 | "revisionTime": "2016-07-26T19:29:10Z" 40 | }, 41 | { 42 | "checksumSHA1": "mdF4jxevxRCzHFMdVuqscGxdzHk=", 43 | "path": "github.com/segmentio/jutil", 44 | "revision": "4585c8bf761df9c67b34bf75e9fc62f0a3771198", 45 | "revisionTime": "2016-08-02T17:54:54Z" 46 | }, 47 | { 48 | "checksumSHA1": "bMkycyRW1VueXtRZHfs7crHuoeY=", 49 | "path": "github.com/segmentio/stats", 50 | "revision": "a0055de693bd723cf8076f278c810d4f590b6a04", 51 | "revisionTime": "2016-12-03T03:10:38Z" 52 | }, 53 | { 54 | "checksumSHA1": "ze4KIJzPfimbfzVRgJUE4czbpQU=", 55 | "path": "github.com/segmentio/stats/datadog", 56 | "revision": "a0055de693bd723cf8076f278c810d4f590b6a04", 57 | "revisionTime": "2016-12-03T03:10:38Z" 58 | }, 59 | { 60 | "checksumSHA1": "2oS0jMh05BSDvhlzkKMFk4oF1+k=", 61 | "path": "github.com/segmentio/stats/httpstats", 62 | "revision": "a0055de693bd723cf8076f278c810d4f590b6a04", 63 | "revisionTime": "2016-12-03T03:10:38Z" 64 | }, 65 | { 66 | "checksumSHA1": "poEpVOWzVNefnfJMu+Z6QgzXueQ=", 67 | "path": "github.com/segmentio/stats/iostats", 68 | "revision": "a0055de693bd723cf8076f278c810d4f590b6a04", 69 | "revisionTime": "2016-12-03T03:10:38Z" 70 | }, 71 | { 72 | "checksumSHA1": "WEN9ElYBcUzXEFpAIMj5Qn2OeiI=", 73 | "path": "github.com/segmentio/stats/netstats", 74 | "revision": "a0055de693bd723cf8076f278c810d4f590b6a04", 75 | "revisionTime": "2016-12-03T03:10:38Z" 76 | }, 77 | { 78 | "checksumSHA1": "qcEGeFAwb/6reryrBdJe9Sm6HnQ=", 79 | "path": "github.com/segmentio/stats/procstats", 80 | "revision": "a0055de693bd723cf8076f278c810d4f590b6a04", 81 | "revisionTime": "2016-12-03T03:10:38Z" 82 | }, 83 | { 84 | "checksumSHA1": "JYchMBzqbuql1ZMb4qspRLLm9/w=", 85 | "path": "github.com/segmentio/stats/procstats/linux", 86 | "revision": "a0055de693bd723cf8076f278c810d4f590b6a04", 87 | "revisionTime": "2016-12-03T03:10:38Z" 88 | }, 89 | { 90 | "checksumSHA1": "9C4Av3ypK5pi173F76ogJT/d8x4=", 91 | "path": "golang.org/x/crypto/ssh/terminal", 92 | "revision": "ede567c8e044a5913dad1d1af3696d9da953104c", 93 | "revisionTime": "2016-11-04T19:41:44Z" 94 | }, 95 | { 96 | "checksumSHA1": "MlTI84eWAFvqeRgXxBtjRYHk1yQ=", 97 | "path": "golang.org/x/sys/unix", 98 | "revision": "30237cf4eefd639b184d1f2cb77a581ea0be8947", 99 | "revisionTime": "2016-11-19T15:29:01Z" 100 | }, 101 | { 102 | "checksumSHA1": "12GqsW8PiRPnezDDy0v4brZrndM=", 103 | "path": "gopkg.in/yaml.v2", 104 | "revision": "a5b47d31c556af34a302ce5d659e6fea44d90de0", 105 | "revisionTime": "2016-09-28T15:37:09Z" 106 | } 107 | ], 108 | "rootPath": "github.com/segmentio/consul-router" 109 | } 110 | --------------------------------------------------------------------------------