Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

Writing a High-Throughput Custom DNS Resolver in Go

A crawler or an ingress resolving thousands of distinct hostnames per second exposes a gap in the standard library. The folklore is that net.LookupHost blocks OS threads via cgo — that stopped being true in Go 1.9, when the pure-Go resolver became the default on Unix (forced with GODEBUG=netdns=go). The real problems are worse and subtler:

  • The pure-Go resolver keeps no TTL cache. Every lookup is a fresh query to a nameserver, even for names you resolved ten seconds ago.
  • Concurrent lookups of the same name are duplicated. A thundering herd of 200 goroutines asking for the same host fires 200 queries.
  • Under nsswitch.conf entries the pure-Go resolver can’t honor (mdns, nis, custom NSS modules), Go silently falls back to the cgo resolver — and that one does block an OS thread per lookup.

The fix is an application-level resolver that caches by TTL and coalesces concurrent lookups with single-flight, built on top of net.Resolver with a custom dialer so we control the nameserver and timeouts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package main

import (
	"context"
	"net"
	"sync"
	"time"

	"golang.org/x/sync/singleflight"
)

type Lookup struct {
	cache *cache
	group singleflight.Group
	res   *net.Resolver
}

type cache struct {
	mu      sync.Mutex
	ttl     time.Duration
	entries map[string]*entry
}

type entry struct {
	ips     []net.IP
	expires time.Time
}

func NewLookup() *Lookup {
	res := &net.Resolver{
		PreferGo: true,
		Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
			d := net.Dialer{Timeout: 2 * time.Second}
			return d.DialContext(ctx, "udp", "8.8.8.8:53")
		},
	}
	return &Lookup{
		cache: &cache{ttl: 60 * time.Second, entries: make(map[string]*entry)},
		res:   res,
	}
}

func (l *Lookup) Resolve(ctx context.Context, host string) ([]net.IP, error) {
	c := l.cache
	c.mu.Lock()
	if e, ok := c.entries[host]; ok && time.Now().Before(e.expires) {
		c.mu.Unlock()
		return e.ips, nil
	}
	c.mu.Unlock()

	ips, err, _ := l.group.Do(host, func() (interface{}, error) {
		ips, err := l.res.LookupIP(ctx, "ip", host)
		if err == nil {
			c.mu.Lock()
			c.entries[host] = &entry{ips: ips, expires: time.Now().Add(c.ttl)}
			c.mu.Unlock()
		}
		return ips, err
	})
	if err != nil {
		return nil, err
	}
	return ips.([]net.IP), nil
}

Two mechanisms do all the work:

  • TTL cache. A cold lookup costs 5–30 ms once; subsequent lookups are a map hit plus a slice copy, call it a hundred microseconds including the copy.
  • single-flight. N goroutines asking for the same host while it’s cold trigger exactly one wire query; the rest share the result.

In our crawler — roughly 1,000 distinct hostnames/second with a 60-second TTL — p99 lookup latency dropped from 22 ms to 0.4 ms, and DNS query volume fell ~98%, which also took us off the edge of the resolver provider’s rate limit. That last part was the quiet win: our burst pattern was fine on average but frequently breached the provider’s per-second ceiling during cache-empty phases.

The gotchas are where this design bites in production:

  • Never cache negative results for the full TTL. One transient resolver outage, cached as a 60-second NXDOMAIN or timeout, took out a chunk of our traffic for a full minute. Cache negatives for at most 5 seconds, or don’t cache them at all.
  • TTL works against you at deploy time. With a 60-second cache, a blue/green DNS shift takes a minute to converge on every client. For health-critical names keep the TTL at 10 seconds or less and let single-flight absorb the wire cost.
  • single-flight shares the first caller’s context. Late callers wait for the first call to finish even if their own context is already canceled — the wait is not cancelable. Bound the underlying lookup with a timeout in the Dial hook (2 seconds above), or you’ll accumulate waiters during a resolver outage.
  • Add TCP fallback for truncated responses. Large answers get a TC bit set and require TCP; our Dial hook pins UDP, and we also dial "tcp" when the UDP response is truncated. net.Resolver handles that switch for you if the dialer allows it.

A final note on the cache key: key by host, not by (host, nameserver), unless you are actively testing DNS failover. We made that mistake once; every nameserver flip invalidated the whole cache and re-created the exact cold-start storm the cache was built to prevent.

comments powered by Disqus