feat: add "nearest.<service>.internal" mode to internal DNS (#159)

* feat: Adding a "nearest.service.local" mode to internal DNS

* Pass listenAddr to internal DNS server (rather than calling into network.MachineIP)

Also adds an exact round-robin via "rr.{service}" mode. Not sure we want that over random or not, though.

* Remove the resolved count and rotating round-robin. "rr.name" mode is now the same as default with random shuffle.

* Add test on nearest internal DNS lookup

* Add docs on internal DNS order modes
This commit is contained in:
Justin Bradford
2025-10-29 14:25:59 +10:00
committed by GitHub
parent 6af7a9861d
commit ba6290d616
4 changed files with 147 additions and 4 deletions
+34 -3
View File
@@ -8,6 +8,7 @@ import (
"math/rand/v2" "math/rand/v2"
"net" "net"
"net/netip" "net/netip"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -40,6 +41,7 @@ type Resolver interface {
// to upstream DNS servers. // to upstream DNS servers.
type Server struct { type Server struct {
listenAddr netip.Addr listenAddr netip.Addr
localSubnet netip.Prefix
resolver Resolver resolver Resolver
upstreamServers []netip.AddrPort upstreamServers []netip.AddrPort
@@ -53,7 +55,7 @@ type Server struct {
// NewServer creates a new DNS server with the given configuration. // NewServer creates a new DNS server with the given configuration.
// If upstreams is nil, nameservers from /etc/resolv.conf will be used. An empty upstreams list means to only resolve // If upstreams is nil, nameservers from /etc/resolv.conf will be used. An empty upstreams list means to only resolve
// internal DNS queries and not forward any external queries. // internal DNS queries and not forward any external queries.
func NewServer(listenAddr netip.Addr, resolver Resolver, upstreams []netip.AddrPort) (*Server, error) { func NewServer(listenAddr netip.Addr, localSubnet netip.Prefix, resolver Resolver, upstreams []netip.AddrPort) (*Server, error) {
if !listenAddr.IsValid() { if !listenAddr.IsValid() {
return nil, fmt.Errorf("invalid listen address: %s", listenAddr) return nil, fmt.Errorf("invalid listen address: %s", listenAddr)
} }
@@ -87,6 +89,7 @@ func NewServer(listenAddr netip.Addr, resolver Resolver, upstreams []netip.AddrP
return &Server{ return &Server{
listenAddr: listenAddr, listenAddr: listenAddr,
localSubnet: localSubnet,
resolver: resolver, resolver: resolver,
upstreamServers: upstreams, upstreamServers: upstreams,
forwardSemaphore: make(chan struct{}, maxConcurrentForwards), forwardSemaphore: make(chan struct{}, maxConcurrentForwards),
@@ -287,7 +290,7 @@ func (s *Server) forwardRequest(req *dns.Msg, proto string) (*dns.Msg, error) {
// handleAQuery processes an A query for the internal domain and returns A records for the requested name. // handleAQuery processes an A query for the internal domain and returns A records for the requested name.
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found. // The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
func (s *Server) handleAQuery(name string) []dns.RR { func (s *Server) handleAQuery(name string) []dns.RR {
serviceName := trimInternalDomain(name) serviceName, mode := extractModeFromDomain(trimInternalDomain(name))
ips := s.resolver.Resolve(serviceName) ips := s.resolver.Resolve(serviceName)
if len(ips) == 0 { if len(ips) == 0 {
s.log.Debug("Failed to resolve service name.", "service", serviceName) s.log.Debug("Failed to resolve service name.", "service", serviceName)
@@ -296,10 +299,28 @@ func (s *Server) handleAQuery(name string) []dns.RR {
s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips) s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips)
if len(ips) > 1 { if len(ips) > 1 {
// TODO: sort by proximity to the requesting container/machine. For now, just shuffle the IPs. // Shuffle the IPs to approximate round-robin.
// We want to do this as a baseline for "nearest" mode, as well.
rand.Shuffle(len(ips), func(i, j int) { rand.Shuffle(len(ips), func(i, j int) {
ips[i], ips[j] = ips[j], ips[i] ips[i], ips[j] = ips[j], ips[i]
}) })
// Default (mode == "") currently behaves the same as round-robin,
// and nothing additional to do for round-robin (mode == "rr").
if mode == "nearest" {
// Sort IPs on local subnet to the top.
slices.SortFunc(ips, func(a, b netip.Addr) int {
aIsLocal := s.localSubnet.Contains(a)
bIsLocal := s.localSubnet.Contains(b)
if aIsLocal && !bIsLocal {
return -1
} else if bIsLocal && !aIsLocal {
return 1
}
return 0
})
}
} }
// Create A records for each IP. // Create A records for each IP.
@@ -345,3 +366,13 @@ func trimInternalDomain(name string) string {
return strings.TrimSuffix(name, "."+InternalDomain) return strings.TrimSuffix(name, "."+InternalDomain)
} }
func extractModeFromDomain(name string) (string, string) {
modes := []string{"nearest", "rr"}
for _, mode := range modes {
if cut, found := strings.CutPrefix(name, mode+"."); found {
return cut, mode
}
}
return name, ""
}
+6 -1
View File
@@ -430,7 +430,12 @@ func (m *Machine) Run(ctx context.Context) error {
} }
dnsResolver := dns.NewClusterResolver(m.store) dnsResolver := dns.NewClusterResolver(m.store)
dnsServer, err := dns.NewServer(m.IP(), dnsResolver, m.config.DNSUpstreams) dnsServer, err := dns.NewServer(
m.IP(),
m.state.Network.Subnet,
dnsResolver,
m.config.DNSUpstreams,
)
if err != nil { if err != nil {
return fmt.Errorf("create embedded DNS server: %w", err) return fmt.Errorf("create embedded DNS server: %w", err)
} }
+35
View File
@@ -3,6 +3,7 @@ package e2e
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -224,4 +225,38 @@ func TestInternalDNS(t *testing.T) {
assertNoDNSErrors(t, dnsOutput) assertNoDNSErrors(t, dnsOutput)
}) })
t.Run("nearest mode prioritizes local subnet IPs", func(t *testing.T) {
// Test the "nearest" mode which should sort local subnet IPs first
dnsOutput := runDNSQuery(t, "dns-test-nearest", "nearest."+serviceName+".internal", "/tmp/dns_result_nearest.txt")
t.Logf("Nearest mode DNS query output:\n%s", dnsOutput)
// Find which machine ran the query
querySvc, err := cli.InspectService(ctx, "dns-test-nearest")
require.NoError(t, err)
require.NotEmpty(t, querySvc.Containers, "Query service should have a container")
queryMachineID := querySvc.Containers[0].MachineID
// Find the local container IP (on the same machine as the query)
var localIP string
for _, ctr := range svc.Containers {
if ctr.MachineID == queryMachineID {
localIP = ctr.Container.UncloudNetworkIP().String()
break
}
}
require.NotEmpty(t, localIP, "Should find local container IP on query machine %s", queryMachineID)
// Extract the first IP address from the DNS output using regex
// Pattern matches: "Name: nearest.test-dns-service.internal" followed by "Address: X.X.X.X"
re := regexp.MustCompile(`(?m)Name:\s+[\w\.\-]+\s+Address:\s+([\d\.]+)`)
matches := re.FindStringSubmatch(dnsOutput)
require.Len(t, matches, 2, "Should find Name's Address in DNS output")
firstIP := matches[1]
assert.Equal(t, localIP, firstIP,
"Nearest mode should return local subnet IP first (query machine: %s, local IP: %s, first DNS result: %s)",
queryMachineID, localIP, firstIP)
assertNoDNSErrors(t, dnsOutput)
})
} }
@@ -59,3 +59,75 @@ Address: 10.210.1.3
Name: worker.internal Name: worker.internal
Address: 10.210.1.4 Address: 10.210.1.4
``` ```
## IP Ordering Mode
Additionally, the IP ordering preference can be specified with a `rr` (round-robin) or `nearest` subdomain prefix.
### `rr` (round-robin) *current default*
Randomly shuffled order on each lookup.
```
$ nslookup rr.worker.internal
Server: 127.0.0.11
Address: 127.0.0.11#53
Name: rr.worker.internal
Address: 10.210.0.3
Name: rr.worker.internal
Address: 10.210.0.4
Name: rr.worker.internal
Address: 10.210.1.3
Name: rr.worker.internal
Address: 10.210.1.4
$ nslookup rr.worker.internal
Server: 127.0.0.11
Address: 127.0.0.11#53
Name: rr.worker.internal
Address: 10.210.0.4
Name: rr.worker.internal
Address: 10.210.1.3
Name: rr.worker.internal
Address: 10.210.1.4
Name: rr.worker.internal
Address: 10.210.0.3
```
## Nearest scope
Returns machine-local instances first.
`machine-a`:
```
$ nslookup nearest.worker.internal
Server: 127.0.0.11
Address: 127.0.0.11#53
Name: nearest.worker.internal
Address: 10.210.0.3
Name: nearest.worker.internal
Address: 10.210.0.4
Name: nearest.worker.internal
Address: 10.210.1.3
Name: nearest.worker.internal
Address: 10.210.1.4
```
`machine-b`:
```
$ nslookup nearest.worker.internal
Server: 127.0.0.11
Address: 127.0.0.11#53
Name: nearest.worker.internal
Address: 10.210.1.3
Name: nearest.worker.internal
Address: 10.210.1.4
Name: nearest.worker.internal
Address: 10.210.0.3
Name: nearest.worker.internal
Address: 10.210.0.4
```
The prefixes can be used with service ID and machine-scoped service names, as well (e.g. `nearest.3ecb3a8bbec5fd3f46efb056a934714a.internal` or `rr.0903f0ee483aa97d559eeeaac5e22283.m.worker.internal`).