Skip to content

Commit

Permalink
Merge pull request hudl#5 from hudl/discoverDNS
Browse files Browse the repository at this point in the history
Allow the use of DNS discovery for eureka connections
  • Loading branch information
ryansb committed Jun 19, 2014
2 parents c9b29ad + f67c167 commit 5f63bca
Show file tree
Hide file tree
Showing 5 changed files with 197 additions and 5 deletions.
1 change: 1 addition & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type eureka struct {
InTheCloud bool // default false
ConnectTimeoutSeconds int // default 10s
UseDNSForServiceUrls bool // default false
DNSDiscoveryZone string // default ""
ServerDNSName string // default ""
ServiceUrls []string // default []
ServerPort int // default 7001
Expand Down
32 changes: 27 additions & 5 deletions connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,27 @@ func init() {
// balancing scheme.
// TODO: Make this not just pick a random one.
func (e *EurekaConnection) SelectServiceURL() string {
return e.ServiceUrls[rand.Int()%len(e.ServiceUrls)]
if e.discoveryTtl == nil {
e.discoveryTtl = make(chan struct{}, 1)
}
if e.DNSDiscovery && len(e.discoveryTtl) == 0 {
servers, ttl, err := discoverDNS(e.DiscoveryZone, e.ServicePort)
if err != nil {
return choice(e.ServiceUrls)
}
e.discoveryTtl <- struct{}{}
time.AfterFunc(ttl, func() {
// At the end of the timeout, empty the channel so that the next
// SelectServiceURL call will refresh the DNS info
<-e.discoveryTtl
})
e.ServiceUrls = servers
}
return choice(e.ServiceUrls)
}

func choice(options []string) string {
return options[rand.Int()%len(options)]
}

// NewConnFromConfigFile sets up a connection object based on a config in
Expand All @@ -32,17 +52,19 @@ func NewConnFromConfigFile(location string) (c EurekaConnection, err error) {
// NewConnFromConfig will, given a Config struct, return a connection based on
// those options
func NewConnFromConfig(conf Config) (c EurekaConnection) {
if conf.Eureka.UseDNSForServiceUrls {
//TODO: Read service urls from DNS TXT records
log.Critical("ERROR: UseDNSForServiceUrls option unsupported.")
}
c.ServiceUrls = conf.Eureka.ServiceUrls
c.ServicePort = conf.Eureka.ServerPort
if len(c.ServiceUrls) == 0 && len(conf.Eureka.ServerDNSName) > 0 {
c.ServiceUrls = []string{conf.Eureka.ServerDNSName}
}
c.Timeout = time.Duration(conf.Eureka.ConnectTimeoutSeconds) * time.Second
c.PollInterval = time.Duration(conf.Eureka.PollIntervalSeconds) * time.Second
c.PreferSameZone = conf.Eureka.PreferSameZone
if conf.Eureka.UseDNSForServiceUrls {
log.Warning("UseDNSForServiceUrls is an experimental option")
c.DNSDiscovery = true
c.DiscoveryZone = conf.Eureka.DNSDiscoveryZone
}
return c
}

Expand Down
106 changes: 106 additions & 0 deletions dns_discover.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package fargo

// MIT Licensed (see README.md) - Copyright (c) 2013 Hudl <@Hudl>

import (
"fmt"
"github.com/franela/goreq"
"github.com/miekg/dns"
"time"
)

const azURL = "http://169.254.169.254/latest/meta-data/placement/availability-zone"

var ErrNotInAWS = fmt.Errorf("Not in AWS")

func discoverDNS(domain string, port int) (servers []string, ttl time.Duration, err error) {
r, _ := region()

// all DNS queries must use the FQDN
domain = "txt." + r + "." + dns.Fqdn(domain)
if _, ok := dns.IsDomainName(domain); !ok {
err = fmt.Errorf("invalid domain name: '%s' is not a domain name", domain)
return
}
regionRecords, ttl, err := findTXT(domain)
if err != nil {
return
}

for _, az := range regionRecords {
instances, _, er := findTXT("txt." + dns.Fqdn(az))
if er != nil {
continue
}
for _, instance := range instances {
// format the service URL
servers = append(servers, fmt.Sprintf("http://%s:%d/eureka/v2", instance, port))
}
}
return
}

func findTXT(fqdn string) ([]string, time.Duration, error) {
defaultTTL := 120 * time.Second
query := new(dns.Msg)
query.SetQuestion(fqdn, dns.TypeTXT)
response, err := dns.Exchange(query, dnsServerAddr)
if err != nil {
log.Error("Failure resolving name %s err=%s", fqdn, err.Error())
return nil, defaultTTL, err
}
if len(response.Answer) < 1 {
err := fmt.Errorf("no Eureka discovery TXT record returned for name=%s", fqdn)
log.Error("no answer for name=%s err=%s", fqdn, err.Error())
return nil, defaultTTL, err
}
if response.Answer[0].Header().Rrtype != dns.TypeTXT {
err := fmt.Errorf("did not receive TXT record back from query specifying TXT record. This should never happen.")
log.Error("Failure resolving name %s err=%s", fqdn, err.Error())
return nil, defaultTTL, err
}
txt := response.Answer[0].(*dns.TXT)
ttl := response.Answer[0].Header().Ttl
if ttl < 60 {
ttl = 60
}

return txt.Txt, time.Duration(ttl) * time.Second, nil
}

var dnsServerAddr string

func init() {
// Find a DNS server using the OS resolv.conf
config, _ := dns.ClientConfigFromFile("/etc/resolv.conf")
dnsServerAddr = config.Servers[0] + ":" + config.Port
}

func region() (string, error) {
zone, err := availabilityZone()
if err != nil {
log.Error("Could not retrieve availability zone err=%s", err.Error())
return "us-east-1", err
}
return zone[:len(zone)-1], nil
}

// defaults to us-east-1 if there's a problem
func availabilityZone() (string, error) {
response, err := goreq.Request{Uri: azURL}.Do()
if err != nil {
return "", err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
body, _ := response.Body.ToString()
return "", fmt.Errorf("bad response code: code %d does not indicate successful request, body=%s",
response.StatusCode,
body,
)
}
zone, err := response.Body.ToString()
if err != nil {
return "", err
}
return zone[:len(zone)-1], nil
}
59 changes: 59 additions & 0 deletions dns_discover_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package fargo

// MIT Licensed (see README.md) - Copyright (c) 2013 Hudl <@Hudl>

import (
. "github.com/smartystreets/goconvey/convey"
"testing"
"time"
)

func TestGetNXDomain(t *testing.T) {
Convey("Given nonexistent domain nxd.local.", t, func() {
resp, _, err := findTXT("nxd.local.")
So(err, ShouldNotBeNil)
So(len(resp), ShouldEqual, 0)
})
}

func TestGetNetflixTestDomain(t *testing.T) {
Convey("Given domain txt.us-east-1.discoverytest.netflix.net.", t, func() {
// TODO: use a mock DNS server to eliminate dependency on netflix
// keeping their discoverytest domain up
resp, ttl, err := findTXT("txt.us-east-1.discoverytest.netflix.net.")
So(err, ShouldBeNil)
So(ttl, ShouldEqual, 60*time.Second)
So(len(resp), ShouldEqual, 3)
Convey("And the contents are zone records", func() {
expected := map[string]bool{
"us-east-1c.us-east-1.discoverytest.netflix.net": true,
"us-east-1d.us-east-1.discoverytest.netflix.net": true,
"us-east-1e.us-east-1.discoverytest.netflix.net": true,
}
for _, item := range resp {
_, ok := expected[item]
So(ok, ShouldEqual, true)
}
Convey("And the zone records contain instances", func() {
for _, record := range resp {
servers, _, err := findTXT("txt." + record + ".")
So(err, ShouldBeNil)
So(len(servers) >= 1, ShouldEqual, true)
// servers should be EC2 DNS names
So(servers[0][0:4], ShouldEqual, "ec2-")
}
})
})
})
Convey("Autodiscover discoverytest.netflix.net.", t, func() {
servers, ttl, err := discoverDNS("discoverytest.netflix.net", 7001)
So(ttl, ShouldEqual, 60*time.Second)
So(err, ShouldBeNil)
So(len(servers), ShouldEqual, 4)
Convey("Servers discovered should all be EC2 DNS names", func() {
for _, s := range servers {
So(s[0:11], ShouldEqual, "http://ec2-")
}
})
})
}
4 changes: 4 additions & 0 deletions struct.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@ var EurekaURLSlugs = map[string]string{
// EurekaConnection is the settings required to make eureka requests
type EurekaConnection struct {
ServiceUrls []string
ServicePort int
Timeout time.Duration
PollInterval time.Duration
PreferSameZone bool
Retries int
DNSDiscovery bool
DiscoveryZone string
discoveryTtl chan struct{}
UseJson bool
}

Expand Down

0 comments on commit 5f63bca

Please sign in to comment.