Bladeren bron

Add 'closest' flag

This changeset adds a new boolean option 'closest' to the zone options
and to the label options. The label option defaults to the zone option
for 'closest'.

When closest is set on a label within a zone, each A record has its
location determined and stored in memory. When a query is received,
after the targeting process is performed, the geographically closest
group of A records are selected prior to the weight algorithm being
applied.

In practice this means you can use a simple technique (set 'closest'
to be "true") to choose the geographically closest servers. Note
that geographic proximity may not correlate perfectly to topological
proximity.

Note that when 'closest' is selected on any label within the zone,
the GEOIP_CITY_EDITION_REV1 geoip data is required, as that is what
contains latitude and longitude data.

No provision is currently made for servers that change longitude
and latitude (most likely due to a geoip data change) during the
runtime life of the server.

Signed-off-by: Alex Bligh <[email protected]>
Alex Bligh 10 jaren geleden
bovenliggende
commit
6526d6b229
7 gewijzigde bestanden met toevoegingen van 148 en 19 verwijderingen
  1. 26 1
      geoip.go
  2. 55 2
      picker.go
  3. 12 3
      serve.go
  4. 5 6
      targeting.go
  5. 5 5
      targeting_test.go
  6. 29 1
      zone.go
  7. 16 1
      zones.go

+ 26 - 1
geoip.go

@@ -3,7 +3,9 @@ package main
 import (
 	"github.com/abh/geodns/Godeps/_workspace/src/github.com/abh/geoip"
 	"github.com/abh/geodns/countries"
+	"github.com/golang/geo/s2"
 	"log"
+	"math"
 	"net"
 	"strings"
 	"time"
@@ -23,6 +25,27 @@ type GeoIP struct {
 	asnLastLoad time.Time
 }
 
+const MAX_DISTANCE = 360
+
+type Location struct {
+	latitude  float64
+	longitude float64
+}
+
+func (l *Location) MaxDistance() float64 {
+	return MAX_DISTANCE
+}
+
+func (l *Location) Distance(to *Location) float64 {
+	if to == nil {
+		return MAX_DISTANCE
+	}
+	ll1 := s2.LatLngFromDegrees(l.latitude, l.longitude)
+	ll2 := s2.LatLngFromDegrees(to.latitude, to.longitude)
+	angle := ll1.Distance(ll2)
+	return math.Abs(angle.Degrees())
+}
+
 var geoIP = new(GeoIP)
 
 func (g *GeoIP) GetCountry(ip net.IP) (country, continent string, netmask int) {
@@ -38,7 +61,7 @@ func (g *GeoIP) GetCountry(ip net.IP) (country, continent string, netmask int) {
 	return
 }
 
-func (g *GeoIP) GetCountryRegion(ip net.IP) (country, continent, regionGroup, region string, netmask int) {
+func (g *GeoIP) GetCountryRegion(ip net.IP) (country, continent, regionGroup, region string, netmask int, location *Location) {
 	if g.city == nil {
 		log.Println("No city database available")
 		country, continent, netmask = g.GetCountry(ip)
@@ -50,6 +73,8 @@ func (g *GeoIP) GetCountryRegion(ip net.IP) (country, continent, regionGroup, re
 		return
 	}
 
+	location = &Location{float64(record.Latitude), float64(record.Longitude)}
+
 	country = record.CountryCode
 	region = record.Region
 	if len(country) > 0 {

+ 55 - 2
picker.go

@@ -6,13 +6,13 @@ import (
 	"github.com/abh/geodns/Godeps/_workspace/src/github.com/miekg/dns"
 )
 
-func (label *Label) Picker(qtype uint16, max int) Records {
+func (label *Label) Picker(qtype uint16, max int, location *Location) Records {
 
 	if qtype == dns.TypeANY {
 		var result []Record
 		for rtype := range label.Records {
 
-			rtypeRecords := label.Picker(rtype, max)
+			rtypeRecords := label.Picker(rtype, max, location)
 
 			tmpResult := make(Records, len(result)+len(rtypeRecords))
 
@@ -45,6 +45,59 @@ func (label *Label) Picker(qtype uint16, max int) Records {
 		result := make([]Record, max)
 		sum := label.Weight[qtype]
 
+		// Find the distance to each server, and find the servers that are
+		// closer to the querier than the max'th furthest server, or within
+		// 5% thereof. What this means in practice is that if we have a nearby
+		// cluster of servers that are close, they all get included, so load
+		// balancing works
+		if qtype == dns.TypeA && location != nil && max < rrCount {
+			// First we record the distance to each server
+			distances := make([]float64, rrCount)
+			for i, s := range servers {
+				distance := location.Distance(s.Loc)
+				distances[i] = distance
+			}
+
+			// though this looks like O(n^2), typically max is small (e.g. 2)
+			// servers often have the same geographic location
+			// and rrCount is pretty small too, so the gain of an
+			// O(n log n) sort is small.
+			chosen := 0
+			choose := make([]bool, rrCount)
+
+			for chosen < max {
+				// Determine the minimum distance of servers not yet chosen
+				minDist := location.MaxDistance()
+				for i, _ := range servers {
+					if !choose[i] && distances[i] <= minDist {
+						minDist = distances[i]
+					}
+				}
+				// The threshold for inclusion on the this pass is 5% more
+				// than the minimum distance
+				minDist = minDist * 1.05
+				// Choose all the servers within the distance
+				for i := range servers {
+					if !choose[i] && distances[i] <= minDist {
+						choose[i] = true
+						chosen++
+					}
+				}
+			}
+
+			// Now choose only the chosen servers, using filtering without allocation
+			// slice trick. Meanwhile recalculate the total weight
+			tmpServers := servers[:0]
+			sum = 0
+			for i, s := range servers {
+				if choose[i] {
+					tmpServers = append(tmpServers, s)
+					sum += s.Weight
+				}
+			}
+			servers = tmpServers
+		}
+
 		for si := 0; si < max; si++ {
 			n := rand.Intn(sum + 1)
 			s := 0

+ 12 - 3
serve.go

@@ -81,7 +81,7 @@ func serve(w dns.ResponseWriter, req *dns.Msg, z *Zone) {
 		ip = realIP
 	}
 
-	targets, netmask := z.Options.Targeting.GetTargets(ip)
+	targets, netmask, location := z.Options.Targeting.GetTargets(ip, z.HasClosest)
 
 	m := new(dns.Msg)
 	m.SetReply(req)
@@ -133,9 +133,14 @@ func serve(w dns.ResponseWriter, req *dns.Msg, z *Zone) {
 					ip.String(),
 				}
 
-				targets, netmask := z.Options.Targeting.GetTargets(ip)
+				targets, netmask, location := z.Options.Targeting.GetTargets(ip, z.HasClosest)
 				txt = append(txt, strings.Join(targets, " "))
 				txt = append(txt, fmt.Sprintf("/%d", netmask), serverID, serverIP)
+				if location != nil {
+					txt = append(txt, fmt.Sprintf("(%.3f,%.3f)", location.latitude, location.longitude))
+				} else {
+					txt = append(txt, "(?,?)")
+				}
 
 				m.Answer = []dns.RR{&dns.TXT{Hdr: h,
 					Txt: txt,
@@ -159,7 +164,11 @@ func serve(w dns.ResponseWriter, req *dns.Msg, z *Zone) {
 		return
 	}
 
-	if servers := labels.Picker(labelQtype, labels.MaxHosts); servers != nil {
+	if !labels.Closest {
+		location = nil
+	}
+
+	if servers := labels.Picker(labelQtype, labels.MaxHosts, location); servers != nil {
 		var rrs []dns.RR
 		for _, record := range servers {
 			rr := dns.Copy(record.RR)

+ 5 - 6
targeting.go

@@ -24,20 +24,19 @@ func init() {
 	cidr48Mask = net.CIDRMask(48, 128)
 }
 
-func (t TargetOptions) GetTargets(ip net.IP) ([]string, int) {
+func (t TargetOptions) GetTargets(ip net.IP, hasClosest bool) ([]string, int, *Location) {
 
 	targets := make([]string, 0)
 
 	var country, continent, region, regionGroup, asn string
 	var netmask int
+	var location *Location
 
 	if t&TargetASN > 0 {
 		asn, netmask = geoIP.GetASN(ip)
 	}
-
-	if t&TargetRegion > 0 || t&TargetRegionGroup > 0 {
-		country, continent, regionGroup, region, netmask = geoIP.GetCountryRegion(ip)
-
+	if t&TargetRegion > 0 || t&TargetRegionGroup > 0 || hasClosest {
+		country, continent, regionGroup, region, netmask, location = geoIP.GetCountryRegion(ip)
 	} else if t&TargetCountry > 0 || t&TargetContinent > 0 {
 		country, continent, netmask = geoIP.GetCountry(ip)
 	}
@@ -80,7 +79,7 @@ func (t TargetOptions) GetTargets(ip net.IP) ([]string, int) {
 	if t&TargetGlobal > 0 {
 		targets = append(targets, "@")
 	}
-	return targets, netmask
+	return targets, netmask, location
 }
 
 func (t TargetOptions) String() string {

+ 5 - 5
targeting_test.go

@@ -41,7 +41,7 @@ func (s *TargetingSuite) TestGetTargets(c *C) {
 	geoIP.setupGeoIPASN()
 
 	tgt, _ := parseTargets("@ continent country")
-	targets, _ := tgt.GetTargets(ip)
+	targets, _, _ := tgt.GetTargets(ip, false)
 	c.Check(targets, DeepEquals, []string{"us", "north-america", "@"})
 
 	if geoIP.city == nil {
@@ -50,20 +50,20 @@ func (s *TargetingSuite) TestGetTargets(c *C) {
 	}
 
 	tgt, _ = parseTargets("@ continent country region ")
-	targets, _ = tgt.GetTargets(ip)
+	targets, _, _ = tgt.GetTargets(ip, false)
 	c.Check(targets, DeepEquals, []string{"us-ca", "us", "north-america", "@"})
 
 	tgt, _ = parseTargets("@ continent regiongroup country region ")
-	targets, _ = tgt.GetTargets(ip)
+	targets, _, _ = tgt.GetTargets(ip, false)
 	c.Check(targets, DeepEquals, []string{"us-ca", "us-west", "us", "north-america", "@"})
 
 	tgt, _ = parseTargets("@ continent regiongroup country region asn ip")
-	targets, _ = tgt.GetTargets(ip)
+	targets, _, _ = tgt.GetTargets(ip, false)
 	c.Check(targets, DeepEquals, []string{"[207.171.1.1]", "[207.171.1.0]", "as7012", "us-ca", "us-west", "us", "north-america", "@"})
 
 	ip = net.ParseIP("2607:f238:2:0::ff:4")
 	tgt, _ = parseTargets("ip")
-	targets, _ = tgt.GetTargets(ip)
+	targets, _, _ = tgt.GetTargets(ip, false)
 	c.Check(targets, DeepEquals, []string{"[2607:f238:2::ff:4]", "[2607:f238:2::]"})
 
 }

+ 29 - 1
zone.go

@@ -14,6 +14,7 @@ type ZoneOptions struct {
 	MaxHosts  int
 	Contact   string
 	Targeting TargetOptions
+	Closest   bool
 }
 
 type ZoneLogging struct {
@@ -24,6 +25,7 @@ type ZoneLogging struct {
 type Record struct {
 	RR     dns.RR
 	Weight int
+	Loc    *Location
 }
 
 type Records []Record
@@ -41,6 +43,7 @@ type Label struct {
 	Ttl      int
 	Records  map[uint16]Records
 	Weight   map[uint16]int
+	Closest  bool
 }
 
 type labels map[string]*Label
@@ -60,7 +63,7 @@ type Zone struct {
 	Options    ZoneOptions
 	Logging    *ZoneLogging
 	Metrics    ZoneMetrics
-
+	HasClosest bool
 	sync.RWMutex
 }
 
@@ -128,6 +131,7 @@ func (z *Zone) AddLabel(k string) *Label {
 	label.Label = k
 	label.Ttl = 0 // replaced later
 	label.MaxHosts = z.Options.MaxHosts
+	label.Closest = z.Options.Closest
 
 	label.Records = make(map[uint16]Records)
 	label.Weight = make(map[uint16]int)
@@ -185,3 +189,27 @@ func (z *Zone) findLabels(s string, targets []string, qts qTypes) (*Label, uint1
 
 	return z.Labels[s], 0
 }
+
+// Find the locations of all the A records within a zone. If we were being really clever
+// here we could use LOC records too. But for the time being we'll just use GeoIP
+
+func (z *Zone) SetLocations() {
+	qtypes := []uint16{dns.TypeA}
+	for _, label := range z.Labels {
+		if label.Closest {
+			for _, qtype := range qtypes {
+				if label.Records[qtype] != nil && len(label.Records[qtype]) > 0 {
+					for i := range label.Records[qtype] {
+						label.Records[qtype][i].Loc = nil
+						rr := label.Records[qtype][i].RR
+						if a, ok := rr.(*dns.A); ok {
+							ip := a.A
+							_, _, _, _, _, location := geoIP.GetCountryRegion(ip)
+							label.Records[qtype][i].Loc = location
+						}
+					}
+				}
+			}
+		}
+	}
+}

+ 16 - 1
zones.go

@@ -214,6 +214,11 @@ func readZoneFile(zoneName, fileName string) (zone *Zone, zerr error) {
 			zone.Options.Contact = v.(string)
 		case "max_hosts":
 			zone.Options.MaxHosts = valueToInt(v)
+		case "closest":
+			zone.Options.Closest = v.(bool)
+			if zone.Options.Closest {
+				zone.HasClosest = true
+			}
 		case "targeting":
 			zone.Options.Targeting, err = parseTargets(v.(string))
 			if err != nil {
@@ -252,7 +257,7 @@ func readZoneFile(zoneName, fileName string) (zone *Zone, zerr error) {
 	//log.Println("IP", string(Zone.Regions["0.us"].IPv4[0].ip))
 
 	switch {
-	case zone.Options.Targeting >= TargetRegionGroup:
+	case zone.Options.Targeting >= TargetRegionGroup || zone.HasClosest:
 		geoIP.setupGeoIPCity()
 	case zone.Options.Targeting >= TargetContinent:
 		geoIP.setupGeoIPCountry()
@@ -261,6 +266,10 @@ func readZoneFile(zoneName, fileName string) (zone *Zone, zerr error) {
 		geoIP.setupGeoIPASN()
 	}
 
+	if zone.HasClosest {
+		zone.SetLocations()
+	}
+
 	return zone, nil
 }
 
@@ -289,6 +298,12 @@ func setupZoneData(data map[string]interface{}, Zone *Zone) {
 			case "max_hosts":
 				label.MaxHosts = valueToInt(rdata)
 				continue
+			case "closest":
+				label.Closest = rdata.(bool)
+				if label.Closest {
+					Zone.HasClosest = true
+				}
+				continue
 			case "ttl":
 				label.Ttl = valueToInt(rdata)
 				continue