From 10db2a80df36b3d64b0b62644c91420046d827f8 Mon Sep 17 00:00:00 2001 From: Miek Gieben <miek@miek.nl> Date: Tue, 19 Apr 2016 11:13:24 +0100 Subject: [PATCH] Cache (#126) * Add middleware/cache Add a caching middleware that caches nxdomain, nodata and successful responses. It differentiates between DNSSEC on normal DNS replies. Each reply is compress and scrubbed so it will fit the specific client asking for it. * first simple test, less exporting of stuff * more * Add middleware/cache Add a caching middleware that caches nxdomain, nodata and successful responses. It differentiates between DNSSEC on normal DNS replies. Each reply is compressed and scrubbed so it will fit the specific client asking for it. The TTL is decremented with the time spend in the cache. There is syntax that allows you to cap the TTL for all records, no matter what. This allows for a shortlived cache, just to absorb query peaks. +Tests * cache test infrastructure * Testing --- core/directives.go | 1 + core/setup/cache.go | 54 ++++++++ core/setup/file.go | 3 +- core/setup/loadbalance.go | 5 +- middleware/cache/README.md | 29 +++++ middleware/cache/cache.go | 196 ++++++++++++++++++++++++++++++ middleware/cache/cache_test.go | 112 +++++++++++++++++ middleware/cache/handler.go | 44 +++++++ middleware/cache/item.go | 98 +++++++++++++++ middleware/cache/item_test.go | 25 ++++ middleware/etcd/lookup.go | 8 +- middleware/loadbalance/handler.go | 3 +- 12 files changed, 566 insertions(+), 12 deletions(-) create mode 100644 core/setup/cache.go create mode 100644 middleware/cache/README.md create mode 100644 middleware/cache/cache.go create mode 100644 middleware/cache/cache_test.go create mode 100644 middleware/cache/handler.go create mode 100644 middleware/cache/item.go create mode 100644 middleware/cache/item_test.go diff --git a/core/directives.go b/core/directives.go index fbbba40c7..2529dacb1 100644 --- a/core/directives.go +++ b/core/directives.go @@ -57,6 +57,7 @@ var directiveOrder = []directive{ {"chaos", setup.Chaos}, {"rewrite", setup.Rewrite}, {"loadbalance", setup.Loadbalance}, + {"cache", setup.Cache}, {"file", setup.File}, {"secondary", setup.Secondary}, {"etcd", setup.Etcd}, diff --git a/core/setup/cache.go b/core/setup/cache.go new file mode 100644 index 000000000..0d2440cfc --- /dev/null +++ b/core/setup/cache.go @@ -0,0 +1,54 @@ +package setup + +import ( + "strconv" + + "github.com/miekg/coredns/middleware" + "github.com/miekg/coredns/middleware/cache" +) + +// Cache sets up the root file path of the server. +func Cache(c *Controller) (middleware.Middleware, error) { + ttl, zones, err := cacheParse(c) + if err != nil { + return nil, err + } + return func(next middleware.Handler) middleware.Handler { + return cache.NewCache(ttl, zones, next) + }, nil +} + +func cacheParse(c *Controller) (int, []string, error) { + var ( + err error + ttl int + ) + + for c.Next() { + if c.Val() == "cache" { + // cache [ttl] [zones..] + + origins := []string{c.ServerBlockHosts[c.ServerBlockHostIndex]} + args := c.RemainingArgs() + if len(args) > 0 { + origins = args + // first args may be just a number, then it is the ttl, if not it is a zone + t := origins[0] + ttl, err = strconv.Atoi(t) + if err == nil { + origins = origins[1:] + if len(origins) == 0 { + // There was *only* the ttl, revert back to server block + origins = []string{c.ServerBlockHosts[c.ServerBlockHostIndex]} + } + } + } + + for i, _ := range origins { + origins[i] = middleware.Host(origins[i]).Normalize() + } + return ttl, origins, nil + } + } + return 0, nil, nil +} diff --git a/core/setup/file.go b/core/setup/file.go index 25ee5149d..0b33fe47f 100644 --- a/core/setup/file.go +++ b/core/setup/file.go @@ -40,7 +40,7 @@ func fileParse(c *Controller) (file.Zones, error) { names := []string{} for c.Next() { if c.Val() == "file" { - // file db.file [origin] + // file db.file [zones...] if !c.NextArg() { return file.Zones{}, c.ArgErr() } @@ -83,7 +83,6 @@ func fileParse(c *Controller) (file.Zones, error) { } z[origin].NoReload = noReload } - } } } diff --git a/core/setup/loadbalance.go b/core/setup/loadbalance.go index 93b919e5f..4b132489b 100644 --- a/core/setup/loadbalance.go +++ b/core/setup/loadbalance.go @@ -5,15 +5,12 @@ import ( "github.com/miekg/coredns/middleware/loadbalance" ) -// Root sets up the root file path of the server. +// Loadbalance sets up the root file path of the server. func Loadbalance(c *Controller) (middleware.Middleware, error) { for c.Next() { - // and choosing the correct balancer // TODO(miek): block and option parsing } return func(next middleware.Handler) middleware.Handler { return loadbalance.RoundRobin{Next: next} }, nil - - return nil, nil } diff --git a/middleware/cache/README.md b/middleware/cache/README.md new file mode 100644 index 000000000..aade84694 --- /dev/null +++ b/middleware/cache/README.md @@ -0,0 +1,29 @@ +# cache + +`cache` enables a frontend cache. + +## Syntax + +~~~ +cache [ttl] [zones...] +~~~ + +* `ttl` max TTL in seconds, if not specified the TTL of the reply (SOA minimum or minimum TTL in the + answer section) will be used. +* `zones` zones it should should cache for. If empty the zones from the configuration block are used. + + +Each element in the cache is cached according to its TTL, for the negative cache the SOA's MinTTL +value is used. + +A cache mostly makes sense with a middleware that is potentially slow, i.e. a proxy that retrieves +answer, or to minimize backend queries for middleware like etcd. Using a cache with the file +middleware essentially doubles the memory load with no concealable increase of query speed. + +## Examples + +~~~ +cache +~~~ + +Enable caching for all zones. diff --git a/middleware/cache/cache.go b/middleware/cache/cache.go new file mode 100644 index 000000000..1ec71b047 --- /dev/null +++ b/middleware/cache/cache.go @@ -0,0 +1,196 @@ +package cache + +/* +The idea behind this implementation is as follows. We have a cache that is index +by a couple different keys, which allows use to have: + +- negative cache: qname only for NXDOMAIN responses +- negative cache: qname + qtype for NODATA responses +- positive cache: qname + qtype for succesful responses. + +We track DNSSEC responses separately, i.e. under a different cache key. +Each Item stored contains the message split up in the different sections +and a few bits of the msg header. + +For instance an NXDOMAIN for blaat.miek.nl will create the +following negative cache entry (do signal state of DO (do off, DO on)). + + ncache: do <blaat.miek.nl> + Item: + Ns: <miek.nl> SOA RR + +If found a return packet is assembled and returned to the client. Taking size and EDNS0 +constraints into account. + +We also need to track if the answer received was an authoritative answer, ad bit and other +setting, for this we also store a few header bits. + +For the positive cache we use the same idea. Truncated responses are never stored. +*/ + +import ( + "log" + "time" + + "github.com/miekg/coredns/middleware" + + "github.com/miekg/dns" + gcache "github.com/patrickmn/go-cache" +) + +// Cache is middleware that looks up responses in a cache and caches replies. +type Cache struct { + Next middleware.Handler + Zones []string + cache *gcache.Cache + cap time.Duration +} + +func NewCache(ttl int, zones []string, next middleware.Handler) Cache { + return Cache{Next: next, Zones: zones, cache: gcache.New(defaultDuration, purgeDuration), cap: time.Duration(ttl) * time.Second} +} + +type messageType int + +const ( + success messageType = iota + nameError // NXDOMAIN in header, SOA in auth. + noData // NOERROR in header, SOA in auth. + otherError // Don't cache these. +) + +// classify classifies a message, it returns the MessageType. +func classify(m *dns.Msg) (messageType, *dns.OPT) { + opt := m.IsEdns0() + soa := false + if m.Rcode == dns.RcodeSuccess { + return success, opt + } + for _, r := range m.Ns { + if r.Header().Rrtype == dns.TypeSOA { + soa = true + break + } + } + + // Check length of different section, and drop stuff that is just to large. + if soa && m.Rcode == dns.RcodeSuccess { + return noData, opt + } + if soa && m.Rcode == dns.RcodeNameError { + return nameError, opt + } + + return otherError, opt +} + +func cacheKey(m *dns.Msg, t messageType, do bool) string { + if m.Truncated { + return "" + } + + qtype := m.Question[0].Qtype + qname := middleware.Name(m.Question[0].Name).Normalize() + switch t { + case success: + return successKey(qname, qtype, do) + case nameError: + return nameErrorKey(qname, do) + case noData: + return noDataKey(qname, qtype, do) + case otherError: + return "" + } + return "" +} + +type CachingResponseWriter struct { + dns.ResponseWriter + cache *gcache.Cache + cap time.Duration +} + +func NewCachingResponseWriter(w dns.ResponseWriter, cache *gcache.Cache, cap time.Duration) *CachingResponseWriter { + return &CachingResponseWriter{w, cache, cap} +} + +func (c *CachingResponseWriter) WriteMsg(res *dns.Msg) error { + do := false + mt, opt := classify(res) + if opt != nil { + do = opt.Do() + } + + key := cacheKey(res, mt, do) + c.Set(res, key, mt) + + if c.cap != 0 { + setCap(res, uint32(c.cap.Seconds())) + } + + return c.ResponseWriter.WriteMsg(res) +} + +func (c *CachingResponseWriter) Set(m *dns.Msg, key string, mt messageType) { + if key == "" { + // logger the log? TODO(miek) + return + } + + duration := c.cap + switch mt { + case success: + if c.cap == 0 { + duration = minTtl(m.Answer, mt) + } + i := newItem(m, duration) + + c.cache.Set(key, i, duration) + case nameError, noData: + if c.cap == 0 { + duration = minTtl(m.Ns, mt) + } + i := newItem(m, duration) + + c.cache.Set(key, i, duration) + } +} + +func (c *CachingResponseWriter) Write(buf []byte) (int, error) { + log.Printf("[WARNING] Caching called with Write: not caching reply") + n, err := c.ResponseWriter.Write(buf) + return n, err +} + +func (c *CachingResponseWriter) Hijack() { + c.ResponseWriter.Hijack() + return +} + +func minTtl(rrs []dns.RR, mt messageType) time.Duration { + if mt != success && mt != nameError && mt != noData { + return 0 + } + + minTtl := maxTtl + for _, r := range rrs { + switch mt { + case nameError, noData: + if r.Header().Rrtype == dns.TypeSOA { + return time.Duration(r.(*dns.SOA).Minttl) * time.Second + } + case success: + if r.Header().Ttl < minTtl { + minTtl = r.Header().Ttl + } + } + } + return time.Duration(minTtl) * time.Second +} + +const ( + purgeDuration = 1 * time.Minute + defaultDuration = 20 * time.Minute + baseTtl = 5 // minimum ttl that we will allow + maxTtl uint32 = 2 * 3600 +) diff --git a/middleware/cache/cache_test.go b/middleware/cache/cache_test.go new file mode 100644 index 000000000..310a1164e --- /dev/null +++ b/middleware/cache/cache_test.go @@ -0,0 +1,112 @@ +package cache + +import ( + "testing" + "time" + + "github.com/miekg/coredns/middleware" + "github.com/miekg/coredns/middleware/test" + + "github.com/miekg/dns" +) + +type cacheTestCase struct { + test.Case + in test.Case + AuthenticatedData bool + Authoritative bool + RecursionAvailable bool + Truncated bool +} + +var cacheTestCases = []cacheTestCase{ + { + RecursionAvailable: true, AuthenticatedData: true, Authoritative: true, + Case: test.Case{ + Qname: "miek.nl.", Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("miek.nl. 1800 IN MX 1 aspmx.l.google.com."), + test.MX("miek.nl. 1800 IN MX 10 aspmx2.googlemail.com."), + test.MX("miek.nl. 1800 IN MX 10 aspmx3.googlemail.com."), + test.MX("miek.nl. 1800 IN MX 5 alt1.aspmx.l.google.com."), + test.MX("miek.nl. 1800 IN MX 5 alt2.aspmx.l.google.com."), + }, + }, + in: test.Case{ + Qname: "miek.nl.", Qtype: dns.TypeMX, + Answer: []dns.RR{ + test.MX("miek.nl. 1800 IN MX 1 aspmx.l.google.com."), + test.MX("miek.nl. 1800 IN MX 10 aspmx2.googlemail.com."), + test.MX("miek.nl. 1800 IN MX 10 aspmx3.googlemail.com."), + test.MX("miek.nl. 1800 IN MX 5 alt1.aspmx.l.google.com."), + test.MX("miek.nl. 1800 IN MX 5 alt2.aspmx.l.google.com."), + }, + }, + }, + { + Truncated: true, + Case: test.Case{ + Qname: "miek.nl.", Qtype: dns.TypeMX, + Answer: []dns.RR{test.MX("miek.nl. 1800 IN MX 1 aspmx.l.google.com.")}, + }, + in: test.Case{}, + }, +} + +func cacheMsg(m *dns.Msg, tc cacheTestCase) *dns.Msg { + m.RecursionAvailable = tc.RecursionAvailable + m.AuthenticatedData = tc.AuthenticatedData + m.Authoritative = tc.Authoritative + m.Truncated = tc.Truncated + m.Answer = tc.in.Answer + m.Ns = tc.in.Ns + // m.Extra = tc.in.Extra , not the OPT record! + return m +} + +func newTestCache() (Cache, *CachingResponseWriter) { + c := NewCache(0, []string{"."}, nil) + crr := NewCachingResponseWriter(nil, c.cache, time.Duration(0)) + return c, crr +} + +func TestCache(t *testing.T) { + c, crr := newTestCache() + + for _, tc := range cacheTestCases { + m := tc.in.Msg() + m = cacheMsg(m, tc) + do := tc.in.Do + + mt, _ := classify(m) + key := cacheKey(m, mt, do) + crr.Set(m, key, mt) + + name := middleware.Name(m.Question[0].Name).Normalize() + qtype := m.Question[0].Qtype + i, ok := c.Get(name, qtype, do) + if !ok && !m.Truncated { + t.Errorf("Truncated message should not have been cached") + } + + if ok { + resp := i.toMsg(m) + + if !test.Header(t, tc.Case, resp) { + t.Logf("%v\n", resp) + continue + } + + if !test.Section(t, tc.Case, test.Answer, resp.Answer) { + t.Logf("%v\n", resp) + } + if !test.Section(t, tc.Case, test.Ns, resp.Ns) { + t.Logf("%v\n", resp) + + } + if !test.Section(t, tc.Case, test.Extra, resp.Extra) { + t.Logf("%v\n", resp) + } + } + } +} diff --git a/middleware/cache/handler.go b/middleware/cache/handler.go new file mode 100644 index 000000000..51e3731bd --- /dev/null +++ b/middleware/cache/handler.go @@ -0,0 +1,44 @@ +package cache + +import ( + "github.com/miekg/coredns/middleware" + + "github.com/miekg/dns" + "golang.org/x/net/context" +) + +// ServeDNS implements the middleware.Handler interface. +func (c Cache) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := middleware.State{W: w, Req: r} + + qname := state.Name() + qtype := state.QType() + zone := middleware.Zones(c.Zones).Matches(qname) + if zone == "" { + return c.Next.ServeDNS(ctx, w, r) + } + + do := state.Do() // might need more from OPT record? + + if i, ok := c.Get(qname, qtype, do); ok { + resp := i.toMsg(r) + state.SizeAndDo(resp) + w.WriteMsg(resp) + return dns.RcodeSuccess, nil + } + crr := NewCachingResponseWriter(w, c.cache, c.cap) + return c.Next.ServeDNS(ctx, crr, r) +} + +func (c Cache) Get(qname string, qtype uint16, do bool) (*item, bool) { + nxdomain := nameErrorKey(qname, do) + if i, ok := c.cache.Get(nxdomain); ok { + return i.(*item), true + } + + successOrNoData := successKey(qname, qtype, do) + if i, ok := c.cache.Get(successOrNoData); ok { + return i.(*item), true + } + return nil, false +} diff --git a/middleware/cache/item.go b/middleware/cache/item.go new file mode 100644 index 000000000..6f0190c52 --- /dev/null +++ b/middleware/cache/item.go @@ -0,0 +1,98 @@ +package cache + +import ( + "strconv" + "time" + + "github.com/miekg/dns" +) + +type item struct { + Authoritative bool + AuthenticatedData bool + RecursionAvailable bool + Answer []dns.RR + Ns []dns.RR + Extra []dns.RR + + origTtl uint32 + stored time.Time +} + +func newItem(m *dns.Msg, d time.Duration) *item { + i := new(item) + i.Authoritative = m.Authoritative + i.AuthenticatedData = m.AuthenticatedData + i.RecursionAvailable = m.RecursionAvailable + i.Answer = m.Answer + i.Ns = m.Ns + i.Extra = make([]dns.RR, len(m.Extra)) + // Don't copy OPT record as these are hop-by-hop. + j := 0 + for _, e := range m.Extra { + if e.Header().Rrtype == dns.TypeOPT { + continue + } + i.Extra[j] = e + j++ + } + i.Extra = i.Extra[:j] + + i.origTtl = uint32(d.Seconds()) + i.stored = time.Now().UTC() + + return i +} + +// toMsg turns i into a message, it tailers to reply to m. +func (i *item) toMsg(m *dns.Msg) *dns.Msg { + m1 := new(dns.Msg) + m1.SetReply(m) + m1.Authoritative = i.Authoritative + m1.AuthenticatedData = i.AuthenticatedData + m1.RecursionAvailable = i.RecursionAvailable + m1.Compress = true + + m1.Answer = i.Answer + m1.Ns = i.Ns + m1.Extra = i.Extra + + ttl := int(i.origTtl) - int(time.Now().UTC().Sub(i.stored).Seconds()) + if ttl < baseTtl { + ttl = baseTtl + } + setCap(m1, uint32(ttl)) + return m1 +} + +// setCap sets the ttl on all RRs in all sections. +func setCap(m *dns.Msg, ttl uint32) { + for _, r := range m.Answer { + r.Header().Ttl = uint32(ttl) + } + for _, r := range m.Ns { + r.Header().Ttl = uint32(ttl) + } + for _, r := range m.Extra { + r.Header().Ttl = uint32(ttl) + } +} + +// nodataKey returns a caching key for NODATA responses. +func noDataKey(qname string, qtype uint16, do bool) string { + if do { + return "1" + qname + ".." + strconv.Itoa(int(qtype)) + } + return "0" + qname + ".." + strconv.Itoa(int(qtype)) +} + +// nameErrorKey returns a caching key for NXDOMAIN responses. +func nameErrorKey(qname string, do bool) string { + if do { + return "1" + qname + } + return "0" + qname +} + +// successKey returns a caching key for successfull answers. +func successKey(qname string, qtype uint16, do bool) string { return noDataKey(qname, qtype, do) } diff --git a/middleware/cache/item_test.go b/middleware/cache/item_test.go new file mode 100644 index 000000000..5989b0099 --- /dev/null +++ b/middleware/cache/item_test.go @@ -0,0 +1,25 @@ +package cache + +import ( + "testing" + + "github.com/miekg/dns" +) + +func TestKey(t *testing.T) { + if noDataKey("miek.nl.", dns.TypeMX, false) != "0miek.nl...15" { + t.Errorf("failed to create correct key") + } + if noDataKey("miek.nl.", dns.TypeMX, true) != "1miek.nl...15" { + t.Errorf("failed to create correct key") + } + if nameErrorKey("miek.nl.", false) != "0miek.nl." { + t.Errorf("failed to create correct key") + } + if nameErrorKey("miek.nl.", true) != "1miek.nl." { + t.Errorf("failed to create correct key") + } + if noDataKey("miek.nl.", dns.TypeMX, false) != successKey("miek.nl.", dns.TypeMX, false) { + t.Errorf("nameErrorKey and successKey should be the same") + } +} diff --git a/middleware/etcd/lookup.go b/middleware/etcd/lookup.go index db8f02906..88f362fc7 100644 --- a/middleware/etcd/lookup.go +++ b/middleware/etcd/lookup.go @@ -349,15 +349,13 @@ func (e Etcd) SOA(zone string, state middleware.State) *dns.SOA { Mbox: "hostmaster." + zone, Ns: "ns.dns." + zone, Serial: uint32(time.Now().Unix()), - Refresh: 14400, - Retry: 3600, - Expire: 604800, + Refresh: 7200, + Retry: 1800, + Expire: 86400, Minttl: 60, } } -// NS returns the NS records from etcd. - // TODO(miek): DNSKEY and friends... intercepted by the DNSSEC middleware? func isDuplicateCNAME(r *dns.CNAME, records []dns.RR) bool { diff --git a/middleware/loadbalance/handler.go b/middleware/loadbalance/handler.go index bb8619543..119b4d5d2 100644 --- a/middleware/loadbalance/handler.go +++ b/middleware/loadbalance/handler.go @@ -3,6 +3,7 @@ package loadbalance import ( "github.com/miekg/coredns/middleware" + "github.com/miekg/dns" "golang.org/x/net/context" ) @@ -12,7 +13,7 @@ type RoundRobin struct { Next middleware.Handler } -// ServeHTTP implements the middleware.Handler interface. +// ServeDNS implements the middleware.Handler interface. func (rr RoundRobin) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { wrr := NewRoundRobinResponseWriter(w) return rr.Next.ServeDNS(ctx, wrr, r)